-
-
Save taylorfinnell/5349b8085d57836a45be7637055e0692 to your computer and use it in GitHub Desktop.
| /* | |
| A Bluetooth to MQTT for integrating the ChromaComfort smart bathroom fan into Home Assistant. | |
| This is an extremley rough sketch of an esp32 firmware for bringing the ChromaComfort Smart Fan into | |
| HomeAssistant. It's meant to be flashed on an esp32. | |
| This firmware allows the esp32 to both recv status updates about the fan and also issue requests to the fan. This means | |
| that Home Assistant will stay in sync even if the fan is controlled by the remote. | |
| Note that you will not be able to use your fan as a BT speaker without first unplugging the ESP32. | |
| This is a best guess at the BT communnications. Lots of information was gained by inspecting the android application. | |
| Things mostly work okay, but it's not perfect. | |
| */ | |
| #include <Arduino.h> | |
| #include <ArduinoHA.h> | |
| #include <BluetoothSerial.h> | |
| #include <WiFi.h> | |
| #include <cppQueue.h> | |
| // ########## | |
| // ### Customize Me | |
| uint8_t address[6] = {0x4c, 0x72, 0x74, 0xf2, 0xec, 0xa}; | |
| #define BROKER_ADDR IPAddress(192, 168, 10, 43) | |
| #define WIFI_SSID "CHANGEME" | |
| #define WIFI_PASSWORD "CHANGEME" | |
| #define MQTT_USER "CHANGEME" | |
| #define MQTT_PASS "CHANGEME" | |
| // ########## | |
| #define DEVICE_PIN "1234" | |
| #define CODE_CMD_START 58 | |
| #define CODE_CMD_LENGTH 17 | |
| #define CODE_VERSION 1 | |
| #define CODE_CTRL_CMD_1 0 | |
| #define CODE_CTRL_CMD_2 64 | |
| #define CODE_TURN_FAN_ON 1 | |
| #define CODE_TURN_FAN_OFF 2 | |
| #define CODE_TURN_LIGHT_ON 3 | |
| #define CODE_TURN_LIGHT_OFF 4 | |
| #define CODE_TURN_ON_RGB 5 | |
| #define CODE_TURN_OFF_RGB 6 | |
| #define CODE_SAVE_FAVORITE_COLOR1 13 | |
| #define CODE_ACTIVATE_FAVORITE_COLOR1 11 | |
| #define CODE_DEACTIVATE_FAVORITE_COLOR1 12 | |
| #define CODE_COUNTDOWN_ON 17 | |
| #define CODE_COUNTDOWN_OFF 18 | |
| #define CODE_SAVE_CUSTOM_PATTERN 42 | |
| #define CODE_ACTIVATE_CUSTOM_PATTERN 32 | |
| #define CODE_DEACTIVATE_CUSTOM_PATTERN 33 | |
| WiFiClient client; | |
| HADevice device; | |
| HAMqtt mqtt(client, device); | |
| BluetoothSerial SerialBT; | |
| int lastUpdateAt = 0; | |
| int acks = 0; | |
| int txs = 0; | |
| int rxs = 0; | |
| int heartbeats = 0; | |
| int panics = 0; | |
| HASwitch fan("fan"); | |
| HASwitch wallRgb("wallrgb"); | |
| HALight light("light", HALight::BrightnessFeature | HALight::RGBFeature); | |
| HASensorNumber uptimeSensor("uptime"); | |
| HASensorNumber errorSensor("errors"); | |
| HASensorNumber txSensor("tx"); | |
| HASensorNumber rxSensor("rx"); | |
| HASensorNumber acksSensor("acks"); | |
| typedef struct Packet { | |
| byte header; | |
| byte len; | |
| uint8_t data[CODE_CMD_LENGTH]; | |
| }; | |
| typedef struct TxCmd { | |
| byte version = 5; | |
| byte ctrl_cmd_1 = CODE_CTRL_CMD_1; | |
| byte ctrl_cmd_2 = CODE_CTRL_CMD_2; | |
| byte type = 0; | |
| byte r = 0; | |
| byte g = 0; | |
| byte b = 0; | |
| byte dimmer = 0; | |
| byte speed = 30; | |
| byte sweep_color_value_1 = 1; | |
| byte sweep_color_value_2 = 24; | |
| byte duration = 0; | |
| byte timer_1_value = 0; | |
| byte timer_2_value = 0; | |
| byte timer_3_value = 0; | |
| byte timer_4_value = 0; | |
| byte data_end = 0; | |
| }; | |
| typedef struct StatusRxCmd { | |
| byte version = 5; | |
| byte control1; | |
| byte control2; | |
| byte status_mask; | |
| byte unk; | |
| byte brightness; | |
| }; | |
| typedef struct AckRxCmd { | |
| byte version = 5; | |
| byte control1; | |
| byte control2; | |
| byte end; | |
| }; | |
| int applyGammaCorrection(int r, double t) { | |
| return (int)(255 * pow((double)r / 255, t)); | |
| } | |
| #define IMPLEMENTATION FIFO | |
| #define NB_ITEMS 25 | |
| Packet t_dat[NB_ITEMS]; | |
| Packet r_dat[NB_ITEMS]; | |
| cppQueue tx( | |
| sizeof(Packet), NB_ITEMS, IMPLEMENTATION, false, t_dat, | |
| sizeof(t_dat)); // Instantiate queue with static queue data arguments | |
| cppQueue rx( | |
| sizeof(Packet), NB_ITEMS, IMPLEMENTATION, false, r_dat, | |
| sizeof(r_dat)); // Instantiate queue with static queue data arguments | |
| Packet createPacket(uint8_t header, const uint8_t* data, size_t dataSize) { | |
| Packet packet; | |
| packet.header = header; | |
| packet.len = (uint8_t)dataSize; | |
| memcpy(packet.data, data, dataSize); | |
| return packet; | |
| } | |
| void turnFanOn() { | |
| TxCmd cmd; | |
| cmd.type = CODE_TURN_FAN_ON; | |
| Packet packet = createPacket(58, (const uint8_t*)&cmd, 17); | |
| tx.push(&packet); | |
| } | |
| void activateFavColor(byte brightness) { | |
| TxCmd cmd1; | |
| cmd1.type = CODE_ACTIVATE_FAVORITE_COLOR1; | |
| cmd1.dimmer = brightness; | |
| Packet packet = createPacket(58, (const uint8_t*)&cmd1, 17); | |
| tx.push(&packet); | |
| } | |
| void setRGB(byte r, byte g, byte b) { | |
| TxCmd cmd0; | |
| cmd0.type = CODE_SAVE_FAVORITE_COLOR1; | |
| cmd0.r = r; | |
| cmd0.g = g; | |
| cmd0.b = b; | |
| Packet packet = createPacket(58, (const uint8_t*)&cmd0, 17); | |
| tx.push(&packet); | |
| } | |
| void deactivateFavColor() { | |
| TxCmd cmd1; | |
| cmd1.type = CODE_DEACTIVATE_FAVORITE_COLOR1; | |
| Packet packet = createPacket(58, (const uint8_t*)&cmd1, 17); | |
| tx.push(&packet); | |
| } | |
| void turnOnWallRGB() { | |
| TxCmd cmd1; | |
| cmd1.type = CODE_TURN_ON_RGB; | |
| Packet packet = createPacket(58, (const uint8_t*)&cmd1, 17); | |
| tx.push(&packet); | |
| } | |
| void turnOffWallRGB() { | |
| TxCmd cmd1; | |
| cmd1.type = CODE_TURN_OFF_RGB; | |
| Packet packet = createPacket(58, (const uint8_t*)&cmd1, 17); | |
| tx.push(&packet); | |
| } | |
| void turnFanOff() { | |
| TxCmd cmd; | |
| cmd.type = CODE_TURN_FAN_OFF; | |
| Packet packet = createPacket(58, (const uint8_t*)&cmd, 17); | |
| tx.push(&packet); | |
| } | |
| int fromHABrightness(uint8_t b) { | |
| // HA is 0->255 we are 0->100 | |
| float x = (float)b / 255.0f; | |
| x *= 100.0f; | |
| return (int)x; | |
| } | |
| int toHABrightness(uint8_t b) { | |
| // HA is 0->255 we are 0->100 | |
| float x = (float)b / 100.0f; | |
| x *= 255.0f; | |
| return (int)x; | |
| } | |
| void onSwitchCommandFan(bool state, HASwitch* sender) { | |
| if (state == sender->getCurrentState()) { | |
| return; | |
| } | |
| if (state) { | |
| turnFanOn(); | |
| } else { | |
| turnFanOff(); | |
| } | |
| sender->setState(state); | |
| } | |
| void onSwitchCommandWallRgb(bool state, HASwitch* sender) { | |
| if (state == sender->getCurrentState()) { | |
| return; | |
| } | |
| if (state) { | |
| turnOnWallRGB(); | |
| } else { | |
| turnOffWallRGB(); | |
| } | |
| sender->setState(state); | |
| } | |
| void onStateCommand(bool state, HALight* sender) { | |
| if (state == sender->getCurrentState()) { | |
| return; | |
| } | |
| if (state) { | |
| activateFavColor(fromHABrightness(sender->getCurrentBrightness())); | |
| } else { | |
| deactivateFavColor(); | |
| } | |
| sender->setState(state); | |
| } | |
| void onBrightnessCommand(uint8_t brightness, HALight* sender) { | |
| if ((int)brightness == (int)sender->getCurrentBrightness()) { | |
| return; | |
| } | |
| activateFavColor(fromHABrightness(brightness)); | |
| sender->setBrightness(brightness); | |
| } | |
| void onRGBColorCommand(HALight::RGBColor color, HALight* sender) { | |
| setRGB(applyGammaCorrection(color.red, 4), | |
| applyGammaCorrection(color.green, 4), | |
| applyGammaCorrection(color.blue, 4)); | |
| sender->setRGBColor(color); | |
| } | |
| void printPacket(Packet packet) { | |
| Serial.printf("Packet (len=%d, header=%d): ", packet.len, packet.header); | |
| Serial.printf("0x%02X ", packet.header); | |
| Serial.printf("0x%02X ", packet.len); | |
| for (int i = 0; i < packet.len; i++) { | |
| Serial.printf("0x%02X ", packet.data[i]); | |
| } | |
| Serial.print("\r\n"); | |
| } | |
| void onBTData(const uint8_t* buffer, size_t size) { | |
| // the length does not include the length and header byte | |
| if (buffer[0] != 58) { | |
| Serial.printf("Got invalid packet: %d, %d\r\n", size, buffer[1]); | |
| return; | |
| } | |
| Packet packet = createPacket(buffer[0], buffer + 2, buffer[1]); | |
| printPacket(packet); | |
| // If we have tx to send, let's only queue acks. | |
| rx.push(&packet); | |
| } | |
| void setup() { | |
| Serial.begin(115200); | |
| Serial.println("Starting..."); | |
| // Unique ID must be set! | |
| byte mac[6]; | |
| WiFi.macAddress(mac); | |
| device.setUniqueId(mac, sizeof(mac)); | |
| // connect to wifi | |
| WiFi.begin(WIFI_SSID, WIFI_PASSWORD); | |
| while (WiFi.status() != WL_CONNECTED) { | |
| Serial.print("."); | |
| delay(500); // waiting for the connection | |
| } | |
| Serial.println(); | |
| Serial.println("Connected to the network"); | |
| // set device's details (optional) | |
| device.setName("ChromaComfort BT to MQTT Bridge"); | |
| device.setSoftwareVersion("1.0.0"); | |
| fan.onCommand(onSwitchCommandFan); | |
| fan.setName("Fan"); // optional | |
| wallRgb.onCommand(onSwitchCommandWallRgb); | |
| wallRgb.setName("Wall RGB"); // optional | |
| light.onStateCommand(onStateCommand); | |
| light.onBrightnessCommand(onBrightnessCommand); // optional | |
| light.onRGBColorCommand(onRGBColorCommand); // optional | |
| light.setName("Light"); | |
| uptimeSensor.setIcon("mdi:home"); | |
| uptimeSensor.setName("Uptime"); | |
| uptimeSensor.setUnitOfMeasurement("s"); | |
| errorSensor.setIcon("mdi:alert-circle"); | |
| errorSensor.setName("Errors"); | |
| txSensor.setIcon("mdi:transfer-up"); | |
| txSensor.setName("TX"); | |
| rxSensor.setIcon("mdi:transfer-down"); | |
| rxSensor.setName("RX"); | |
| acksSensor.setIcon("mdi:call-received"); | |
| acksSensor.setName("Acks"); | |
| mqtt.begin(BROKER_ADDR, MQTT_USER, MQTT_PASS); | |
| Serial.println("Connected to mqtt"); | |
| SerialBT.begin("ChromaComfort Companion", true); | |
| SerialBT.setTimeout(2000); | |
| SerialBT.setPin(DEVICE_PIN); | |
| SerialBT.onData(onBTData); | |
| Serial.println("Attempting to connect"); | |
| bool connected = SerialBT.connect(address); | |
| while (!connected) { | |
| Serial.println("Failed to connect...trying again"); | |
| connected = SerialBT.connect(address); | |
| } | |
| Serial.println("Connected to BT"); | |
| } | |
| int heartbeats_since_ack = 0; | |
| bool firstRun = true; | |
| void loop() { | |
| mqtt.loop(); | |
| if (!firstRun) { | |
| acksSensor.setValue(0); | |
| rxSensor.setValue(0); | |
| txSensor.setValue(0); | |
| errorSensor.setValue(0); | |
| uptimeSensor.setValue(0); | |
| firstRun = false; | |
| } | |
| if ((millis() - lastUpdateAt) > 2000) { // update in 2s interval | |
| unsigned long uptimeValue = millis() / 1000; | |
| uptimeSensor.setValue((int)uptimeValue); | |
| heartbeats += 1; | |
| if (tx.getCount() > 0) { | |
| heartbeats_since_ack += 1; | |
| } | |
| lastUpdateAt = millis(); | |
| rxSensor.setValue(rxs); | |
| } | |
| if (tx.getCount() > 0) { | |
| Serial.printf("It's been %d heartbeats since last ack", | |
| heartbeats_since_ack); | |
| Packet packet; | |
| tx.peek(&packet); | |
| Serial.println("About to send packet: "); | |
| printPacket(packet); | |
| int written = SerialBT.write((uint8_t*)&packet, sizeof(packet)); | |
| if (written != sizeof(packet)) { | |
| Serial.printf("Failed to write: %d\n", written); | |
| } else { | |
| SerialBT.flush(); | |
| } | |
| txs += 1; | |
| txSensor.setValue(txs); | |
| } | |
| if (rx.getCount() > 0) { | |
| rxs += 1; | |
| Packet packet; | |
| rx.pop(&packet); | |
| // Device has acked our tx, pop it. | |
| if (packet.len == 4 && tx.getCount() > 0) { | |
| AckRxCmd* ack = (AckRxCmd*)(packet.data); | |
| if (ack->control1 == 160 && ack->control2 == 64) { | |
| acks += 1; | |
| acksSensor.setValue(acks); | |
| tx.pop(&packet); | |
| heartbeats_since_ack = 0; | |
| } | |
| } | |
| // Nothing to be sent or acked so we can update our status | |
| if (packet.len == 17 && tx.getCount() == 0) { | |
| StatusRxCmd* status = (StatusRxCmd*)(packet.data); | |
| if (status->control1 == 160 && status->control2 == 65) { | |
| Serial.println("Status update..."); | |
| byte s = status->status_mask; | |
| bool isFanOn = ((s >> 7) & 1) == 1; | |
| bool isLightOn = ((s >> 6) & 1) == 1; | |
| bool rgbButton = ((s >> 5) & 1) == 1; | |
| bool rgbSweep = ((s >> 4) & 1) == 1; | |
| bool isFavoriteColor1Active = ((s >> 3) & 1) == 1; | |
| bool isFavoriteColor2Active = ((s >> 2) & 1) == 1; | |
| bool userPattern = ((s >> 1) & 1) == 1; | |
| bool reservedBit = ((s >> 0) & 1) == 1; | |
| int brightness = status->brightness; | |
| Serial.printf( | |
| "Light: %d, Fan: %d, isFavoriteColor1Active: %d, wallRGBButton: " | |
| "%d, Brightness: %d\r\n", | |
| isLightOn, isFanOn, isFavoriteColor1Active, rgbButton, brightness); | |
| fan.setState(isFanOn); | |
| light.setState(isLightOn || isFavoriteColor1Active); | |
| light.setBrightness(toHABrightness(brightness)); | |
| wallRgb.setState(rgbButton); | |
| } | |
| } | |
| } | |
| // about 2 seconds | |
| if (heartbeats_since_ack > 2) { | |
| Serial.println("Flushing the queue from panic"); | |
| tx.flush(); | |
| heartbeats_since_ack = 0; | |
| panics += 1; | |
| errorSensor.setValue(panics); | |
| } | |
| } |
How do I use this with homeassistant? Any help is appreciated
I could not figure out how to do the music player :(. I think it should be possible but I could not quite sort out how to stream music from the esp32 to the fan (or if that was even the best/correct method).
If you know the correct route to take please let me know :)
To use this with HA you can compile/flash it to an esp32 device using the Arduino IDE. When the esp32 device boots I believe HA should auto-discover it (you may need to install the MQTT integration). I can try to make a better guide and repository if needed
Great work. Is this a piggy back system or is the ChromaComfort chip flashed?
Piggy back, the original chip/fan is untouched
I can't thank you enough for this! The amount of time I spent looking for a way to integrate my fan in to HA is embarrassing. This was my first time working with any kind device like the esp32 but it wasn't as bad as I thought. After a few youtube videos and a little help from gemini to fix some minor compiling errors and I got it working in about an hour. Thanks again!
This is awesome! Any plans to expose the speaker in the unit as a media player to HA? That would enable use of the BT speaker without having to remove the ESP32 (in theory at least). I have one of these units and would love to test this out / give any feedback I have if you would want it 👍