diff --git a/LICENSE b/LICENSE index 37f19cc..71a436d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 Lev Aronsky +Copyright (c) 2023 NicoIIT Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 17e2cf6..6067fa9 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,28 @@ -# Lev's ESPHome Components +# BLE ADV ESPHome Components -Custom components for ESPHome +Custom components for ESPHome using BLE Advertising -## Lamps based on BLE Advertising +## Fans / Lamps controlled by BLE Advertising -Use this for various Chinese lamps that are controlled via BLE advertising packets. Supported apps: +Use this for various Chinese lamps that are controlled via BLE advertising packets. +Supported apps: -* LampSmart Pro (tested against Marpou Ceiling Light) -* ZhiJia (tested against aftermarket LED drivers; only the latest version is currently supported) +* LampSmart Pro +* Lamp Smart Pro - Soft Lighting / Smart Lighting +* FanLamp Pro +* ApplianceSmart +* Vmax smart +* Zhi Jia +* Other (Legacy), removed app from play store: 'FanLamp', 'ControlSwitch' -Details can be found [here](components/ble_adv_light/README.md). +Details can be found [here](components/ble_adv_controller/README.md). -## LampSmart Pro (deprecated) - -Using this component directly is deprecated, and it will be removed in the future. Please switch to -the above component with the LampSmart Pro configuration. -Used for Marpou Ceiling Light - see details [here](components/lampsmart_pro_light/README.md). +## Credits +Based on the initial work from: +* @MasterDevX, [lampify](https://github.com/MasterDevX/lampify) +* @flicker581, [lampsmart_pro_light](https://github.com/flicker581/esphome-lampsmart) +* @aronsky, [ble_adv_light](https://github.com/aronsky/esphome-components) +* @14roiron, [zhijia encoders](https://github.com/aronsky/esphome-components/issues/11), [investigations](https://github.com/aronsky/esphome-components/issues/18) +* All testers and bug reporters from the initial threads: + * https://community.home-assistant.io/t/controlling-ble-ceiling-light-with-ha/520612/199 + * https://github.com/aronsky/esphome-components/pull/17 diff --git a/components/ble_adv_controller/CUSTOM.md b/components/ble_adv_controller/CUSTOM.md new file mode 100644 index 0000000..8d82c2f --- /dev/null +++ b/components/ble_adv_controller/CUSTOM.md @@ -0,0 +1,396 @@ +# ble_adv_controller - Details and Custom commands + +This component is reproducing the BLE advertising messages sent by android applications and/or remotes in order to control a device, itself composed of one or several lights / fans. In order to do so, some dev guys have uncompiled the software of those applications and extracted the command they supported as well as how those applications were encoding the BLE advertising messages issued and tried to reproduce them. + +# What we know of the android applications using BLE advertising + +## The bases +There are a few basics to know about android applications exchanging messages with devices: +* BLE advertising is a kind of **broadcast**: it sends messages in the air and not specifically to a given target, meaning that **ALL** BLE devices near to the controlling phone will receive the messages. Each device will then try to decode it, check if it is the effective target of the message and process the action in case it is or ignore it if it is not. +* Each message advertising is working as followed from the app point of view: + - **Encoding**: A message is prepared based on the command issued and given to BLE advertising stack + - **Start**: The advertising is started + - **Wait**: wait for a few milliseconds (duration in the config) during which the message is repeatidly delivered to whoever listens + - **Stop**: The advertising is stopped, the message is no more emitted +* The device listens to any BLE Advertising message emited with the following process: + - if the message cannot be interpreted (wrong encoding), discard it + - if the message is not containing the correct identifier, discard it + - if the same message was already processed, discard it + - else process it +* **Pairing** consists in having the phone (or controller) and the device agreeing on an identifier to be sent in all messages and that would indicate the device is the effective target of the message. This is done by sending a pairing message including an identifier generated by the app/controller to the device. When the device receives this message with an identifier it does not know it will just ... ignore it ... EXCEPT if it has been restarted (power off/on) less than 5 seconds ago, in this case it will accept the new identifier. This is the choosen way to have a specific device accepting a new identifer, and not all the devices in the roomm... +* Pairing with several controllers is possible, at least a remote and a phone, but it seems the phone apps and our apps may have to share the same identifier. +* When the device is delivered to the end-user, it will no more change, meaning that the messages it receives to be controlled will have to be always the same. This particularly means that if an application is able to control it at a given time, any new version of this application in the future (new android version, bug fix, ...) will have to support the send of those exact same messages, and then the last version of the app will generate different variants corresponding to previous encoding at a given time. + +## The messages +The BLE Advertising messages are composed of different parts: +* The BLE Advertising Header and stack which are always the same and part of the BLE standard, the behaviour can be customized vi 'esp_ble_adv_params_t' +* The BLE Advertising data (max 31 bytes) composed of repeated data sections: + * 1 byte for the length of the section (length of the data + length of the type) + * 1 byte for the type of the section + * the data of the section + + Example: + * raw data: 02.01.02.1B.03.F9.08.49.13.F0.69.25.4E.31.51.BA.32.08.0A.24.CB.3B.7C.71.DC.8B.B8.97.08.D0.4C + * section 1: + * 02.01.02 + * => Length: 2, type: 01 (AD_FLAG), data: 02 + * section 2: + * 1B.03.F9.08.49.13.F0.69.25.4E.31.51.BA.32.08.0A.24.CB.3B.7C.71.DC.8B.B8.97.08.D0.4C + * => Length: 1B (27), type: 03 (UUIDs), data: F9.08.49.13.F0.69.25.4E.31.51.BA.32.08.0A.24.CB.3B.7C.71.DC.8B.B8.97.08.D0.4C (26 uint) + +The goal of this component is to convert each entity action into this raw data and emit it. Still this is not so simple as there are several applications using this methodology, and for each application different ways of encoding the data that evovled over the years. + +Supported Applications: +* **Zhi jia**, includes several encoding variant: v0 (MSC16) / v1 (MSC26) / v2 (MSC26A) +* **FanLamp Pro**, includes variants v1 / v2 / v3 +* **LampSmart Pro**, includes variants: v1 / v3 +* Other (probably remotes), found in other repositories. Similar to what FanLamp Pro and Lamp Smart Pro generate but with small differences, variants v1a / v1b / v2 / v3. + +To build the Data section corresponding to a command, the encoding is done as follow: +* Convert the command and its parameters into a base structure containing among other: + * A **command id**: a code based on one byte identifying a command. Different in between applications but usually common between variants of a same app. + * Command **arguments**: 0 to 4 bytes containing parameters of the command (fan speed value, light brigthness, ...) +* Add parameters from the controller part: + * A **type**: a 2 bytes code. No one found out yet the use of it, seems to be always 0x0100 but can be set to anything it seems + * An **index** / group_index: a 1 byte code allowing to specify a sub-identifier. + * An **identifier**: a 2 or 4 bytes code generated and exchanged during pairing, identifying a device in a 'unique' way + * A **transaction count**: a 1 byte code increased by 1 on each transaction. Allows the device to identify if a message was already read and processed, and not re-process it (guess) +* Signing: compute an id based on a hard coded key allowing the device to be sure the message is coming only from the allowed app and would not be an interference from another message from another app (or a way to try to prevent smart people to reproduce the message...) +* CRC computing: to be sure the message is complete and not corrupted +* Whitening: to avoid the message to be mostly zeros + +# Capturing Advertising messages +It can be usefull to capture the messages sent by a phone app or a remote already paired with the device you want to control, in order to extract some info such as the `identifier` for instance. + +This can be done quite 'easily' using the [ESPHome BLE Tracker](https://esphome.io/components/esp32_ble_tracker.html) component, and this config (you need to have at least one ble_adv_controller defined): + +``` +esp32_ble_tracker: + scan_parameters: + interval: 15ms + window: 15ms + on_ble_advertise: + then: + - lambda: 'ble_adv_static_handler->capture(x, true);' + +ble_adv_controller: + - id: my_controller + encoding: fanlamp_pro +``` + +This will generate DEBUG logs such as those ones each time a raw advertising message is received: +``` +[17:37:52][D][ble_adv_handler:297]: raw - 02.01.02.03.03.27.18.15.16.27.18.A8.01.51.3F.91.A2.00.E2.DC.38.AD.F0.64.03.07.00.00.00 (29) +``` + +It TRIES to capture EVERYTHING, meaning: +* if you have existing Bluetooth devices doing BLE Advertising, you will also capture the logs of those devices... +* it tries to capture as much as it can, but it can miss some of the messages, I would say it captures 75% of the messages + +Moreover, the phone app or the remotes are generating several advertising messages for a same command issued, for example the ***FanLamp Pro app is generating 6 distinct raw message for each action*** (2 commands for each variant with different AD Flag section...) + +For each message captured, it tries to decode it with each encoder available, and if one matches it produces the following: +``` +[16:08:56][D][ble_adv_handler:268]: raw - 02.01.01.1B.03.F0.08.30.80.B8.F7.E1.27.DB.F4.95.C1.65.7D.A4.9F.67.F6.B6.30.34.8B.53.2B.38.A2 (31) +[16:08:56][I][lampsmart_pro - v3:233]: Decoded OK - tx: 131, cmd: '0x11', Args: [0,0,0,0] +[16:08:56][I][ble_adv_handler:243]: config: +[16:08:56]ble_adv_controller: +[16:08:56] - id: my_controller_id +[16:08:56] encoding: lampsmart_pro +[16:08:56] variant: v3 +[16:08:56] forced_id: 0xB4555A3F +[16:08:56][D][lampsmart_pro - v3:103]: UUID: '0xB4555A3F', index: 0, tx: 131, cmd: '0x11', args: [0,0,0,0] +[16:08:56][D][ble_adv_handler:254]: enc - 02.01.19.1B.03.F0.08.30.80.B8.F7.E1.27.DB.F4.95.C1.65.7D.A4.9F.67.F6.B6.30.34.8B.53.2B.38.A2 (31) +[16:08:56][I][ble_adv_handler:256]: Decoded / Re-encoded with NO DIFF +``` + +The config loggued gives you what iall the info you would need to setup a copy of the remote / phone app you listen! + +STILL if you listen to your phone app, you will end up with more or less 6 configs (and 3 removing the dupe, one for each variant), so you will have to find the relevant one as the controlled device probably listen to only ONE of those variants... + +# Raw injection service +If you captured a raw advertising message emitted by a phone app or a remote, just define a dummy controller and you can re inject the message as such with the following HA service: +``` +esphome: _inject_raw_ +``` +Just put the raw hexa string in the `raw` parameters, accepted formats (leading 0x, trailing length, spaces and dots are removed automatically): +``` +0201021B03F9084913F069254E3151BA32080A24CB3B7C71DC8BB89708D04C +0x0201021B03F9084913F069254E3151BA32080A24CB3B7C71DC8BB89708D04C +02 01 02 1B 03 F9 08 49 13 F0 69 25 4E 31 51 BA 32 08 0A 24 CB 3B 7C 71 DC 8B B8 97 08 D0 4C +02.01.02.1B.03.F9.08.49.13.F0.69.25.4E.31.51.BA.32.08.0A.24.CB.3B.7C.71.DC.8B.B8.97.08.D0.4C +02.01.02.1B.03.F9.08.49.13.F0.69.25.4E.31.51.BA.32.08.0A.24.CB.3B.7C.71.DC.8B.B8.97.08.D0.4C (31) +``` +This will lead to the component re emitting the raw command as such, which can be useful if none of the encoding / variant is working for you, as you can still be able to build a template light in Home Assistant directly if you managed to capture the ON and OFF command, such as this one: +``` +light: + - platform: template + lights: + my_raw_light: + friendly_name: "My Raw Light" + turn_on: + service: esphome.my_device_inject_raw_my_controller + data: + raw: 02.01.02.1B.16.F0.08.10.00.DC.36.2F.22.9A.A0.0F.BE.FC.F9.68.C1.28.0C.1D.AD.09.DA.19.A9.35.23 + turn_off: + service: esphome.my_device_inject_raw_my_controller + data: + raw: 02.01.02.1B.16.F0.08.10.00.DF.59.DC.4B.A4.38.7A.C8.A8.B0.6D.F8.3F.FD.B7.A9.FC.7C.4A.C0.AA.7C +``` +The controller parameters forced_id / index / encoding / variant are ignored, but the `duration` and `max_duration` are available. The messages are also put in the sequencing queue and benefit from the centralized advertising of the component. + +# Raw decoding Service +If you captured a raw advertising message emitted by a phone app or a remote, you can try to have it decoded by the existing decoders available in the app, using the service: +``` +esphome: _raw_decode +``` +Just put the raw hexa string in the `raw` parameters, see previous section for the formats. +You will see the result of the decoding in the logs of the application, as such: +``` +[16:08:56][D][ble_adv_handler:268]: raw - 02.01.01.1B.03.F0.08.30.80.B8.F7.E1.27.DB.F4.95.C1.65.7D.A4.9F.67.F6.B6.30.34.8B.53.2B.38.A2 (31) +[16:08:56][I][lampsmart_pro - v3:233]: Decoded OK - tx: 131, cmd: '0x11', Args: [0,0,0,0] +[16:08:56][I][ble_adv_handler:243]: config: +[16:08:56]ble_adv_controller: +[16:08:56] - id: my_controller_id +[16:08:56] encoding: lampsmart_pro +[16:08:56] variant: v3 +[16:08:56] forced_id: 0xB4555A3F +[16:08:56][D][lampsmart_pro - v3:103]: UUID: '0xB4555A3F', index: 0, tx: 131, cmd: '0x11', args: [0,0,0,0] +[16:08:56][D][ble_adv_handler:254]: enc - 02.01.19.1B.03.F0.08.30.80.B8.F7.E1.27.DB.F4.95.C1.65.7D.A4.9F.67.F6.B6.30.34.8B.53.2B.38.A2 (31) +[16:08:56][I][ble_adv_handler:256]: Decoded / Re-encoded with NO DIFF +``` +* raw: the hexa string injected +* Decoded OK: the message was decoded by an encoder, here 'lampsmart_pro - v3'. Action Parameters are loggued. +* config: the config to setup for your controller to duplicate the source of the message. The defined controller will use the same identifier and will then be able to control the same device without any need to pair! +* enc: the hexa string as it would be re-encoded by the encoder from the parameters extracted for the controller and the Action parameters. +* the result of the comparison between what was injected and what was re encoded, to be sure the encoder would work OK! This comparison ignores the irrelevant differences in AD_Flag section (02.01.01 / 02.01.19). + +# Custom Command Service +if you are using 'api' component to communicate with HA, for each ble_adv_controller a HA service is available: +* name of the service: +``` +esphome: _cmd_ +``` +![screenshot](../../doc/images/BleAdvService.jpg) + +It uses as a bases the ble_adv_controller, and then its associated parameters and features (encoding, variant, identifier, transaction count). It allows to specify directly command parameters (cmd, arg0..3) skipping the 'Convert' part and processing the encoding from there (add controller params, Signing, CRC, Whitening and emitting command). + +## Known commands +For info here are the "known" commands already extracted from code and their corresponding command id and parameter values when known, for each main encoding sets: +* **ZhiJia v0**: + * uses ZhiJia encoding, variant v0: encoding MSC16. + * all 3 args can be used +* **ZhiJia v1**: + * uses ZhiJia encoding, variant v1: encoding MSC26. + * Only arg0 used for known commands, BUT arg1 and arg2 available in message structure +* **ZhiJia v2**: + * uses ZhiJia encoding, variant v2: encoding MSC26A. + * Only arg0 used for known commands, BUT arg1 and arg2 available in message structure +* **FanLamp v1**: + * for FanLamp Pro / SmartLamp Pro, with variant v1, and Other for variant v1a and v1b using the same data structure. + * arg0 and arg1 used for known commands, but arg2 available (and used by pair command btw...) +* **FanLamp v2**: + * for FanLamp Pro / SmartLamp Pro, with variant v2 and v3 (v3 is v2 plus a signing step) + * arg0 never used but available. + * arg1, arg2 and arg3 used depending on the commands + +| Command | ZhiJia v0 | ZhiJia v1 | ZhiJia v2 | FanLamp v1 | FanLamp v2 | +|--------------|-------------|-------------|-------------|-------------------|-------------------| +| pair | 0xB4 | 0xA2 | 0xA2 | 0x28, garbage in args... | 0x28 | +| unpair | 0xB0 | 0xA3 | 0xA3 | 0x45 | 0x45 | +| light_on | 0xB3 | 0xA5 | 0xA5 | 0x10 | 0x10 | +| light_off | 0xB2 | 0xA6 | 0xA6 | 0x11 | 0x11 | +| light_dim | 0xB5, arg1=0..3, arg2| 0xAD, arg0 | 0xAD, arg0 | N/A | N/A | +| light_cct | 0xB7, arg1=0..3, arg2| 0xAE, arg0 | 0xAE, arg0 | N/A | N/A | +| light_wcolor | N/A | N/A | N/A | 0x21, arg0 arg1 | 0x21, arg2, arg3 | +| light_sec_on | N/A | N/A | N/A | 0x12 | 0x12 | +| light_sec_off| N/A | N/A | N/A | 0x13 | 0x13 | +| fan_on | N/A | N/A | 0xD2 | 0x31, arg0=0 | 0x31, arg2=0 | +| fan_off | N/A | N/A | 0xD3 | 0x31, arg0=0 | 0x31, arg2=0 | +| fan_speed | N/A | N/A | 0xDB + (2*)speed | N/A | N/A | +| fan_onoff_speed(3) | N/A | N/A | 0xD2 | 0x31, arg0=0..3 | 0x31, arg2=0..3 | +| fan_onoff_speed(6) | N/A | N/A | 0xD2 | 0x32, arg0=0..6, arg1=6 | 0x31, arg2=0..6, arg1=0x20 | +| fan_dir | N/A | N/A | N/A | 0x15, arg0=0..1 | 0x15, arg1=0..1 | +| fan_osc | N/A | N/A | N/A | N/A | 0x16, arg1=0..1 | + +NOTE: the cmd code given are hexa codes, **you have to translate them into decimal for use in HA service**, use Windows Calculator in programmer mode. + +## Guessing commands +Well one can try all options and values... Or try to read the decompiled software ! + +### ZhiJia +* Source code [HERE](https://gist.github.com/NicoIIT/39bf095b80806253772fa7eb82a532b2) +* Commands are calling function 'sendMessage' with 4 parameters, and optionally a 5th one for duration, or function 'startAdvertising' +``` +sendMessage(cmd1, args1[3], cmd2, args2[3], duration) +``` +This function is then processing the following advertising sequentially and in loop, each of them advertised during 120ms and then going to the next one, limited to 60 advertising (7.2s max then) if no other command is issued: +1. Advertise (cmd2, args2[3]) encoded with MSC26A.msc26: v2 +2. Advertise (cmd2, args2[3]) encoded with MSC26.msc26: v1 +3. Advertise (cmd1, args1[3]) encoded with MSC16.msc16: v0 + +Only one of those advertising is effectively needed to control a Lamp, thus in this component you can choose which one to use (variant). + +``` +startAdvertising(cmd2, args2[3], duration) +``` +This function is advertising (cmd2, args2[3]) encoded with MSC26A.msc26 (v2) during 3s max if no other command is issued. + +--- +**NOTE**: Minimum advertising time + +The minimum advertising time depends on the parameter of the next command to be executed (`duration` in the function called), it could be INTERVAL_CLICK (80ms, used most of the time) or INTERVAL_ACTION (360ms, used for setBeganColorTemperature and setBeganBrightness only): if the new command is issued before the minimum advertising time it specifies, then it is just... ignored... + +The ColorTemperature and Brigthness commands seem to be done in 2 steps (began and end), despite those 2 steps are implemented the same way except for the minimum advertising time... Taking the example of the Color Temperature: +* When the Color Temperature progress bar starts to be changed, the setBeganColorTemperature is called a first time +* It is then called each time the progress bar is reported as changing. As per previous comment on minimum advertising time, this means most of those calls are ignored, keeping one every 360ms max. +* When the progress bar stops being changed a last call is done to setEndColorTemperature. The only difference is that it is taken into account only if a command was not previously issued in the last 80ms. +--- + +The cmd codes are negative in the decompiled software, no idea why, but to transform them one can add 256. + +For the custom command, the mapping is the following: +* cmd2 + 256 -> cmd +* args2[0] -> arg0 +* args2[1] -> arg1 +* args2[2] -> arg2 + +Example custom commands: +* Fan Speed to speed_level 3: + * software: + ``` + public void setFanEeSpeed(int i) { + byte[] bArr = {0, 0, 0}; + switch (i) { + ... + case 2: + startAdvertising((byte) -35, bArr, INTERVAL_CLICK); + break; + case 3: + startAdvertising((byte) -34, bArr, INTERVAL_CLICK); + break; + ... + } + ``` + => there is a different cmd code for each speed, args are not used ......... + => cmd = 256 + (-34) = 222, in fact 256 + (-37) + speed_level + * custom command parameters: {cmd: 222, arg0: 0, arg1: 0, arg2: 0, arg3: 0} + + +### FanLamp v1 (and v1a / v1b) +* Source code [HERE](https://gist.github.com/NicoIIT/527f21f7bbbd766b9844d5efbef86959) +* Commands are calling function 'getMessage' with 3, 4 or 5 parameters most of the time: +``` +getMessage(cmd, index, tx_count) +getMessage(cmd, index, value1, tx_count) +getMessage(cmd, index, value1, value2, tx_count) +``` +All are at the end calling the same encoding function with 7 params, for info only: +``` +getmessage(cmd, LampData.mMasterControlAddr, index, value1, value2, LampConfig.UNK1, tx_count) +``` +For the custom command, the mapping is the following: +* cmd -> cmd +* index -> index +* value1 -> arg0 +* value2 -> arg1 +* unused: type, arg2, arg3 + +Example custom commands: +* Fan Level to 3 for fan with index 0: + * software: + ``` + public void sendFanLevelMessage(int i, int i2, int i3) { + startSendData(getMessage(49, i, i2, i3)); + } + ``` + * custom command parameters: {type: 0, index: 0, cmd: 49, arg0: 3, arg1: 0, arg2: 0, arg3: 0} +* Fan Gear to 5 for fan with index 0: + * software: + ``` + public void sendFanGearMessage(int i, int i2, int i3) { + startSendData(getMessage(50, LampData.mMasterControlAddr, i, i2, 6, LampConfig.UNK1, i3)); + } + ``` + * custom command parameters: {cmd: 50, arg0: 5, arg1: 6, arg2: 0, arg3: 0}. + +Note that the Fan Gear command is the same as Fan Level but it gives 6 levels instead of 3. Depend on the device to be controlled. + +### FanLamp v2 (v2 and v3) +* Source code [HERE](https://gist.github.com/aronsky/f433de654f008fedb5161e08eb32c33e) and [HERE](https://gist.github.com/aronsky/f2d8afab134d15f34256187a82a53a9c) +* Commands are feeding the following data structure: + * blev2para.type -> type (but with no impact...); + * blev2para.group_index -> index; + * blev2para.cmd -> cmd; + * blev2para.para[0] -> arg0; + * blev2para.para[1] -> arg1; + * blev2para.para[2] -> arg2; + * blev2para.para[3] -> arg3; + +Example custom commands: +* Fan Level to 3 for fan with index 0: + * software: + ``` + jbyteArray Java_com_alllink_encodelib_Tool_blevbFanSpeed(JNIEnv *env,jclass jobj,jint type,jlong addr,jint index,jshort generic_flag,jshort value) + { + ... + memset(&blev2para,0,0x18); + blev2para.type = (uint16_t)type; + blev2para.addr = (uint32_t)addr; + blev2para.group_index = (uint8_t)index; + blev2para.cmd = 0x31; + blev2para.para[1] = (uint8_t)generic_flag; + blev2para.para[2] = (uint8_t)value; + ble_v2_encode(&blev2para,decoded_data); + ... + } + ``` + * custom command parameters: {cmd: 49, arg0: 0, arg1: 32, arg2: 3, arg3: 0} + +Note that arg1 should take the 'generic_flag' value, but no idea how to build this one, this is where the '**guess**' happens: after 31 unsuccessful tries, the 32nd worked! + +# Component Implementation + +## The ESP BLE Advertising Technical Stack +As seen before, the component is based on standard ESP BLE Advertising. As per Bluetooth standard, this advertising consists in having the Bluetooth stack sending repeatidly messages at a given rate that is customizable by the caller, in between 20ms and 10s, see parameters doc [here](https://github.com/espressif/esp-idf/blob/main/components/bt/host/bluedroid/api/include/api/esp_gap_ble_api.h#L401). + +In this component it is setup to the minimum 20ms, meaning that when the component starts ESP BLE advertising with a given message, the message is repeatidly sent every 20ms until the component requests to stop. + +## Implementation +The whole process is controlled by the `BleAdvHandler`, which has a unique class instance in order to ensure what is sent to the ESP BLE stack is controlled in a unique way even if there are several controllers requesting to publish messages at the same time. `BlAdvHandler` is in charge of gathering all requests coming, organize them and ensure they are published as fast as possible to the ESP BLE stack. It is the only class communicating with the ESP BLE Stack. + +Each device controlled has a corresponding instance of `BleAdvController` (configured by `ble_adv_controller`yaml section). This controller references the `BleAdvHandler` and is the only one to communicate with it. The controller also references its `BleAdvEncoder` type, which is the class in charge of building advertising messages. + +Each Home Assistant entity (button, light, fan) has a corresponding instance of `BleAdvEntity`, implemented by +`BleAdvButton`, `BleAdvLight` and `BleAdvFan`. Those entities are receiving the requests coming from Home Assistant. +They are linked to their parent `BleAdvController`. + +## The command flow +When an entity class is receiving a request from Home Assistant, the following is performed: +* The entity converts the request into a standardized `Command` and asks its linked controller to process it. +* The controller finds the relevant encoder to be used as per its configuration and asks it to build the message(s) corresponding to the command. There can be several messages, as in case of encoding for all variants. +* The controller puts the messages built in its processing queue, potentially discarding previous messages of the same type that would be pending in the processing queue. +* The controller is dequeuing the processing queue, for each message or group of messages: + * it requests the `BleAdvHandler` to start advertising the message(s) + * it requests the `BleAdvHandler` to stop advertising the message(s) after a given duration which can be: + * the minimum `duration` if there are other messages pending in the queue + * the maximum `max_duration` if there is no other message after those ones +* On start advertising request the `BleAdvHandler` puts the message(s) in its sequential queue and processes them: + * Each message is advertised for a given short base `seq_duration` (setup by the controller) + * Once this duration is expired, the advertising is stopped, the message is put back at the end of the queue and the next message in the queue starts to be advertized. All messages in the processing queue are then advertized sequentially allowing several controllers to emit messages "simultaneously", (in fact repeatedly by dedicated sequence) + * In case there is only one message in the sequential queue, the advertising is not stopped until it effectively receives a stop advertising request. +* On stop advertising request for a given message, the `BleAdvHandler` removes the message from its sequential queue. + +This command flow ensures: +* that the controller is emiting only one command at a time to target its controlling device, and let it control the global emitting duration +* that several controllers can process commands at the same time + +## Durations +The setting of the multiple duration parameters is important and should respect rules: +* The `seq_duration` should be significantly higher than the ESP BLE minimum interval (20ms). If setup to 30ms, the message will be advertized twice during this duration. If setup to 150ms it will be advertized 8 times. + * It corresponds to the maximum time needed by the device to receive (but not process) a command. + * Recommended value is 50ms. + * If the device controlled is far from the ESPcontroller, there could be garbage on the line and this seq_duration would need to be increased. + * If the seq_duration is too low your device will go in panic mode so be careful +* The `duration` should be higher than `seq_duration`, and should correspond to the time the controlled device is taking to effectivelly process a command and be ready to process the next one. +* The `max_duration` should be significantly higher than `duration`. It correspond to the maximum time the command is emitted in case no other command is coming for this controller. It is mostly useful for Pairing commands, recommended is 3000 (3s). diff --git a/components/ble_adv_controller/README.md b/components/ble_adv_controller/README.md new file mode 100644 index 0000000..6542b63 --- /dev/null +++ b/components/ble_adv_controller/README.md @@ -0,0 +1,306 @@ +# ble_adv_controller + +## Goal and requirements +The goal of this component is to build a hardware proxy [ESPHome based](https://esphome.io/) in between Home Assistant and Ceiling Fans and Lamps controlled using Bluetooth Low Energy(BLE) Advertising. If your Ceiling Fan or lamp is working with one of the following Android App, then you should be able to control it: +* LampSmart Pro +* Lamp Smart Pro - Soft Lighting / Smart Lighting +* FanLamp Pro +* ApplianceSmart +* Vmax smart +* Zhi Jia +* Other (Legacy), removed app from play store: 'FanLamp', 'ControlSwitch' - (could not be validated) + +This component is an [ESPHome external component](https://esphome.io/components/external_components.html). In order to use it you will need to have: +* A basic knowledge of [ESPHome](https://esphome.io/). A good entry point is [here](https://esphome.io/guides/getting_started_hassio.html). +* The [ESPHome integration](https://www.home-assistant.io/integrations/esphome/) in Home Assistant +* An [Espressif](https://www.espressif.com/) microcontroller supporting Bluetooth v4, such as any [ESP32](https://www.espressif.com/en/products/socs/esp32) based model. You can find some for a few dollars on any online marketplace searching for ESP32. + +When the setup will be completed you will have a new ESPHome device available in Home assistant, exposing standard entities such as: +* Light(s) entity allowing the control of Color Temperature and Brightness +* Fan entity allowing the control of Speed ( 3 or 6 levels) and direction forward / reverse +* Button entity, for pairing + +## Known Limitations +The technical solution implemented by manufacturers to control those devices is `BLE Advertising` and it comes with limitations: +* The communication is unidirectional meaning any change done by one of the controlling element will never be known by the other elements: for example if you switch on the light using the remote, then neither Home Assistant nor the phone App will know it and will then not show an updated state. +* Each command needs to be maintained for a given minimum `duration` which is customizable by configuration but has drawbacks: + * If the value is too small, the targetted device may not receive it and then not process the command + * If the value is too high, each command is queued one after the other and then sending commands at a high rate will make delay more and more the commands. + * The use of ESPHome light `transitions` is not recommended (and deactivated by default) as it generates high command rate. A mitigation has been implemented in order to remove commands of the same type from the processing queue when a new one is received, it seriously improves the behavior of the component but it is still not perfect. +* Some commands are the same for ON and OFF, working as a Toggle in fact. Sending high rate commands will cause the mix of ON and OFF commands and result in flickering and desynchronization of states. + +## How to try it + +1. As a preliminary step, be sure to be able to create a base ESPHome configuration from the ESPHome Dashboard, install it to your ESP32, have it available in Home Assistant and be able to access the logs (needed in case of issue). This is a big step if you are new to ESPHome but on top of [ESPHome doc](https://esphome.io/guides/getting_started_hassio.html) you will find tons of tutorial on the net for that. +2. Add to your up and running ESPHome configuration the reference to this repo using ([ESPHome external component](https://esphome.io/components/external_components.html)) +3. Add a lamp controller `ble_adv_controller` specifying (see example configuration): + * its `id` to be referenced by entities it controls. The `id` is also the reference used to pair with the device: if it is changed the device needs to be re-paired with the new `id`. + * its `encoding`, this is fully known from the phone controlling app, see the possible values in the examples below. + * its `variant`, this is the version of the encoding. Keep the default value (last version) as a first step, this will probably be the good one if your light is recent. +4. Add one or several light or fan entities to the configuration with the `ble_adv_controller` platform +5. Add a `pair` configuration button to ease the pairing action from HA +6. Install and flash the ESP32 device +7. Find the relevant `variant` and `duration` corresponding to your device thanks to [Dynamic configuration](#dynamic-configuration) or the full setup by [setting without pairing](#setup-without-pairing) +8. Enjoy controlling your BLE light with Home Assistant! + +## Known issues and not implemented or tested features + +* Does not support RGB lights for now, request it if needed. +* ZhiJia encoding v0 and v1 (may be needed for older version of Lamps controlled with ZhiJia app) have not been tested (as no end user available to test it and help debugging) and then may not work. Contact us if you have such device and we will make it work together! + +## Example configuration: basic lamp using ZhiJia encoding v2 and Pair button + +```yaml +ble_adv_controller: + - id: my_controller + encoding: zhijia + +light: + - platform: ble_adv_controller + ble_adv_controller_id: my_controller + name: Kitchen Light + +button: + - platform: ble_adv_controller + ble_adv_controller_id: my_controller + name: Pair + cmd: pair +``` + +## Example configuration: basic device using FanLamp Pro encoding v3, with light, fan and Pair button + +```yaml +ble_adv_controller: + - id: my_controller + encoding: fanlamp_pro + +light: + - platform: ble_adv_controller + ble_adv_controller_id: my_controller + name: Main Light + +fan: + - platform: ble_adv_controller + ble_adv_controller_id: my_controller + name: Fan + +button: + - platform: ble_adv_controller + ble_adv_controller_id: my_controller + name: Pair + cmd: pair +``` + +## Example configuration: All options and their default values + +```yaml +ble_adv_controller: + # A controller per device, or per remote in fact as it has the same role + - id: my_controller + # encoding: + # 'zhijia', for 'Zhi Jia' app + # 'fanlamp_pro', for 'FanLamp Pro' app or 'ApplianceSmart' App + # 'lampsmart_pro', for 'LampSmart Pro' App, 'LampSmart Pro-Soft Lighting' App or 'Vmax smart' App + # 'remote', for some of the remotes we know + # 'other', for legacy variants from initial repos, may correspond to removed app 'FanLamp' or 'ControlSwitch' + # 'lampsmart_pro', 'other' or 'remote' are very similar to 'fanlamp_pro' but slightly different + # if nothing is specified for them in what follows, consider them as 'fanlamp_pro' + encoding: fanlamp_pro + # variant: variant of the encoding + # For 'zhijia': Can be v0 (MSC16), v1 (MSC26) or v2 (MSC26A), default is v2 + # For 'fanlamp_pro': Can be any of v1, v2 or v3, default is v3 + # For 'lampsmart_pro': Can be v1, v2 or v3, default is v3 + # For 'remote': can be v1 or v3 (only remotes we know for now..), default is v3 + # For 'other': Can be any of v1a / v1b / v2 / v3, they are corresponding to legacy variants extracted from old version of this repo. + # Kept here for backward compatibility but should not be needed. + # Can be configured dynamically in HA directly, device 'Configuration' section, "Encoding". + variant: v3 + # max_duration (default 3000, range 300 -> 10000): the maximum duration in ms during which the command is advertized. + # if a command is received before the 'max_duration' but after the 'duration', it is processed immediately + # Increasing this parameter will have no major consequences, the component will just keep advertize the command + # Could be interesting at pairing time to have the pairing command advertized for a long time + max_duration: 3000 + # duration (default 200, range 100 -> 500): the MINIMUM duration in ms during which the command is sent. + # It corresponds to the maximum time the controlled device is taking to process a command and be ready to receive a new one. + # if a command is received before the 'duration' it is queued and processed later, + # if there is already a similar command pending, in this case the pending command is removed from the queue + # Increasing this parameter will make the combination of commands slower. See 'Dynamic Configuration'. + # Can be configured dynamically in HA directly, device 'Configuration' section, "Duration". + duration: 200 + # reversed: reversing the cold / warm at encoding time, needed for some controllers + # default to false + reversed: false + # forced_id: provide the 4 bytes identifier key extracted from your app phone traffic + # to share the same key than the phone + # example: 0xBFF62757 + # For ZhiJia, default to 0xC630B8 which was the value hard-coded in ble_adv_light component. Max 0xFFFFFF. + # For FanLamp: default to 0, uses the hash id computed by esphome from the id/name of the controller + forced_id: 0 + # index: a supplementary counter on the phone app to distinguish in between several devices + # only usefull if you want to copy the phone app setup + index: 0 + # show_config (default true): shows the dynamic configuration in the device info page in Home Automation + show_config: true + +light: + - platform: ble_adv_controller + # ble_adv_controller_id: the ID of your controller + ble_adv_controller_id: my_controller + # name: the name as it will appear in Home Assistant + name: First Light + # min_brightness: % minimum brightness supported by the light before it shuts done + # just setup this value to 0, then test your lamp by decreasing the brightness percent by percent. + # when it switches off, you have the min_brightness to setup here. + # Default to 1% + # Can be configured dynamically in HA directly, device 'Configuration' section, "Min Brightness". + min_brightness: 1% + # constant_brightness (default to false): the natural white is usually brighter than the cold or warm color + # if you setup constant_brightness to true, the natural white will have same brightness than cold and warm ones + constant_brightness: false + # separate_dim_cct (default to false): Zhi Jia ONLY + # if true, 2 distinct commands will be sent to the lamp for brightness and color temperature + # may be needed for some Zhi Jia v2 lamps that do not support a unique command + separate_dim_cct: false + + - platform: ble_adv_controller + ble_adv_controller_id: my_controller + name: Secondary Light + # secondary: true. Qualifies this light as the secondary light to be controlled. + # Exclusive with any options for brightness / cold / warm + secondary: true + +fan: + - platform: ble_adv_controller + ble_adv_controller_id: my_controller + name: my fan + # speed_count: the number of speed level available on your remote / app. Can be 0 / 3 / 6. + # if not properly setup the remote and this component does not behave properly together + # only speed 6 is available for zhijia, and this is the default + speed_count: 6 + # use_direction: ability to change the fan direction forward / reverse. + # default to true, not available for zhijia + use_direction: true + # use_oscillation: ability to start / stop the fan oscillation. + # default to false, only available for FanLamp v2 / v3 + use_oscillation: false + # forced_refresh_on_start: forces the send of oscillation / direction at fan start + # some fans are resetting the direction / oscillation when the fan is stopped + # so when they are switched on, the direction / oscillation state in HA is no more in sync + # with the effective state of the device + # default to true. + forced_refresh_on_start: true + +button: + - platform: ble_adv_controller + ble_adv_controller_id: my_controller + name: Pair + # cmd: the action to be executed when the button is pressed + # any of 'pair', 'unpair', 'custom', 'light_on', ... + cmd: pair +``` + +## Good to know + +### Dynamic configuration +It could be painful to find the correct variant or the correct duration by each time modifying the option in the yaml configuration of esphome. In order to help a dynamic configuration is available in Home Assistant 'Configuration' part of the esphome device: + +![choice encoding](../../doc/images/Choice_encoding.jpg) + +* `Variant` is customizable in the encoding selection part, the idea is to do the following: + * Start with the 'Zhi Jia - All' or 'FanLamp -All' depending on the corresponding phone app, and perform the Pairing with this: the component will send the pairing message with all variants, as the phone app is doing. If you need the pairing to be kept emited for a long time, increase the 'max_duration' option. + * Once done you can test to switch ON / OFF the main light to check the pairing went OK + * Then you can try the variants one by one and switch ON / OFF to find the exact variant used by your lamp + +* `Duration` is customizable, the lowest the better it makes the device answer faster. It is recommended to try to switch very fast ON/OFF the main light several times: If you end up with wrong state (light ON whereas HA state is OFF, or the reverse) it means the duration is too low and needs to be increased. + +Once you managed to define the relevant values (without the need to re flash each time!), you can save the values in the yaml config, and even hide the dynamic configuration with the option `show_config: false` + +### Setup without pairing +Yes, it is possible! +If you already have a phone app or a remote already paired with the device to be controlled, then it means there is already an existing `identifier` (and possibly `index`) setup on the control device, so you just need to find it and setup your controller accordingly (`forced_id` and `index`). + +Check the [tech section](CUSTOM.md#capturing-advertising-messages) to know how to capture and log those parameters. + +If you already captured some traffic with app such as `nRF Connect` or `wireshark`, you can inject them directly using this [HA Service](CUSTOM.md#raw-injection-service). + +### Reverse Cold / Warm +If this component works, but the cold and warm temperatures are reversed (that is, setting the temperature in Home Assistant to warm results in cold/blue light, and setting it to cold results in warm/yellow light), add a `reversed: true` line to your `ble_adv_controller` config. + +### Cold / Warm and brightness do not work on Zhi Jia v1 or v2 lamp +If the brightness or color temperature does not work for your Zhi Jia v1 or v2 lamp, please setup the `separate_dim_cct` option to true and try again. + +### Minimum Brightness +If the minimum brightness is too bright, and you know that your light can go darker - try changing the minimum brightness via the `min_brightness` configuration option (it takes a percentage) or directly via the dynamic configuration in HA `Min Brightness`. + +### Saving state on ESP32 reboot +Fan and Light entities are inheriting properties from their ESPHome parent [Fan](https://esphome.io/components/fan/index.html) and [Light](https://esphome.io/components/light/index.html), in particular they implement the `restore_mode` which has default value `ALWAYS_OFF` on ESPHome base lights and fans. + +The default has been forced to value `RESTORE_DEFAULT_OFF` on the fan and light entities of this component so that they could remember their last state (ON/OFF, but also brightness, color temperature and fan speed). You can still modify this value if it is not OK for you. + +### Action on turn on/off +Some devices perform some automatic actions when the main light or the fan are switched off, as for instance switch off the secondary light, or reset the Fan Direction or Oscillation status. +In order to update the state the same way in Home Assistant, simply add an [automation](https://esphome.io/components/light/index.html#light-on-turn-on-off-trigger) in your config, for instance: +* Switch Off the secondary light at the same time than the main light: +```yaml +light: + - platform: ble_adv_controller + ble_adv_controller_id: my_controller + name: Main Light + on_turn_off: + then: + light.turn_off: secondary_light + - platform: ble_adv_controller + ble_adv_controller_id:my_controller + id: secondary_light + name: Secondary Light + secondary: true +``` +* Reset Fan Direction and Oscillation at Fan turn_on: +```yaml +fan: + - platform: ble_adv_controller + ble_adv_controller_id: my_controller + id: my_fan + name: My Fan + on_turn_on: + then: + fan.turn_on: + id: my_fan + direction: forward + oscillating: false +``` +This triggers a second ON message, but also the proper state of direction and oscillating if they are reset by the device at turn off. + +### Holding Pair button +If the pairing process of your lamp is requesting you to "hold the pair button on the phone app while switching on the lamp", it is not a reason to do the same in HA! The phone app has its own way to advertise messages for a long time which is in their case to maintain the button. + +Our way of handling it is different: the HA button is only sending ONE request to the component that will start advertising process during a maximum of `max_duration` if no other command is requested. If the default 3s is not enough for the process of your lamp you can increase it to 10000 (10s) or regularly press the pair button and the pairing will not stop being emitted (or only for a very very very short time). + +### Light Transition +Esphome is providing features to handle 'smooth' transitions. While they are not very well supported by this component due to the BLE ADV technilogy used, they can still help reproduce the app phone behavior in such case. + +For instance, the Zhi Jia app is always sending at least 2 messages when the brightness or color temperature is updated and this can be achieved the same way by setting the light property 'default_transition_length' to the same value than 'duration', as per default 200ms. (NOT TESTED but may work and solve flickering issues) + +### Warning in logs +You can have the following warnings in logs: +``` +[16:08:56][W][component:237]: Component 'xxxx' took a long time for an operation (56 ms). +[16:08:56][W][component:238]: Components should block for at most 30 ms. +``` +This is not an issue, it just means ESPHome considered it spent too much time in our component and that it should not be the case. It has no real impact but it means the Api / Wifi / BLE may not work properly or work slowly. +In fact this is mostly due to ESPHome itself as 99% of the time spent in our component is due to the logs... Each line of log needs 10ms to be processed ............... So 5 lines of log during a transaction and we are over the limit... A [PR in ESPHome](https://github.com/esphome/esphome/pull/5373) is pending to solve this. + +In order to avoid this, once you have finalized your config and all is working OK, I recommend to [setup the log level to INFO](https://esphome.io/components/logger.html) instead of DEBUG (which is the default). + +### No encoder is working, help !!!!! +Two different cases here: +* You have successfully paired your device with one of the referenced app at the top of this guide, but you cannot pair the controller you setup whereas you followed this guide. This is not normal, open an Issue on this git repo specifying your full config (anonymized), the phone App to whcich it is paired, the steps you followed and the corresponding DEBUG logs. +* Your device is not working with any of the phone app referenced (another one or a remote?), but you want to have it work with HA! Try to [capture the advertising messages](CUSTOM.md#capturing-advertising-messages) generated by your controlling app or remote. + * If nothing is captured, your device is not controlled by BLE advertising, and we cannot do anything for you. + * If something is captured and a config is extracted, then all is OK! + * If something is captured but no config is extracted but your are in a hurry, you can still build a HA Template light from the captured messages, using the [Raw injection service](CUSTOM.md#raw-injection-service) + * If something is captured but no config is extracted and you are not in a hurry, and you manage to control your device from another phone app, then open an Issue to have your phone app integrated to this component! + +## For the very tecki ones + +If you want to discover new features for your lamp and that you are able to understand the code of this component as well as the code of the applications that generate commands, you can try to send custom commands, details [here](CUSTOM.md). \ No newline at end of file diff --git a/components/ble_adv_controller/__init__.py b/components/ble_adv_controller/__init__.py new file mode 100644 index 0000000..2f255f9 --- /dev/null +++ b/components/ble_adv_controller/__init__.py @@ -0,0 +1,289 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.core import ID +from esphome.const import ( + CONF_DURATION, + CONF_ID, + CONF_NAME, + CONF_REVERSED, + CONF_TYPE, + CONF_INDEX, + CONF_VARIANT, + PLATFORM_ESP32, +) +from esphome.cpp_helpers import setup_entity +from .const import ( + CONF_BLE_ADV_CONTROLLER_ID, + CONF_BLE_ADV_ENCODING, + CONF_BLE_ADV_FORCED_ID, + CONF_BLE_ADV_MAX_DURATION, + CONF_BLE_ADV_SEQ_DURATION, + CONF_BLE_ADV_SHOW_CONFIG, +) + +AUTO_LOAD = ["esp32_ble", "select", "number"] +DEPENDENCIES = ["esp32"] +MULTI_CONF = True + +bleadvcontroller_ns = cg.esphome_ns.namespace('bleadvcontroller') +BleAdvController = bleadvcontroller_ns.class_('BleAdvController', cg.Component, cg.EntityBase) +BleAdvEncoder = bleadvcontroller_ns.class_('BleAdvEncoder') +BleAdvMultiEncoder = bleadvcontroller_ns.class_('BleAdvMultiEncoder', BleAdvEncoder) +BleAdvHandler = bleadvcontroller_ns.class_('BleAdvHandler', cg.Component) +BleAdvEntity = bleadvcontroller_ns.class_('BleAdvEntity', cg.Component) + +FanLampEncoderV1 = bleadvcontroller_ns.class_('FanLampEncoderV1') +FanLampEncoderV2 = bleadvcontroller_ns.class_('FanLampEncoderV2') +ZhijiaEncoderV0 = bleadvcontroller_ns.class_('ZhijiaEncoderV0') +ZhijiaEncoderV1 = bleadvcontroller_ns.class_('ZhijiaEncoderV1') +ZhijiaEncoderV2 = bleadvcontroller_ns.class_('ZhijiaEncoderV2') + +BLE_ADV_ENCODERS = { + "fanlamp_pro" :{ + "variants": { + "v1": { + "class": FanLampEncoderV1, + "args": [ 0x83, False ], + "max_forced_id": 0xFFFFFF, + "ble_param": [ 0x19, 0x03 ], + "header": [0x77, 0xF8], + }, + "v2": { + "class": FanLampEncoderV2, + "args": [ [0x10, 0x80, 0x00], 0x0400, False ], + "ble_param": [ 0x19, 0x03 ], + "header": [0xF0, 0x08], + }, + "v3": { + "class": FanLampEncoderV2, + "args": [ [0x20, 0x80, 0x00], 0x0400, True ], + "ble_param": [ 0x19, 0x03 ], + "header": [0xF0, 0x08], + }, + "v1a": { + "legacy": True, + "msg": "please use 'other - v1a' for exact replacement, or 'fanlamp_pro' v1 / v2 / v3 if effectively using FanLamp Pro app", + }, + "v1b": { + "legacy": True, + "msg": "please use 'other - v1b' for exact replacement, or 'fanlamp_pro' v1 / v2 / v3 if effectively using FanLamp Pro app", + }, + }, + "default_variant": "v3", + "default_forced_id": 0, + }, + "lampsmart_pro": { + "variants": { + "v1": { + "class": FanLampEncoderV1, + "args": [ 0x81 ], + "max_forced_id": 0xFFFFFF, + "ble_param": [ 0x19, 0x03 ], + "header": [0x77, 0xF8], + }, + # v2 is only used by LampSmart Pro - Soft Lighting + "v2": { + "class": FanLampEncoderV2, + "args": [ [0x10, 0x80, 0x00], 0x0100, False ], + "ble_param": [ 0x19, 0x03 ], + "header": [0xF0, 0x08], + }, + "v3": { + "class": FanLampEncoderV2, + "args": [ [0x30, 0x80, 0x00], 0x0100, True ], + "ble_param": [ 0x19, 0x03 ], + "header": [0xF0, 0x08], + }, + "v1a": { + "legacy": True, + "msg": "please use 'other - v1a' for exact replacement, or 'lampsmart_pro' v1 / v3 if effectively using LampSmart Pro app", + }, + "v1b": { + "legacy": True, + "msg": "please use 'other - v1b' for exact replacement, or 'lampsmart_pro' v1 / v3 if effectively using LampSmart Pro app", + }, + }, + "default_variant": "v3", + "default_forced_id": 0, + }, + "zhijia": { + "variants": { + "v0": { + "class": ZhijiaEncoderV0, + "args": [], + "max_forced_id": 0xFFFF, + "ble_param": [ 0x1A, 0xFF ], + "header": [ 0xF9, 0x08, 0x49 ], + }, + "v1": { + "class": ZhijiaEncoderV1, + "args": [], + "max_forced_id": 0xFFFFFF, + "ble_param": [ 0x1A, 0xFF ], + "header": [ 0xF9, 0x08, 0x49 ], + }, + "v2": { + "class": ZhijiaEncoderV2, + "args": [], + "max_forced_id": 0xFFFFFF, + "ble_param": [ 0x1A, 0xFF ], + "header": [ 0x22, 0x9D ], + }, + }, + "default_variant": "v2", + "default_forced_id": 0xC630B8, + }, + "remote" : { + "variants": { + "v1": { + "class": FanLampEncoderV1, + "args": [ 0x83, False, True ], + "max_forced_id": 0xFFFFFF, + "ble_param": [ 0x00, 0xFF ], + "header":[0x56, 0x55, 0x18, 0x87, 0x52], + }, + "v3": { + "class": FanLampEncoderV2, + "args": [ [0x10, 0x00, 0x56], 0x0400, True ], + "ble_param": [ 0x02, 0x16 ], + "header": [0xF0, 0x08], + }, + }, + "default_variant": "v3", + "default_forced_id": 0, + }, +# legacy lampsmart_pro variants v1a / v1b / v2 / v3 +# None of them are actually matching what FanLamp Pro / LampSmart Pro apps are generating +# Maybe generated by some remotes, kept here for backward compatibility, with some raw sample + "other" : { + "variants": { + "v1b": { + "class": FanLampEncoderV1, + "args": [ 0x81, True, True, 0x55 ], + "max_forced_id": 0xFFFFFF, + "ble_param": [ 0x02, 0x16 ], + "header": [0xF9, 0x08], + # 02.01.02.1B.03.F9.08.49.13.F0.69.25.4E.31.51.BA.32.08.0A.24.CB.3B.7C.71.DC.8B.B8.97.08.D0.4C (31) + }, + "v1a": { + "class": FanLampEncoderV1, + "args": [ 0x81, True, True ], + "max_forced_id": 0xFFFFFF, + "ble_param": [ 0x02, 0x03 ], + "header": [0x77, 0xF8], + # 02.01.02.1B.03.77.F8.B6.5F.2B.5E.00.FC.31.51.50.CB.92.08.24.CB.BB.FC.14.C6.9E.B0.E9.EA.73.A4 (31) + }, + "v2": { + "class": FanLampEncoderV2, + "args": [ [0x10, 0x80, 0x00], 0x0100, False ], + "ble_param": [ 0x19, 0x16 ], + "header": [0xF0, 0x08], + # 02.01.02.1B.16.F0.08.10.80.0B.9B.DA.CF.BE.B3.DD.56.3B.E9.1C.FC.27.A9.3A.A5.38.2D.3F.D4.6A.50 (31) + }, + "v3": { + "class": FanLampEncoderV2, + "args": [ [0x10, 0x80, 0x00], 0x0100, True ], + "ble_param": [ 0x19, 0x16 ], + "header": [0xF0, 0x08], + # 02.01.02.1B.16.F0.08.10.80.33.BC.2E.B0.49.EA.58.76.C0.1D.99.5E.9C.D6.B8.0E.6E.14.2B.A5.30.A9 (31) + }, + }, + "default_variant": "v1b", + "default_forced_id": 0, + }, +} + +ENTITY_BASE_CONFIG_SCHEMA = cv.Schema( + { + cv.Required(CONF_BLE_ADV_CONTROLLER_ID): cv.use_id(BleAdvController), + } +) + +CONTROLLER_BASE_CONFIG = cv.ENTITY_BASE_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(BleAdvController), + cv.Optional(CONF_DURATION, default=200): cv.All(cv.positive_int, cv.Range(min=100, max=500)), + cv.Optional(CONF_BLE_ADV_MAX_DURATION, default=3000): cv.All(cv.positive_int, cv.Range(min=300, max=10000)), + cv.Optional(CONF_BLE_ADV_SEQ_DURATION, default=100): cv.All(cv.positive_int, cv.Range(min=0, max=150)), + cv.Optional(CONF_REVERSED, default=False): cv.boolean, + cv.Optional(CONF_BLE_ADV_SHOW_CONFIG, default=True): cv.boolean, + cv.Optional(CONF_INDEX, default=0): cv.All(cv.positive_int, cv.Range(min=0, max=255)), + } +) + +def validate_legacy_variant(config): + encoding = config[CONF_BLE_ADV_ENCODING] + variant = config[CONF_VARIANT] + pv = BLE_ADV_ENCODERS[ encoding ]["variants"][ variant ] + if pv.get("legacy", False): + raise cv.Invalid("DEPRECATED '%s - %s', %s" % (encoding, variant, pv["msg"])) + return config + +def validate_forced_id(config): + encoding = config[CONF_BLE_ADV_ENCODING] + variant = config[CONF_VARIANT] + forced_id = config[CONF_BLE_ADV_FORCED_ID] + params = BLE_ADV_ENCODERS[ encoding ] + max_forced_id = params["variants"][ variant ].get("max_forced_id", 0xFFFFFFFF) + if forced_id > max_forced_id : + raise cv.Invalid("Invalid 'forced_id' for %s - %s: %s. Maximum: 0x%X." % (encoding, variant, forced_id, max_forced_id)) + return config + +CONFIG_SCHEMA = cv.All( + cv.Any( + *[ CONTROLLER_BASE_CONFIG.extend( + { + cv.Required(CONF_BLE_ADV_ENCODING): cv.one_of(encoding), + cv.Optional(CONF_VARIANT, default=params["default_variant"]): cv.one_of(*params["variants"].keys()), + cv.Optional(CONF_BLE_ADV_FORCED_ID, default=params["default_forced_id"]): cv.hex_uint32_t, + } + ) for encoding, params in BLE_ADV_ENCODERS.items() ] + ), + validate_forced_id, + validate_legacy_variant, + cv.only_on([PLATFORM_ESP32]), +) + +async def entity_base_code_gen(var, config): + await cg.register_parented(var, config[CONF_BLE_ADV_CONTROLLER_ID]) + await cg.register_component(var, config) + await setup_entity(var, config) + +class BleAdvRegistry: + handler = None + @classmethod + def get(cls): + if not cls.handler: + hdl_id = ID("ble_adv_static_handler", type=BleAdvHandler) + cls.handler = cg.new_Pvariable(hdl_id) + cg.add(cls.handler.set_component_source("ble_adv_handler")) + cg.add(cg.App.register_component(cls.handler)) + for encoding, params in BLE_ADV_ENCODERS.items(): + for variant, param_variant in params["variants"].items(): + if "class" in param_variant: + enc_id = ID("enc_%s_%s" % (encoding, variant), type=param_variant["class"]) + enc = cg.new_Pvariable(enc_id, encoding, variant, *param_variant["args"]) + cg.add(enc.set_ble_param(*param_variant["ble_param"])) + cg.add(enc.set_header(param_variant["header"])) + cg.add(cls.handler.add_encoder(enc)) + return cls.handler + +async def to_code(config): + hdl = BleAdvRegistry.get() + var = cg.new_Pvariable(config[CONF_ID]) + cg.add(var.set_setup_priority(300)) # start after Bluetooth + await cg.register_component(var, config) + await setup_entity(var, config) + cg.add(var.set_handler(hdl)) + cg.add(var.set_encoding_and_variant(config[CONF_BLE_ADV_ENCODING], config[CONF_VARIANT])) + cg.add(var.set_min_tx_duration(config[CONF_DURATION], 100, 500, 10)) + cg.add(var.set_max_tx_duration(config[CONF_BLE_ADV_MAX_DURATION])) + cg.add(var.set_seq_duration(config[CONF_BLE_ADV_SEQ_DURATION])) + cg.add(var.set_reversed(config[CONF_REVERSED])) + if CONF_BLE_ADV_FORCED_ID in config and config[CONF_BLE_ADV_FORCED_ID] > 0: + cg.add(var.set_forced_id(config[CONF_BLE_ADV_FORCED_ID])) + else: + cg.add(var.set_forced_id(config[CONF_ID].id)) + cg.add(var.set_show_config(config[CONF_BLE_ADV_SHOW_CONFIG])) + + diff --git a/components/ble_adv_controller/ble_adv_controller.cpp b/components/ble_adv_controller/ble_adv_controller.cpp new file mode 100644 index 0000000..0d1f497 --- /dev/null +++ b/components/ble_adv_controller/ble_adv_controller.cpp @@ -0,0 +1,183 @@ +#include "ble_adv_controller.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" +#include "esphome/core/application.h" + +namespace esphome { +namespace bleadvcontroller { + +static const char *TAG = "ble_adv_controller"; + +void BleAdvSelect::control(const std::string &value) { + this->publish_state(value); + uint32_t hash_value = fnv1_hash(value); + this->rtc_.save(&hash_value); +} + +void BleAdvSelect::sub_init() { + App.register_select(this); + this->rtc_ = global_preferences->make_preference< uint32_t >(this->get_object_id_hash()); + uint32_t restored; + if (this->rtc_.load(&restored)) { + for (auto & opt: this->traits.get_options()) { + if(fnv1_hash(opt) == restored) { + this->state = opt; + return; + } + } + } +} + +void BleAdvNumber::control(float value) { + this->publish_state(value); + this->rtc_.save(&value); +} + +void BleAdvNumber::sub_init() { + App.register_number(this); + this->rtc_ = global_preferences->make_preference< float >(this->get_object_id_hash()); + float restored; + if (this->rtc_.load(&restored)) { + this->state = restored; + } +} + +void BleAdvController::set_encoding_and_variant(const std::string & encoding, const std::string & variant) { + this->select_encoding_.traits.set_options(this->handler_->get_ids(encoding)); + this->cur_encoder_ = this->handler_->get_encoder(encoding, variant); + this->select_encoding_.state = this->cur_encoder_->get_id(); + this->select_encoding_.add_on_state_callback(std::bind(&BleAdvController::refresh_encoder, this, std::placeholders::_1, std::placeholders::_2)); +} + +void BleAdvController::refresh_encoder(std::string id, size_t index) { + this->cur_encoder_ = this->handler_->get_encoder(id); +} + +void BleAdvController::set_min_tx_duration(int tx_duration, int min, int max, int step) { + this->number_duration_.traits.set_min_value(min); + this->number_duration_.traits.set_max_value(max); + this->number_duration_.traits.set_step(step); + this->number_duration_.state = tx_duration; +} + +void BleAdvController::setup() { +#ifdef USE_API + register_service(&BleAdvController::on_pair, "pair_" + this->get_object_id()); + register_service(&BleAdvController::on_unpair, "unpair_" + this->get_object_id()); + register_service(&BleAdvController::on_cmd, "cmd_" + this->get_object_id(), {"cmd", "arg0", "arg1", "arg2", "arg3"}); + register_service(&BleAdvController::on_raw_inject, "inject_raw_" + this->get_object_id(), {"raw"}); +#endif + if (this->is_show_config()) { + this->select_encoding_.init("Encoding", this->get_name()); + this->number_duration_.init("Duration", this->get_name()); + } +} + +void BleAdvController::dump_config() { + ESP_LOGCONFIG(TAG, "BleAdvController '%s'", this->get_object_id().c_str()); + ESP_LOGCONFIG(TAG, " Hash ID '%X'", this->params_.id_); + ESP_LOGCONFIG(TAG, " Index '%d'", this->params_.index_); + ESP_LOGCONFIG(TAG, " Transmission Min Duration: %d ms", this->get_min_tx_duration()); + ESP_LOGCONFIG(TAG, " Transmission Max Duration: %d ms", this->max_tx_duration_); + ESP_LOGCONFIG(TAG, " Transmission Sequencing Duration: %d ms", this->seq_duration_); + ESP_LOGCONFIG(TAG, " Configuration visible: %s", this->show_config_ ? "YES" : "NO"); +} + +#ifdef USE_API +void BleAdvController::on_pair() { + Command cmd(CommandType::PAIR); + this->enqueue(cmd); +} + +void BleAdvController::on_unpair() { + Command cmd(CommandType::UNPAIR); + this->enqueue(cmd); +} + +void BleAdvController::on_cmd(float cmd_type, float arg0, float arg1, float arg2, float arg3) { + Command cmd(CommandType::CUSTOM); + cmd.cmd_ = (uint8_t)cmd_type; + cmd.args_[0] = (uint8_t)arg0; + cmd.args_[1] = (uint8_t)arg1; + cmd.args_[2] = (uint8_t)arg2; + cmd.args_[3] = (uint8_t)arg3; + this->enqueue(cmd); +} + +void BleAdvController::on_raw_inject(std::string raw) { + this->commands_.emplace_back(CommandType::CUSTOM); + this->commands_.back().params_.emplace_back(); + this->commands_.back().params_.back().from_hex_string(raw); +} +#endif + +bool BleAdvController::enqueue(Command &cmd) { + if (!this->cur_encoder_->is_supported(cmd)) { + ESP_LOGW(TAG, "Unsupported command received: %d. Aborted.", cmd.main_cmd_); + return false; + } + + // Reset tx count if near the limit + if (this->params_.tx_count_ > 120) { + this->params_.tx_count_ = 0; + } + + // Remove any previous command of the same type in the queue, if not used for several purposes + if (cmd.main_cmd_ != CommandType::CUSTOM) { + uint8_t nb_rm = std::count_if(this->commands_.begin(), this->commands_.end(), [&](QueueItem& q){ return q.cmd_type_ == cmd.cmd_; }); + if (nb_rm) { + ESP_LOGD(TAG, "Removing %d previous pending commands", nb_rm); + this->commands_.remove_if( [&](QueueItem& q){ return q.cmd_type_ == cmd.cmd_; } ); + } + } + + // enqueue the new command and encode the buffer(s) + this->commands_.emplace_back(cmd.main_cmd_); + this->cur_encoder_->encode(this->commands_.back().params_, cmd, this->params_); + + // setup seq duration for each packet + bool use_seq_duration = (this->seq_duration_ > 0) && (this->seq_duration_ < this->get_min_tx_duration()); + for (auto & param : this->commands_.back().params_) { + param.duration_ = use_seq_duration ? this->seq_duration_: this->get_min_tx_duration(); + } + + return true; +} + +void BleAdvController::loop() { + uint32_t now = millis(); + if(this->adv_start_time_ == 0) { + // no on going command advertised by this controller, check if any to advertise + if(!this->commands_.empty()) { + QueueItem & item = this->commands_.front(); + this->adv_id_ = this->handler_->add_to_advertiser(item.params_); + this->adv_start_time_ = now; + this->commands_.pop_front(); + } + } + else { + // command is being advertised by this controller, check if stop and clean-up needed + uint32_t duration = this->commands_.empty() ? this->max_tx_duration_ : this->number_duration_.state; + if (now > this->adv_start_time_ + duration) { + this->adv_start_time_ = 0; + this->handler_->remove_from_advertiser(this->adv_id_); + } + } +} + +void BleAdvEntity::dump_config_base(const char * tag) { + ESP_LOGCONFIG(tag, " Controller '%s'", this->get_parent()->get_name().c_str()); +} + +void BleAdvEntity::command(CommandType cmd_type, const std::vector &args) { + Command cmd(cmd_type); + std::copy(args.begin(), args.end(), cmd.args_); + this->get_parent()->enqueue(cmd); +} + +void BleAdvEntity::command(CommandType cmd, uint8_t value1, uint8_t value2) { + this->command(cmd, {value1, value2}); +} + +} // namespace bleadvcontroller +} // namespace esphome diff --git a/components/ble_adv_controller/ble_adv_controller.h b/components/ble_adv_controller/ble_adv_controller.h new file mode 100644 index 0000000..d0a2114 --- /dev/null +++ b/components/ble_adv_controller/ble_adv_controller.h @@ -0,0 +1,153 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/entity_base.h" +#include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" +#ifdef USE_API +#include "esphome/components/api/custom_api_device.h" +#endif +#include "esphome/components/select/select.h" +#include "esphome/components/number/number.h" +#include "ble_adv_handler.h" +#include +#include + +namespace esphome { +namespace bleadvcontroller { + + +// Base class to define a dynamic Configuration +template < class BaseEntity > +class BleAdvDynConfig: public BaseEntity +{ +public: + void init(const char * name, const StringRef & parent_name) { + // Due to the use of sh... StringRef, we are forced to keep a ref on the built string... + this->ref_name_ = std::string(parent_name) + " - " + std::string(name); + this->set_object_id(this->ref_name_.c_str()); + this->set_name(this->ref_name_.c_str()); + this->set_entity_category(EntityCategory::ENTITY_CATEGORY_CONFIG); + this->sub_init(); + this->publish_state(this->state); + } + + // register to App and restore from config / saved data + virtual void sub_init() = 0; + +protected: + std::string ref_name_; + ESPPreferenceObject rtc_{nullptr}; +}; + +/** + BleAdvSelect: basic implementation of 'Select' to handle configuration choice from HA directly + */ +class BleAdvSelect: public BleAdvDynConfig < select::Select > { +protected: + void control(const std::string &value) override; + void sub_init() override; +}; + +/** + BleAdvNumber: basic implementation of 'Number' to handle duration(s) choice from HA directly + */ +class BleAdvNumber: public BleAdvDynConfig < number::Number > { +protected: + void control(float value) override; + void sub_init() override; +}; + +/** + BleAdvController: + One physical device controlled == One Controller. + Referenced by Entities as their parent to perform commands. + Chooses which encoder(s) to be used to issue a command + Interacts with the BleAdvHandler for Queue processing + */ +class BleAdvController : public Component, public EntityBase +#ifdef USE_API + , public api::CustomAPIDevice +#endif +{ +public: + void setup() override; + void loop() override; + virtual void dump_config() override; + + void set_min_tx_duration(int tx_duration, int min, int max, int step); + uint32_t get_min_tx_duration() { return (uint32_t)this->number_duration_.state; } + void set_max_tx_duration(uint32_t tx_duration) { this->max_tx_duration_ = tx_duration; } + void set_seq_duration(uint32_t seq_duration) { this->seq_duration_ = seq_duration; } + void set_forced_id(uint32_t forced_id) { this->params_.id_ = forced_id; } + void set_forced_id(const std::string & str_id) { this->params_.id_ = fnv1_hash(str_id); } + void set_index(uint8_t index) { this->params_.index_ = index; } + void set_encoding_and_variant(const std::string & encoding, const std::string & variant); + void set_reversed(bool reversed) { this->reversed_ = reversed; } + bool is_reversed() const { return this->reversed_; } + bool is_supported(const Command &cmd) { return this->cur_encoder_->is_supported(cmd); } + void set_show_config(bool show_config) { this->show_config_ = show_config; } + bool is_show_config() { return this->show_config_; } + + void set_handler(BleAdvHandler * handler) { this->handler_ = handler; } + void refresh_encoder(std::string id, size_t index); + +#ifdef USE_API + // Services + void on_pair(); + void on_unpair(); + void on_cmd(float cmd, float arg0, float arg1, float arg2, float arg3); + void on_raw_inject(std::string raw); +#endif + + bool enqueue(Command &cmd); + +protected: + + uint32_t max_tx_duration_ = 3000; + uint32_t seq_duration_ = 150; + + ControllerParam_t params_; + + bool reversed_; + + bool show_config_{false}; + BleAdvSelect select_encoding_; + BleAdvEncoder * cur_encoder_{nullptr}; + BleAdvNumber number_duration_; + BleAdvHandler * handler_{nullptr}; + + class QueueItem { + public: + QueueItem(CommandType cmd_type): cmd_type_(cmd_type) {} + CommandType cmd_type_; + std::vector< BleAdvParam > params_; + + // Only move operators to avoid data copy + QueueItem(QueueItem&&) = default; + QueueItem& operator=(QueueItem&&) = default; + }; + std::list< QueueItem > commands_; + + // Being advertised data properties + uint32_t adv_start_time_ = 0; + uint16_t adv_id_ = 0; +}; + +/** + BleAdvEntity: + Base class for implementation of Entities, referencing the parent BleAdvController + */ +class BleAdvEntity: public Component, public Parented < BleAdvController > +{ + public: + virtual void dump_config() override = 0; + + protected: + void dump_config_base(const char * tag); + void command(CommandType cmd, const std::vector &args); + void command(CommandType cmd, uint8_t value1 = 0, uint8_t value2 = 0); +}; + +} //namespace bleadvcontroller +} //namespace esphome diff --git a/components/ble_adv_controller/ble_adv_handler.cpp b/components/ble_adv_controller/ble_adv_handler.cpp new file mode 100644 index 0000000..62bce6c --- /dev/null +++ b/components/ble_adv_controller/ble_adv_handler.cpp @@ -0,0 +1,337 @@ +#include "ble_adv_handler.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" + +#ifdef USE_ESP32_BLE_CLIENT +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#endif + +namespace esphome { +namespace bleadvcontroller { + +static const char *TAG = "ble_adv_handler"; + +void BleAdvParam::from_raw(const uint8_t * buf, size_t len) { + // Copy the raw data as is, limiting to the max size of the buffer + this->len_ = std::min(MAX_PACKET_LEN, len); + std::copy(buf, buf + this->len_, this->buf_); + + // find the data / flag indexes in the buffer + size_t cur_len = 0; + while (cur_len < this->len_ - 2) { + size_t sub_len = this->buf_[cur_len]; + uint8_t type = this->buf_[cur_len + 1]; + if (type == ESP_BLE_AD_TYPE_FLAG) { + this->ad_flag_index_ = cur_len; + } + if ((type == ESP_BLE_AD_MANUFACTURER_SPECIFIC_TYPE) + || (type == ESP_BLE_AD_TYPE_16SRV_CMPL) + || (type == ESP_BLE_AD_TYPE_SERVICE_DATA)){ + this->data_index_ = cur_len; + } + cur_len += (sub_len + 1); + } +} + +void BleAdvParam::from_hex_string(std::string & raw) { + // Clean-up input string + raw = raw.substr(0, raw.find('(')); + raw.erase(std::remove_if(raw.begin(), raw.end(), [&](char & c) { return c == '.' || c == ' '; }), raw.end()); + if (raw.substr(0,2) == "0x") { + raw = raw.substr(2); + } + + // convert to integers + uint8_t raw_int[MAX_PACKET_LEN]{0}; + uint8_t len = std::min(MAX_PACKET_LEN, raw.size()/2); + for (uint8_t i; i < len; ++i) { + raw_int[i] = stoi(raw.substr(2*i, 2), 0, 16); + } + this->from_raw(raw_int, len); +} + +void BleAdvParam::init_with_ble_param(uint8_t ad_flag, uint8_t data_type) { + if (ad_flag != 0x00) { + this->ad_flag_index_ = 0; + this->buf_[0] = 2; + this->buf_[1] = ESP_BLE_AD_TYPE_FLAG; + this->buf_[2] = ad_flag; + this->data_index_ = 3; + this->buf_[4] = data_type; + } else { + this->data_index_ = 0; + this->buf_[1] = data_type; + } +} + +void BleAdvParam::set_data_len(size_t len) { + this->buf_[this->data_index_] = len + 1; + this->len_ = len + 2 + (this->has_ad_flag() ? 3 : 0); +} + +bool BleAdvEncoder::is_supported(const Command &cmd) { + ControllerParam_t cont; + auto cmds = this->translate(cmd, cont); + return !cmds.empty(); +} + +bool BleAdvEncoder::decode(const BleAdvParam & param, Command &cmd, ControllerParam_t & cont) { + // Check global len and header to discard most of encoders + size_t len = param.get_data_len() - this->header_.size(); + const uint8_t * cbuf = param.get_const_data_buf(); + if (len != this->len_) return false; + if (!std::equal(this->header_.begin(), this->header_.end(), cbuf)) return false; + + // copy the data to be decoded, not to alter it for other decoders + uint8_t buf[MAX_PACKET_LEN]{0}; + std::copy(cbuf, cbuf + param.get_data_len(), buf); + return this->decode(buf + this->header_.size(), cmd, cont); +} + +void BleAdvEncoder::encode(std::vector< BleAdvParam > & params, Command &cmd, ControllerParam_t & cont) { + auto cmds = (cmd.main_cmd_ == CommandType::CUSTOM) ? std::vector< Command >({cmd}) : this->translate(cmd, cont); + for (auto & acmd: cmds) { + cont.tx_count_++; + + params.emplace_back(); + BleAdvParam & param = params.back(); + param.init_with_ble_param(this->ad_flag_, this->adv_data_type_); + std::copy(this->header_.begin(), this->header_.end(), param.get_data_buf()); + uint8_t * buf = param.get_data_buf() + this->header_.size(); + + ESP_LOGD(this->id_.c_str(), "UUID: '0x%X', index: %d, tx: %d, cmd: '0x%02X', args: [%d,%d,%d,%d]", + cont.id_, cont.index_, cont.tx_count_, acmd.cmd_, acmd.args_[0], acmd.args_[1], acmd.args_[2], acmd.args_[3]); + + this->encode(buf, acmd, cont); + param.set_data_len(this->len_ + this->header_.size()); + } +} + +void BleAdvEncoder::whiten(uint8_t *buf, size_t len, uint8_t seed) { + uint8_t r = seed; + for (size_t i=0; i < len; i++) { + uint8_t b = 0; + for (size_t j=0; j < 8; j++) { + r <<= 1; + if (r & 0x80) { + r ^= 0x11; + b |= 1 << j; + } + r &= 0x7F; + } + buf[i] ^= b; + } +} + +void BleAdvEncoder::reverse_all(uint8_t* buf, uint8_t len) { + for (size_t i = 0; i < len; ++i) { + uint8_t & x = buf[i]; + x = ((x & 0x55) << 1) | ((x & 0xAA) >> 1); + x = ((x & 0x33) << 2) | ((x & 0xCC) >> 2); + x = ((x & 0x0F) << 4) | ((x & 0xF0) >> 4); + } +} + +void BleAdvMultiEncoder::encode(std::vector< BleAdvParam > & params, Command &cmd, ControllerParam_t & cont) { + uint8_t count = 0; + for(auto & encoder : this->encoders_) { + ControllerParam_t c_cont = cont; // Copy to avoid increasing counts for the same command + encoder->encode(params, cmd, c_cont); + count = std::max(c_cont.tx_count_, count); + } + cont.tx_count_ = count; +} + +bool BleAdvMultiEncoder::is_supported(const Command &cmd) { + bool is_supported = false; + for(auto & encoder : this->encoders_) { + is_supported |= encoder->is_supported(cmd); + } + return is_supported; +} + +void BleAdvHandler::setup() { +#ifdef USE_API + register_service(&BleAdvHandler::on_raw_decode, "raw_decode", {"raw"}); +#endif +} + +void BleAdvHandler::add_encoder(BleAdvEncoder * encoder) { + BleAdvMultiEncoder * enc_all = nullptr; + auto all_enc = std::find_if(this->encoders_.begin(), this->encoders_.end(), + [&](BleAdvEncoder * p){ return p->is_id(encoder->get_encoding(), "All"); }); + if (all_enc == this->encoders_.end()) { + enc_all = new BleAdvMultiEncoder(encoder->get_encoding()); + this->encoders_.push_back(enc_all); + } else { + enc_all = static_cast(*all_enc); + } + this->encoders_.push_back(encoder); + enc_all->add_encoder(encoder); +} + +BleAdvEncoder * BleAdvHandler::get_encoder(const std::string & id) { + for(auto & encoder : this->encoders_) { + if (encoder->is_id(id)) { + return encoder; + } + } + ESP_LOGE(TAG, "No Encoder with id: %s", id.c_str()); + return nullptr; +} + +BleAdvEncoder * BleAdvHandler::get_encoder(const std::string & encoding, const std::string & variant) { + for(auto & encoder : this->encoders_) { + if (encoder->is_id(encoding, variant)) { + return encoder; + } + } + ESP_LOGE(TAG, "No Encoder with encoding: %s and variant: %s", encoding.c_str(), variant.c_str()); + return nullptr; +} + +std::vector BleAdvHandler::get_ids(const std::string & encoding) { + std::vector ids; + for(auto & encoder : this->encoders_) { + if (encoder->is_encoding(encoding)) { + ids.push_back(encoder->get_id()); + } + } + return ids; +} + +uint16_t BleAdvHandler::add_to_advertiser(std::vector< BleAdvParam > & params) { + uint32_t msg_id = ++this->id_count; + for (auto & param : params) { + this->packets_.emplace_back(BleAdvProcess(msg_id, std::move(param))); + ESP_LOGD(TAG, "request start advertising - %d: %s", msg_id, + esphome::format_hex_pretty(param.get_full_buf(), param.get_full_len()).c_str()); + } + params.clear(); // As we moved the content, just to be sure no caller will re use it + return this->id_count; +} + +void BleAdvHandler::remove_from_advertiser(uint16_t msg_id) { + ESP_LOGD(TAG, "request stop advertising - %d", msg_id); + for (auto & param : this->packets_) { + if (param.id_ == msg_id) { + param.to_be_removed_ = true; + } + } +} + +// try to identify the relevant encoder +bool BleAdvHandler::identify_param(const BleAdvParam & param, bool ignore_ble_param) { + for(auto & encoder : this->encoders_) { + if (!ignore_ble_param && !encoder->is_ble_param(param.get_ad_flag(), param.get_data_type())) { + continue; + } + ControllerParam_t cont; + Command cmd(CommandType::CUSTOM); + if(encoder->decode(param, cmd, cont)) { + ESP_LOGI(encoder->get_id().c_str(), "Decoded OK - tx: %d, cmd: '0x%02X', Args: [%d,%d,%d,%d]", + cont.tx_count_, cmd.cmd_, cmd.args_[0], cmd.args_[1], cmd.args_[2], cmd.args_[3]); + + std::string config_str = "config: \nble_adv_controller:"; + config_str += "\n - id: my_controller_id"; + config_str += "\n encoding: %s"; + config_str += "\n variant: %s"; + config_str += "\n forced_id: 0x%X"; + if (cont.index_ != 0) { + config_str += "\n index: %d"; + } + ESP_LOGI(TAG, config_str.c_str(), encoder->get_encoding().c_str(), encoder->get_variant().c_str(), cont.id_, cont.index_); + + // Re encoding with the same parameters to check if it gives the same output + std::vector< BleAdvParam > params; + cont.tx_count_--; // as the encoder will increase it automatically + if(cmd.cmd_ == 0x28) { + // Force recomputation of Args by translate function for PAIR command, as part of encoding + cmd.main_cmd_ = CommandType::PAIR; + } + encoder->encode(params, cmd, cont); + BleAdvParam & fparam = params.back(); + ESP_LOGD(TAG, "enc - %s", esphome::format_hex_pretty(fparam.get_full_buf(), fparam.get_full_len()).c_str()); + bool nodiff = std::equal(param.get_const_data_buf(), param.get_const_data_buf() + param.get_data_len(), fparam.get_data_buf()); + nodiff ? ESP_LOGI(TAG, "Decoded / Re-encoded with NO DIFF") : ESP_LOGE(TAG, "DIFF after Decode / Re-encode"); + + return true; + } + } + return false; +} + +#ifdef USE_API +void BleAdvHandler::on_raw_decode(std::string raw) { + BleAdvParam param; + param.from_hex_string(raw); + ESP_LOGD(TAG, "raw - %s", esphome::format_hex_pretty(param.get_full_buf(), param.get_full_len()).c_str()); + this->identify_param(param, true); +} +#endif + +#ifdef USE_ESP32_BLE_CLIENT +/* Basic class inheriting esp32_ble_tracker::ESPBTDevice in order to access + protected attribute 'scan_result_' containing raw advertisement +*/ +class HackESPBTDevice: public esp32_ble_tracker::ESPBTDevice { +public: + void get_raw_packet(BleAdvParam & param) const { + param.from_raw(this->scan_result_.ble_adv, this->scan_result_.adv_data_len); + } +}; + +void BleAdvHandler::capture(const esp32_ble_tracker::ESPBTDevice & device, bool ignore_ble_param, uint16_t rem_time) { + // Clean-up expired packets + this->listen_packets_.remove_if( [&](BleAdvParam & p){ return p.duration_ < millis(); } ); + + // Read raw advertised packets + BleAdvParam param; + const HackESPBTDevice * hack_device = reinterpret_cast< const HackESPBTDevice * >(&device); + hack_device->get_raw_packet(param); + if (!param.has_data()) return; + + // Check if not already received in the last 300s + auto idx = std::find(this->listen_packets_.begin(), this->listen_packets_.end(), param); + if (idx == this->listen_packets_.end()) { + ESP_LOGD(TAG, "raw - %s", esphome::format_hex_pretty(param.get_full_buf(), param.get_full_len()).c_str()); + param.duration_ = millis() + (uint32_t)rem_time * 1000; + this->identify_param(param, ignore_ble_param); + this->listen_packets_.emplace_back(std::move(param)); + } +} +#endif + +void BleAdvHandler::loop() { + if (this->adv_stop_time_ == 0) { + // No packet is being advertised, process with clean-up IF already processed once and requested for removal + this->packets_.remove_if([&](BleAdvProcess & p){ return p.processed_once_ && p.to_be_removed_; } ); + // if packets to be advertised, advertise the front one + if (!this->packets_.empty()) { + BleAdvParam & packet = this->packets_.front().param_; + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_ble_gap_config_adv_data_raw(packet.get_full_buf(), packet.get_full_len())); + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_ble_gap_start_advertising(&(this->adv_params_))); + this->adv_stop_time_ = millis() + this->packets_.front().param_.duration_; + this->packets_.front().processed_once_ = true; + } + } else { + // Packet is being advertised, check if time to switch to next one in case: + // The advertise seq_duration expired AND + // There is more than one packet to advertise OR the front packet was requested to be removed + bool multi_packets = (this->packets_.size() > 1); + bool front_to_be_removed = this->packets_.front().to_be_removed_; + if ((millis() > this->adv_stop_time_) && (multi_packets || front_to_be_removed)) { + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_ble_gap_stop_advertising()); + this->adv_stop_time_ = 0; + if (front_to_be_removed) { + this->packets_.pop_front(); + } else if (multi_packets) { + this->packets_.emplace_back(std::move(this->packets_.front())); + this->packets_.pop_front(); + } + } + } +} + +} // namespace bleadvcontroller +} // namespace esphome diff --git a/components/ble_adv_controller/ble_adv_handler.h b/components/ble_adv_controller/ble_adv_handler.h new file mode 100644 index 0000000..7cc19c7 --- /dev/null +++ b/components/ble_adv_controller/ble_adv_handler.h @@ -0,0 +1,253 @@ +#pragma once + +#include "esphome/core/defines.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#ifdef USE_API +#include "esphome/components/api/custom_api_device.h" +#endif + +#include +#include +#include + +namespace esphome { + +#ifdef USE_ESP32_BLE_CLIENT +namespace esp32_ble_tracker { + class ESPBTDevice; +} +#endif + +namespace bleadvcontroller { + +enum CommandType { + NOCMD = 0, + PAIR = 1, + UNPAIR = 2, + CUSTOM = 3, + LIGHT_ON = 13, + LIGHT_OFF = 14, + LIGHT_DIM = 15, + LIGHT_CCT = 16, + LIGHT_WCOLOR = 17, + LIGHT_SEC_ON = 18, + LIGHT_SEC_OFF = 19, + FAN_ON = 30, + FAN_OFF = 31, + FAN_SPEED = 32, + FAN_ONOFF_SPEED = 33, + FAN_DIR = 34, + FAN_OSC = 35, +}; + +/** + Command: + structure to transport basic parameters for processing by encoders + */ +class Command +{ +public: + Command(CommandType cmd = CommandType::NOCMD): main_cmd_(cmd) {} + + CommandType main_cmd_; + uint8_t cmd_{0}; + uint8_t args_[4]{0}; +}; + +/** + Controller Parameters + */ +struct ControllerParam_t { + uint32_t id_ = 0; + uint8_t tx_count_ = 0; + uint8_t index_ = 0; + uint16_t seed_ = 0; +}; + +static constexpr size_t MAX_PACKET_LEN = 31; + +class BleAdvParam +{ +public: + BleAdvParam() {}; + BleAdvParam(BleAdvParam&&) = default; + BleAdvParam& operator=(BleAdvParam&&) = default; + + void from_raw(const uint8_t * buf, size_t len); + void from_hex_string(std::string & raw); + void init_with_ble_param(uint8_t ad_flag, uint8_t data_type); + + bool has_ad_flag() const { return this->ad_flag_index_ != MAX_PACKET_LEN; } + uint8_t get_ad_flag() const { return this->buf_[this->ad_flag_index_ + 2]; } + + bool has_data() const { return this->data_index_ != MAX_PACKET_LEN; } + void set_data_len(size_t len); + uint8_t get_data_len() const { return this->buf_[this->data_index_] - 1; } + uint8_t get_data_type() const { return this->buf_[this->data_index_ + 1]; } + uint8_t * get_data_buf() { return this->buf_ + this->data_index_ + 2; } + const uint8_t * get_const_data_buf() const { return this->buf_ + this->data_index_ + 2; } + + uint8_t * get_full_buf() { return this->buf_; } + uint8_t get_full_len() { return this->len_; } + + bool operator==(const BleAdvParam & comp) { return std::equal(comp.buf_, comp.buf_ + MAX_PACKET_LEN, this->buf_); } + + uint32_t duration_{100}; + +protected: + uint8_t buf_[MAX_PACKET_LEN]{0}; + size_t len_{0}; + size_t ad_flag_index_{MAX_PACKET_LEN}; + size_t data_index_{MAX_PACKET_LEN}; +}; + +class BleAdvProcess +{ +public: + BleAdvProcess(uint32_t id, BleAdvParam && param): param_(std::move(param)), id_(id) {} + BleAdvParam param_; + uint32_t id_{0}; + bool processed_once_{false}; + bool to_be_removed_{false}; + + // Only move operators to avoid data copy + BleAdvProcess(BleAdvProcess&&) = default; + BleAdvProcess& operator=(BleAdvProcess&&) = default; +} ; + +/** + BleAdvEncoder: + Base class for encoders, for registration in the BleAdvHandler + and usage by BleAdvController + */ +class BleAdvEncoder { +public: + BleAdvEncoder(const std::string & encoding, const std::string & variant): + id_(encoding + " - " + variant), encoding_(encoding), variant_(variant) {} + + const std::string & get_id() const { return this->id_; } + const std::string & get_encoding() const { return this->encoding_; } + const std::string & get_variant() const { return this->variant_; } + bool is_id(const std::string & ref_id) const { return ref_id == this->id_; } + bool is_id(const std::string & encoding, const std::string & variant) const { return (encoding == this->encoding_) && (variant == this->variant_); } + bool is_encoding(const std::string & encoding) const { return (encoding == this->encoding_); } + + void set_ble_param(uint8_t ad_flag, uint8_t adv_data_type){ this->ad_flag_ = ad_flag; this->adv_data_type_ = adv_data_type; } + bool is_ble_param(uint8_t ad_flag, uint8_t adv_data_type) { return this->ad_flag_ == ad_flag && this->adv_data_type_ == adv_data_type; } + void set_header(const std::vector< uint8_t > && header) { this->header_ = header; } + + virtual std::vector< Command > translate(const Command & cmd, const ControllerParam_t & cont) = 0; + virtual void encode(std::vector< BleAdvParam > & params, Command &cmd, ControllerParam_t & cont); + virtual bool is_supported(const Command &cmd) ; + virtual bool decode(const BleAdvParam & packet, Command &cmd, ControllerParam_t & cont); + +protected: + virtual bool decode(uint8_t* buf, Command &cmd, ControllerParam_t & cont) { return false; }; + virtual void encode(uint8_t* buf, Command &cmd, ControllerParam_t & cont) { }; + + // utils for encoding + void reverse_all(uint8_t* buf, uint8_t len); + void whiten(uint8_t *buf, size_t len, uint8_t seed); + + // encoder identifiers + std::string id_; + std::string encoding_; + std::string variant_; + + // BLE parameters + uint8_t ad_flag_{0x00}; + uint8_t adv_data_type_{ESP_BLE_AD_MANUFACTURER_SPECIFIC_TYPE}; + + // Common parameters + std::vector< uint8_t > header_; + size_t len_{0}; +}; + +#define ENSURE_EQ(param1, param2, ...) if ((param1) != (param2)) { ESP_LOGD(this->id_.c_str(), __VA_ARGS__); return false; } + +/** + BleAdvMultiEncoder: + Encode several messages at the same time with different encoders + */ +class BleAdvMultiEncoder: public BleAdvEncoder +{ +public: + BleAdvMultiEncoder(const std::string encoding): BleAdvEncoder(encoding, "All") {} + virtual void encode(std::vector< BleAdvParam > & params, Command &cmd, ControllerParam_t & cont) override; + virtual bool is_supported(const Command &cmd) override; + void add_encoder(BleAdvEncoder * encoder) { this->encoders_.push_back(encoder); } + + // Not used + virtual std::vector< Command > translate(const Command & cmd, const ControllerParam_t & cont) { return std::vector< Command >(); }; + virtual bool decode(const BleAdvParam & packet, Command &cmd, ControllerParam_t & cont) override { return false; } + +protected: + std::vector< BleAdvEncoder * > encoders_; +}; + +/** + BleAdvHandler: Central class instanciated only ONCE + It owns the list of registered encoders and their simplified access, to be used by Controllers. + It owns the centralized Advertiser allowing to advertise multiple messages at the same time + with handling of prioritization and parallel send when possible + */ +class BleAdvHandler: public Component +#ifdef USE_API + , public api::CustomAPIDevice +#endif +{ +public: + // component handling + void setup() override; + void loop() override; + + // Encoder registration and access + void add_encoder(BleAdvEncoder * encoder); + BleAdvEncoder * get_encoder(const std::string & id); + BleAdvEncoder * get_encoder(const std::string & encoding, const std::string & variant); + std::vector get_ids(const std::string & encoding); + + // Advertiser + uint16_t add_to_advertiser(std::vector< BleAdvParam > & params); + void remove_from_advertiser(uint16_t msg_id); + + // identify which encoder is relevant for the param, decode and log Action and Controller parameters + bool identify_param(const BleAdvParam & param, bool ignore_ble_param); + + // Listener +#ifdef USE_ESP32_BLE_CLIENT + void capture(const esp32_ble_tracker::ESPBTDevice & device, bool ignore_ble_param = true, uint16_t rem_time = 60); +#endif + +#ifdef USE_API + // HA service to decode + void on_raw_decode(std::string raw); +#endif + +protected: + // ref to registered encoders + std::vector< BleAdvEncoder * > encoders_; + + // packets being advertised + std::list< BleAdvProcess > packets_; + uint16_t id_count = 1; + uint32_t adv_stop_time_ = 0; + + esp_ble_adv_params_t adv_params_ = { + .adv_int_min = 0x20, + .adv_int_max = 0x20, + .adv_type = ADV_TYPE_NONCONN_IND, + .own_addr_type = BLE_ADDR_TYPE_PUBLIC, + .peer_addr = { 0x00 }, + .peer_addr_type = BLE_ADDR_TYPE_PUBLIC, + .channel_map = ADV_CHNL_ALL, + .adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY, + }; + + // Packets already captured once + std::list< BleAdvParam > listen_packets_; +}; + +} //namespace bleadvcontroller +} //namespace esphome diff --git a/components/ble_adv_controller/button/__init__.py b/components/ble_adv_controller/button/__init__.py new file mode 100644 index 0000000..8465ca7 --- /dev/null +++ b/components/ble_adv_controller/button/__init__.py @@ -0,0 +1,61 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import button + +from esphome.const import ( + CONF_OUTPUT_ID, + ENTITY_CATEGORY_CONFIG, + DEVICE_CLASS_IDENTIFY, +) + +from .. import ( + bleadvcontroller_ns, + ENTITY_BASE_CONFIG_SCHEMA, + entity_base_code_gen, + BleAdvEntity, +) + +from ..const import ( + CONF_BLE_ADV_COMMANDS, + CONF_BLE_ADV_CMD, + CONF_BLE_ADV_ARGS, + CONF_BLE_ADV_NB_ARGS, +) + +def validate_cmd(cmd): + if not cmd in CONF_BLE_ADV_COMMANDS: + raise cv.Invalid("%s '%s' not in %s" % (CONF_BLE_ADV_CMD, cmd, str(CONF_BLE_ADV_COMMANDS.keys()))) + return cmd + +BleAdvButton = bleadvcontroller_ns.class_('BleAdvButton', button.Button, BleAdvEntity) + +CONFIG_SCHEMA = cv.All( + button.button_schema( + BleAdvButton, + device_class=DEVICE_CLASS_IDENTIFY, + entity_category=ENTITY_CATEGORY_CONFIG, + ).extend( + { + cv.Required(CONF_BLE_ADV_CMD): validate_cmd, + cv.Optional(CONF_BLE_ADV_ARGS): cv.ensure_list(cv.uint8_t), + } + ).extend(ENTITY_BASE_CONFIG_SCHEMA), +) + +async def to_code(config): + # validate the number of args + nb_args = 0 + if CONF_BLE_ADV_ARGS in config: + nb_args = len(config[CONF_BLE_ADV_ARGS]) + cmd = config[CONF_BLE_ADV_CMD] + params = CONF_BLE_ADV_COMMANDS[cmd] + nb_args_cmd = params[CONF_BLE_ADV_NB_ARGS] + if nb_args != nb_args_cmd: + raise cv.Invalid("Invalid number of arguments for '%s': %d, should be %d" % (cmd, nb_args, nb_args_cmd)) + + # perform code gen + var = await button.new_button(config) + await entity_base_code_gen(var, config) + cg.add(var.set_cmd(params[CONF_BLE_ADV_CMD])) + if nb_args > 0: + cg.add(var.set_args(config[CONF_BLE_ADV_ARGS])) diff --git a/components/ble_adv_controller/button/ble_adv_button.cpp b/components/ble_adv_controller/button/ble_adv_button.cpp new file mode 100644 index 0000000..4e175a0 --- /dev/null +++ b/components/ble_adv_controller/button/ble_adv_button.cpp @@ -0,0 +1,21 @@ +#include "ble_adv_button.h" +#include "esphome/core/log.h" +#include "esphome/components/ble_adv_controller/ble_adv_controller.h" + +namespace esphome { +namespace bleadvcontroller { + +static const char *TAG = "ble_adv_button"; + +void BleAdvButton::dump_config() { + LOG_BUTTON("", "BleAdvButton", this); + BleAdvEntity::dump_config_base(TAG); +} + +void BleAdvButton::press_action() { + ESP_LOGD(TAG, "BleAdvButton::press_action called"); + this->command((CommandType)this->cmd_, this->args_); +} + +} // namespace bleadvcontroller +} // namespace esphome diff --git a/components/ble_adv_controller/button/ble_adv_button.h b/components/ble_adv_controller/button/ble_adv_button.h new file mode 100644 index 0000000..f786e67 --- /dev/null +++ b/components/ble_adv_controller/button/ble_adv_button.h @@ -0,0 +1,23 @@ +#pragma once + +#include "esphome/components/button/button.h" +#include "../ble_adv_controller.h" + +namespace esphome { +namespace bleadvcontroller { + +class BleAdvButton : public button::Button, public BleAdvEntity +{ + public: + void dump_config() override; + void press_action() override; + void set_cmd(uint8_t cmd) { this->cmd_ = cmd; } + void set_args(const std::vector &args) { this->args_ = args; } + + protected: + uint8_t cmd_; + std::vector args_; +}; + +} //namespace bleadvcontroller +} //namespace esphome diff --git a/components/ble_adv_controller/const.py b/components/ble_adv_controller/const.py new file mode 100644 index 0000000..ca5dad7 --- /dev/null +++ b/components/ble_adv_controller/const.py @@ -0,0 +1,33 @@ +CONF_BLE_ADV_CONTROLLER_ID = "ble_adv_controller_id" +CONF_BLE_ADV_CMD = "cmd" +CONF_BLE_ADV_ARGS = "args" +CONF_BLE_ADV_NB_ARGS = "nb_args" +CONF_BLE_ADV_ENCODING = "encoding" +CONF_BLE_ADV_COMMANDS = { + "pair" : {CONF_BLE_ADV_CMD: 1, CONF_BLE_ADV_NB_ARGS : 0}, + "unpair" : {CONF_BLE_ADV_CMD: 2, CONF_BLE_ADV_NB_ARGS : 0}, + "custom" : {CONF_BLE_ADV_CMD: 3, CONF_BLE_ADV_NB_ARGS : 5}, + "light_on" : {CONF_BLE_ADV_CMD: 13, CONF_BLE_ADV_NB_ARGS : 0}, + "light_off" : {CONF_BLE_ADV_CMD: 14, CONF_BLE_ADV_NB_ARGS : 0}, + "light_dim" : {CONF_BLE_ADV_CMD: 15, CONF_BLE_ADV_NB_ARGS : 1}, + "light_cct" : {CONF_BLE_ADV_CMD: 16, CONF_BLE_ADV_NB_ARGS : 1}, + "light_wcolor" : {CONF_BLE_ADV_CMD: 17, CONF_BLE_ADV_NB_ARGS : 2}, + "light_sec_on" : {CONF_BLE_ADV_CMD: 18, CONF_BLE_ADV_NB_ARGS : 0}, + "light_sec_off" : {CONF_BLE_ADV_CMD: 19, CONF_BLE_ADV_NB_ARGS : 0}, + "fan_on" : {CONF_BLE_ADV_CMD: 30, CONF_BLE_ADV_NB_ARGS : 0}, + "fan_off" : {CONF_BLE_ADV_CMD: 31, CONF_BLE_ADV_NB_ARGS : 0}, + "fan_speed" : {CONF_BLE_ADV_CMD: 32, CONF_BLE_ADV_NB_ARGS : 1}, + "fan_onoff_speed" : {CONF_BLE_ADV_CMD: 33, CONF_BLE_ADV_NB_ARGS : 2}, + "fan_dir" : {CONF_BLE_ADV_CMD: 34, CONF_BLE_ADV_NB_ARGS : 1}, + "fan_osc" : {CONF_BLE_ADV_CMD: 35, CONF_BLE_ADV_NB_ARGS : 1}, +} +CONF_BLE_ADV_SPEED_COUNT = "speed_count" +CONF_BLE_ADV_DIRECTION_SUPPORTED = "use_direction" +CONF_BLE_ADV_OSCILLATION_SUPPORTED = "use_oscillation" +CONF_BLE_ADV_FORCED_ID = "forced_id" +CONF_BLE_ADV_SHOW_CONFIG = "show_config" +CONF_BLE_ADV_SECONDARY = "secondary" +CONF_BLE_ADV_MAX_DURATION = "max_duration" +CONF_BLE_ADV_SEQ_DURATION = "seq_duration" +CONF_BLE_ADV_SPLIT_DIM_CCT = "separate_dim_cct" +CONF_BLE_ADV_FORCED_REFRESH_ON_START = "forced_refresh_on_start" diff --git a/components/ble_adv_controller/fan/__init__.py b/components/ble_adv_controller/fan/__init__.py new file mode 100644 index 0000000..7df7dd8 --- /dev/null +++ b/components/ble_adv_controller/fan/__init__.py @@ -0,0 +1,48 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import fan + +from esphome.const import ( + CONF_OUTPUT_ID, + CONF_RESTORE_MODE, +) + +from .. import ( + bleadvcontroller_ns, + ENTITY_BASE_CONFIG_SCHEMA, + entity_base_code_gen, + BleAdvEntity, +) + +from ..const import ( + CONF_BLE_ADV_SPEED_COUNT, + CONF_BLE_ADV_DIRECTION_SUPPORTED, + CONF_BLE_ADV_OSCILLATION_SUPPORTED, + CONF_BLE_ADV_FORCED_REFRESH_ON_START, +) + +BleAdvFan = bleadvcontroller_ns.class_('BleAdvFan', fan.Fan, BleAdvEntity) + +CONFIG_SCHEMA = cv.All( + fan.FAN_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(BleAdvFan), + cv.Optional(CONF_BLE_ADV_SPEED_COUNT, default=6): cv.one_of(0,3,6), + cv.Optional(CONF_BLE_ADV_DIRECTION_SUPPORTED, default=True): cv.boolean, + cv.Optional(CONF_BLE_ADV_OSCILLATION_SUPPORTED, default=False): cv.boolean, + cv.Optional(CONF_BLE_ADV_FORCED_REFRESH_ON_START, default=True): cv.boolean, + # override default value for restore mode, to always restore as it was if possible + cv.Optional(CONF_RESTORE_MODE, default="RESTORE_DEFAULT_OFF"): cv.enum(fan.RESTORE_MODES, upper=True, space="_"), + } + ).extend(ENTITY_BASE_CONFIG_SCHEMA), +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) + await entity_base_code_gen(var, config) + await fan.register_fan(var, config) + cg.add(var.set_speed_count(config[CONF_BLE_ADV_SPEED_COUNT])) + cg.add(var.set_direction_supported(config[CONF_BLE_ADV_DIRECTION_SUPPORTED])) + cg.add(var.set_oscillation_supported(config[CONF_BLE_ADV_OSCILLATION_SUPPORTED])) + cg.add(var.set_forced_refresh_on_start(config[CONF_BLE_ADV_FORCED_REFRESH_ON_START])) diff --git a/components/ble_adv_controller/fan/ble_adv_fan.cpp b/components/ble_adv_controller/fan/ble_adv_fan.cpp new file mode 100644 index 0000000..739b49d --- /dev/null +++ b/components/ble_adv_controller/fan/ble_adv_fan.cpp @@ -0,0 +1,89 @@ +#include "ble_adv_fan.h" +#include "esphome/core/log.h" +#include "esphome/components/ble_adv_controller/ble_adv_controller.h" + +namespace esphome { +namespace bleadvcontroller { + +static const char *TAG = "ble_adv_fan"; + +void BleAdvFan::dump_config() { + LOG_FAN("", "BleAdvFan", this); + BleAdvEntity::dump_config_base(TAG); +} + +void BleAdvFan::setup() { + auto restore = this->restore_state_(); + if (restore.has_value()) { + restore->apply(*this); + } +} + +/** +On button ON / OFF pressed: only State ON or OFF received +On Speed change: State and Speed received +On ON with Speed: State and Speed received +On Direction Change: only direction received +*/ +void BleAdvFan::control(const fan::FanCall &call) { + bool direction_refresh = false; + bool oscillation_refresh = false; + if (call.get_state().has_value()) { + // State ON/OFF or SPEED changed + if (!this->state && *call.get_state()) { + // forcing direction / oscillation refresh on 'switch on' if requested + direction_refresh |= this->forced_refresh_on_start_; + oscillation_refresh |= this->forced_refresh_on_start_; + } + this->state = *call.get_state(); + if (call.get_speed().has_value()) { + this->speed = *call.get_speed(); + } + if (this->state) { + // Switch ON, always setting with SPEED + ESP_LOGD(TAG, "BleAdvFan::control - Setting ON with speed %d", this->speed); + if (this->get_parent()->is_supported(CommandType::FAN_ONOFF_SPEED)) { + this->command(CommandType::FAN_ONOFF_SPEED, this->speed, this->traits_.supported_speed_count()); + } else { + this->command(CommandType::FAN_ON); + this->command(CommandType::FAN_SPEED, this->speed, this->traits_.supported_speed_count()); + } + } else { + // Switch OFF + ESP_LOGD(TAG, "BleAdvFan::control - Setting OFF"); + if (this->get_parent()->is_supported(CommandType::FAN_ONOFF_SPEED)) { + this->command(CommandType::FAN_ONOFF_SPEED, 0, this->traits_.supported_speed_count()); + } else { + this->command(CommandType::FAN_OFF); + } + } + } + + if (call.get_direction().has_value()) { + // Change of direction + this->direction = *call.get_direction(); + direction_refresh = true; + } + + if (direction_refresh && this->traits_.supports_direction()) { + bool isFwd = this->direction == fan::FanDirection::FORWARD; + ESP_LOGD(TAG, "BleAdvFan::control - Setting direction %s", (isFwd ? "fwd":"rev")); + this->command(CommandType::FAN_DIR, isFwd); + } + + if (call.get_oscillating().has_value()) { + // Switch Oscillation + this->oscillating = *call.get_oscillating(); + oscillation_refresh = true; + } + + if (oscillation_refresh && this->traits_.supports_oscillation()) { + ESP_LOGD(TAG, "BleAdvFan::control - Setting Oscillation %s", (this->oscillating ? "ON":"OFF")); + this->command(CommandType::FAN_OSC, this->oscillating); + } + + this->publish_state(); +} + +} // namespace bleadvcontroller +} // namespace esphome diff --git a/components/ble_adv_controller/fan/ble_adv_fan.h b/components/ble_adv_controller/fan/ble_adv_fan.h new file mode 100644 index 0000000..6a96715 --- /dev/null +++ b/components/ble_adv_controller/fan/ble_adv_fan.h @@ -0,0 +1,28 @@ +#pragma once + +#include "esphome/components/fan/fan.h" +#include "../ble_adv_controller.h" + +namespace esphome { +namespace bleadvcontroller { + +class BleAdvFan : public fan::Fan, public BleAdvEntity +{ + public: + void dump_config() override; + fan::FanTraits get_traits() override { return this->traits_; } + void setup() override; + void control(const fan::FanCall &call) override; + + void set_speed_count(uint8_t speed_count) { this->traits_.set_supported_speed_count(speed_count); this->traits_.set_speed(speed_count > 0);} + void set_direction_supported(bool use_direction) { this->traits_.set_direction(use_direction); } + void set_oscillation_supported(bool use_oscillation) { this->traits_.set_oscillation(use_oscillation); } + void set_forced_refresh_on_start(bool forced_refresh_on_start) { this->forced_refresh_on_start_ = forced_refresh_on_start; } + +protected: + fan::FanTraits traits_; + bool forced_refresh_on_start_{true}; +}; + +} //namespace bleadvcontroller +} //namespace esphome diff --git a/components/ble_adv_controller/fanlamp_pro.cpp b/components/ble_adv_controller/fanlamp_pro.cpp new file mode 100644 index 0000000..f845889 --- /dev/null +++ b/components/ble_adv_controller/fanlamp_pro.cpp @@ -0,0 +1,325 @@ +#include "fanlamp_pro.h" +#include "esphome/core/log.h" +#include + +#define MBEDTLS_AES_ALT +#include + +namespace esphome { +namespace bleadvcontroller { + +std::vector< Command > FanLampEncoder::translate(const Command & cmd, const ControllerParam_t & cont) { + Command cmd_real(cmd.main_cmd_); + switch(cmd.main_cmd_) + { + case CommandType::PAIR: + cmd_real.cmd_ = 0x28; + break; + case CommandType::UNPAIR: + cmd_real.cmd_ = 0x45; + break; + case CommandType::LIGHT_ON: + cmd_real.cmd_ = 0x10; + break; + case CommandType::LIGHT_OFF: + cmd_real.cmd_ = 0x11; + break; + case CommandType::LIGHT_WCOLOR: + cmd_real.cmd_ = 0x21; + break; + case CommandType::LIGHT_SEC_ON: + cmd_real.cmd_ = 0x12; + break; + case CommandType::LIGHT_SEC_OFF: + cmd_real.cmd_ = 0x13; + break; + case CommandType::FAN_ONOFF_SPEED: + cmd_real.cmd_ = 0x31; + break; + case CommandType::FAN_DIR: + cmd_real.cmd_ = 0x15; + break; + case CommandType::FAN_OSC: + cmd_real.cmd_ = 0x16; + break; + case CommandType::NOCMD: + default: + break; + } + std::vector< Command > cmds; + if(cmd_real.cmd_ != 0x00) { + cmds.emplace_back(cmd_real); + } + return cmds; +} + +uint16_t FanLampEncoder::get_seed(uint16_t forced_seed) { + return (forced_seed == 0) ? (uint16_t) rand() % 0xFFF5 : forced_seed; +} + +uint16_t FanLampEncoder::crc16(uint8_t* buf, size_t len, uint16_t seed) { + return esphome::crc16be(buf, len, seed); +} + +FanLampEncoderV1::FanLampEncoderV1(const std::string & encoding, const std::string & variant, uint8_t pair_arg3, + bool pair_arg_only_on_pair, bool xor1, uint8_t supp_prefix): + FanLampEncoder(encoding, variant, {0xAA, 0x98, 0x43, 0xAF, 0x0B, 0x46, 0x46, 0x46}), pair_arg3_(pair_arg3), pair_arg_only_on_pair_(pair_arg_only_on_pair), + with_crc2_(supp_prefix == 0x00), xor1_(xor1) { + if (supp_prefix != 0x00) this->prefix_.insert(this->prefix_.begin(), supp_prefix); + this->len_ = this->prefix_.size() + sizeof(data_map_t) + (this->with_crc2_ ? 2 : 1); +} + +std::vector< Command > FanLampEncoderV1::translate(const Command & cmd, const ControllerParam_t & cont) { + auto cmds = FanLampEncoder::translate(cmd, cont); + for (auto & cmd_real: cmds) { + switch(cmd_real.main_cmd_) + { + case CommandType::PAIR: + cmd_real.args_[0] = cont.id_ & 0xFF; + cmd_real.args_[1] = (cont.id_ >> 8) & 0xF0; + cmd_real.args_[2] = this->pair_arg3_; + break; + case CommandType::LIGHT_WCOLOR: + cmd_real.args_[0] = cmd.args_[0]; + cmd_real.args_[1] = cmd.args_[1]; + break; + case CommandType::FAN_ONOFF_SPEED: + cmd_real.cmd_ = (cmd.args_[1] == 6) ? 0x32 : 0x31; // use Fan Gear or Fan Level + cmd_real.args_[0] = cmd.args_[0]; + cmd_real.args_[1] = (cmd.args_[1] == 6) ? cmd.args_[1] : 0; + break; + case CommandType::FAN_DIR: + cmd_real.args_[0] = !cmd.args_[0]; + break; + case CommandType::FAN_OSC: + cmd_real.args_[0] = cmd.args_[0]; + break; + case CommandType::NOCMD: + default: + break; + } + } + return cmds; +} + +bool FanLampEncoderV1::decode(uint8_t* buf, Command &cmd, ControllerParam_t & cont){ + this->whiten(buf, this->len_, 0x6F); + this->reverse_all(buf, this->len_); + + uint8_t data_start = this->prefix_.size(); + data_map_t * data = (data_map_t *) (buf + data_start); + + // distinguish in between different encoder variants + if(!std::equal(this->prefix_.begin(), this->prefix_.end(), buf)) return false; + if (data->command == 0x28 && this->pair_arg3_ != data->args[2]) return false; + if (data->command != 0x28 && !this->pair_arg_only_on_pair_ && data->args[2] != this->pair_arg3_) return false; + if (data->command != 0x28 && this->pair_arg_only_on_pair_ && data->args[2] != 0) return false; + + std::string decoded = esphome::format_hex_pretty(buf, this->len_); + uint16_t seed = htons(data->seed); + uint8_t seed8 = static_cast(seed & 0xFF); + ENSURE_EQ(data->r2, this->xor1_ ? seed8 ^ 1 : seed8, "Decoded KO (r2) - %s", decoded.c_str()); + + uint16_t crc16 = htons(this->crc16((uint8_t*)(data), sizeof(data_map_t) - 2, ~seed)); + ENSURE_EQ(crc16, data->crc16, "Decoded KO (crc16) - %s", decoded.c_str()); + + if (data->args[2] != 0) { + ENSURE_EQ(data->args[2], this->pair_arg3_, "Decoded KO (arg3) - %s", decoded.c_str()); + } + + if (this->with_crc2_) { + uint16_t crc16_mac = this->crc16(buf + 1, 5, 0xffff); + uint16_t crc16_2 = htons(this->crc16(buf + data_start, sizeof(data_map_t), crc16_mac)); + uint16_t crc16_data_2 = *(uint16_t*) &buf[this->len_ - 2]; + ENSURE_EQ(crc16_data_2, crc16_2, "Decoded KO (crc16_2) - %s", decoded.c_str()); + } + + uint8_t rem_id = data->src ^ seed8; + + cmd.cmd_ = (CommandType) (data->command); + std::copy(data->args, data->args + sizeof(data->args), cmd.args_); + cont.tx_count_ = data->tx_count; + cont.index_ = (data->group_index & 0x0F00) >> 8; + cont.id_ = data->group_index + 256*256*rem_id; + cont.seed_ = seed; + return true; +} + +/* Code taken from FanLamp App + public static final byte[] PREAMBLE = {113, 15, 85}; // 0x71, 0x0F, 0x55 + public static final byte[] DEVICE_ADDRESS = {-104, 67, -81, 11, 70}; // 0x68, 0x43, 0xAF, 0x0B, 0x46 + + byte[] bArr2 = new byte[22]; + bArr2[0] = (LampConfig.DEVICE_ADDRESS[0] & 128) == 128 ? (byte) -86 : (byte) 85; // 0xAA : 0x55 + int i7 = 0; + while (i7 < Math.min(LampConfig.DEVICE_ADDRESS.length, 5)) { + int i8 = i7 + 1; + bArr2[i8] = LampConfig.DEVICE_ADDRESS[i7]; + i7 = i8; + } + bArr2[6] = bArr2[5]; + bArr2[7] = bArr2[5]; + ... + byte[] bArr3 = new byte[25]; + System.arraycopy(LampConfig.PREAMBLE, 0, bArr3, 0, LampConfig.PREAMBLE.length); + System.arraycopy(bArr2, 0, bArr3, 3, bArr2.length); +*/ + +void FanLampEncoderV1::encode(uint8_t* buf, Command &cmd_real, ControllerParam_t & cont) { + std::copy(this->prefix_.begin(), this->prefix_.end(), buf); + data_map_t *data = (data_map_t*)(buf + this->prefix_.size()); + + uint16_t seed = this->get_seed(cont.seed_); + uint8_t seed8 = static_cast(seed & 0xFF); + uint16_t cmd_id_trunc = static_cast(cont.id_ & 0xF0FF); + + data->command = cmd_real.cmd_; + data->group_index = cmd_id_trunc + (((uint16_t)(cont.index_ & 0x0F)) << 8); + data->tx_count = cont.tx_count_; + data->outs = 0; + data->src = this->xor1_ ? seed8 ^ 1 : seed8 ^ ((cont.id_ >> 16) & 0xFF); + data->r2 = this->xor1_ ? seed8 ^ 1 : seed8; + data->seed = htons(seed); + data->args[0] = cmd_real.args_[0]; + data->args[1] = cmd_real.args_[1]; + data->args[2] = this->pair_arg_only_on_pair_ ? cmd_real.args_[2] : this->pair_arg3_; + data->crc16 = htons(this->crc16((uint8_t*)(data), sizeof(data_map_t) - 2, ~seed)); + + if (this->with_crc2_) { + uint16_t* crc16_2 = (uint16_t*) &buf[this->len_ - 2]; + uint16_t crc_mac = this->crc16(buf + 1, 5, 0xffff); + *crc16_2 = htons(this->crc16((uint8_t*)(data), sizeof(data_map_t), crc_mac)); + } else { + buf[this->len_ - 1] = 0xAA; + } + + this->reverse_all(buf, this->len_); + this->whiten(buf, this->len_, 0x6F); +} + +FanLampEncoderV2::FanLampEncoderV2(const std::string & encoding, const std::string & variant, const std::vector && prefix, uint16_t device_type, bool with_sign): + FanLampEncoder(encoding, variant, prefix), device_type_(device_type), with_sign_(with_sign) { + this->len_ = this->prefix_.size() + sizeof(data_map_t); +} + +uint16_t FanLampEncoderV2::sign(uint8_t* buf, uint8_t tx_count, uint16_t seed) { + uint8_t sigkey[16] = {0, 0, 0, 0x0D, 0xBF, 0xE6, 0x42, 0x68, 0x41, 0x99, 0x2D, 0x0F, 0xB0, 0x54, 0xBB, 0x16}; + + sigkey[0] = seed & 0xff; + sigkey[1] = (seed >> 8) & 0xff; + sigkey[2] = tx_count; + mbedtls_aes_context aes_ctx; + mbedtls_aes_init(&aes_ctx); + mbedtls_aes_setkey_enc(&aes_ctx, sigkey, sizeof(sigkey)*8); + uint8_t aes_in[16], aes_out[16]; + memcpy(aes_in, buf, 16); + mbedtls_aes_crypt_ecb(&aes_ctx, ESP_AES_ENCRYPT, aes_in, aes_out); + mbedtls_aes_free(&aes_ctx); + uint16_t sign = ((uint16_t*) aes_out)[0]; + return sign == 0 ? 0xffff : sign; +} + +void FanLampEncoderV2::whiten(uint8_t *buf, uint8_t size, uint8_t seed, uint8_t salt) { + static constexpr uint8_t XBOXES[128] = { + 0xB7, 0xFD, 0x93, 0x26, 0x36, 0x3F, 0xF7, 0xCC, + 0x34, 0xA5, 0xE5, 0xF1, 0x71, 0xD8, 0x31, 0x15, + 0x04, 0xC7, 0x23, 0xC3, 0x18, 0x96, 0x05, 0x9A, + 0x07, 0x12, 0x80, 0xE2, 0xEB, 0x27, 0xB2, 0x75, + 0xD0, 0xEF, 0xAA, 0xFB, 0x43, 0x4D, 0x33, 0x85, + 0x45, 0xF9, 0x02, 0x7F, 0x50, 0x3C, 0x9F, 0xA8, + 0x51, 0xA3, 0x40, 0x8F, 0x92, 0x9D, 0x38, 0xF5, + 0xBC, 0xB6, 0xDA, 0x21, 0x10, 0xFF, 0xF3, 0xD2, + 0xE0, 0x32, 0x3A, 0x0A, 0x49, 0x06, 0x24, 0x5C, + 0xC2, 0xD3, 0xAC, 0x62, 0x91, 0x95, 0xE4, 0x79, + 0xE7, 0xC8, 0x37, 0x6D, 0x8D, 0xD5, 0x4E, 0xA9, + 0x6C, 0x56, 0xF4, 0xEA, 0x65, 0x7A, 0xAE, 0x08, + 0xE1, 0xF8, 0x98, 0x11, 0x69, 0xD9, 0x8E, 0x94, + 0x9B, 0x1E, 0x87, 0xE9, 0xCE, 0x55, 0x28, 0xDF, + 0x8C, 0xA1, 0x89, 0x0D, 0xBF, 0xE6, 0x42, 0x68, + 0x41, 0x99, 0x2D, 0x0F, 0xB0, 0x54, 0xBB, 0x16 + }; + + for (uint8_t i = 0; i < size; ++i) { + buf[i] ^= XBOXES[((seed + i + 9) & 0x1f) + (salt & 0x3) * 0x20]; + buf[i] ^= seed; + } +} + +std::vector< Command > FanLampEncoderV2::translate(const Command & cmd, const ControllerParam_t & cont) { + auto cmds = FanLampEncoder::translate(cmd, cont); + for (auto & cmd_real: cmds) { + switch(cmd_real.main_cmd_) + { + case CommandType::LIGHT_WCOLOR: + cmd_real.args_[2] = cmd.args_[0]; + cmd_real.args_[3] = cmd.args_[1]; + break; + case CommandType::FAN_ONOFF_SPEED: + cmd_real.args_[1] = (cmd.args_[1] == 6) ? 0x20 : 0; // specific flag for 6 level + cmd_real.args_[2] = cmd.args_[0]; + break; + case CommandType::FAN_DIR: + cmd_real.args_[1] = !cmd.args_[0]; + break; + case CommandType::FAN_OSC: + cmd_real.args_[1] = cmd.args_[0]; + break; + case CommandType::NOCMD: + default: + break; + } + } + return cmds; +} + +bool FanLampEncoderV2::decode(uint8_t* buf, Command &cmd, ControllerParam_t & cont){ + data_map_t * data = (data_map_t *) (buf + this->prefix_.size()); + uint16_t crc16 = this->crc16(buf , this->len_ - 2, ~(data->seed)); + + this->whiten(buf + 2, this->len_ - 6, (uint8_t)(data->seed), 0); + if (!std::equal(this->prefix_.begin(), this->prefix_.end(), buf)) return false; + if (data->type != this->device_type_) return false; + if (this->with_sign_ && data->sign == 0x0000) return false; + if (!this->with_sign_ && data->sign != 0x0000) return false; + + std::string decoded = esphome::format_hex_pretty(buf, this->len_); + ENSURE_EQ(crc16, data->crc16, "Decoded KO (crc16) - %s", decoded.c_str()); + + if (this->with_sign_) { + ENSURE_EQ(this->sign(buf + 1, data->tx_count, data->seed), data->sign, "Decoded KO (sign) - %s", decoded.c_str()); + } + + cmd.cmd_ = (CommandType) (data->command); + std::copy(data->args, data->args + sizeof(data->args), cmd.args_); + cont.tx_count_ = data->tx_count; + cont.index_ = data->group_index; + cont.id_ = data->identifier; + cont.seed_ = data->seed; + + return true; +} + +void FanLampEncoderV2::encode(uint8_t* buf, Command &cmd_real, ControllerParam_t & cont) { + std::copy(this->prefix_.begin(), this->prefix_.end(), buf); + data_map_t * data = (data_map_t *) (buf + this->prefix_.size()); + + uint16_t seed = this->get_seed(cont.seed_); + + data->tx_count = cont.tx_count_; + data->type = this->device_type_; + data->identifier = cont.id_; + data->command = cmd_real.cmd_; + std::copy(cmd_real.args_, cmd_real.args_ + sizeof(cmd_real.args_), data->args); + data->group_index = cont.index_; + data->seed = seed; + + if (this->with_sign_) { + data->sign = this->sign(buf + 1, data->tx_count, seed); + } + + this->whiten(buf + 2, this->len_ - 6, (uint8_t) seed); + data->crc16 = this->crc16(buf, this->len_ - 2, ~seed); +} + +} // namespace bleadvcontroller +} // namespace esphome diff --git a/components/ble_adv_controller/fanlamp_pro.h b/components/ble_adv_controller/fanlamp_pro.h new file mode 100644 index 0000000..0d2f6d6 --- /dev/null +++ b/components/ble_adv_controller/fanlamp_pro.h @@ -0,0 +1,83 @@ +#pragma once + +#include "ble_adv_controller.h" + +namespace esphome { +namespace bleadvcontroller { + +class FanLampEncoder: public BleAdvEncoder +{ +public: + FanLampEncoder(const std::string & encoding, const std::string & variant, const std::vector & prefix): + BleAdvEncoder(encoding, variant), prefix_(prefix) {} + +protected: + virtual std::vector< Command > translate(const Command & cmd, const ControllerParam_t & cont) override; + + uint16_t get_seed(uint16_t forced_seed = 0); + uint16_t crc16(uint8_t* buf, size_t len, uint16_t seed); + + std::vector prefix_; +}; + +class FanLampEncoderV1: public FanLampEncoder +{ +public: + FanLampEncoderV1(const std::string & encoding, const std::string & variant, + uint8_t pair_arg3, bool pair_arg_only_on_pair = true, bool xor1 = false, uint8_t supp_prefix = 0x00); + +protected: + struct data_map_t { + uint8_t command; + uint16_t group_index; + uint8_t args[3]; + uint8_t tx_count; + uint8_t outs; + uint8_t src; + uint8_t r2; + uint16_t seed; + uint16_t crc16; + }__attribute__((packed, aligned(1))); + + virtual std::vector< Command > translate(const Command & cmd, const ControllerParam_t & cont) override; + virtual bool decode(uint8_t* buf, Command &cmd, ControllerParam_t & cont) override; + virtual void encode(uint8_t* buf, Command &cmd, ControllerParam_t & cont) override; + + uint8_t pair_arg3_; + bool pair_arg_only_on_pair_; + bool with_crc2_; + bool xor1_; +}; + +class FanLampEncoderV2: public FanLampEncoder +{ +public: + FanLampEncoderV2(const std::string & encoding, const std::string & variant, const std::vector && prefix, uint16_t device_type, bool with_sign); + +protected: + struct data_map_t { + uint8_t tx_count; + uint16_t type; + uint32_t identifier; + uint8_t group_index; + uint16_t command; + uint8_t args[4]; + uint16_t sign; + uint8_t spare; + uint16_t seed; + uint16_t crc16; + }__attribute__((packed, aligned(1))); + + virtual std::vector< Command > translate(const Command & cmd, const ControllerParam_t & cont) override; + virtual bool decode(uint8_t* buf, Command &cmd, ControllerParam_t & cont) override; + virtual void encode(uint8_t* buf, Command &cmd, ControllerParam_t & cont) override; + + uint16_t sign(uint8_t* buf, uint8_t tx_count, uint16_t seed); + void whiten(uint8_t *buf, uint8_t size, uint8_t seed, uint8_t salt = 0); + + uint16_t device_type_; + bool with_sign_; +}; + +} //namespace bleadvcontroller +} //namespace esphome diff --git a/components/ble_adv_controller/light/__init__.py b/components/ble_adv_controller/light/__init__.py new file mode 100644 index 0000000..9041b16 --- /dev/null +++ b/components/ble_adv_controller/light/__init__.py @@ -0,0 +1,69 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import light, output +from esphome.cpp_helpers import setup_entity +from esphome.const import ( + CONF_CONSTANT_BRIGHTNESS, + CONF_COLD_WHITE_COLOR_TEMPERATURE, + CONF_WARM_WHITE_COLOR_TEMPERATURE, + CONF_MIN_BRIGHTNESS, + CONF_OUTPUT_ID, + CONF_DEFAULT_TRANSITION_LENGTH, + CONF_RESTORE_MODE, +) + +from .. import ( + bleadvcontroller_ns, + ENTITY_BASE_CONFIG_SCHEMA, + entity_base_code_gen, + BleAdvEntity, +) + +from ..const import ( + CONF_BLE_ADV_SECONDARY, + CONF_BLE_ADV_SPLIT_DIM_CCT, +) + +BleAdvLight = bleadvcontroller_ns.class_('BleAdvLight', light.LightOutput, BleAdvEntity) +BleAdvSecLight = bleadvcontroller_ns.class_('BleAdvSecLight', light.LightOutput, BleAdvEntity) + +CONFIG_SCHEMA = cv.All( + cv.Any( + light.RGB_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(BleAdvLight), + cv.Optional(CONF_COLD_WHITE_COLOR_TEMPERATURE, default="167 mireds"): cv.color_temperature, + cv.Optional(CONF_WARM_WHITE_COLOR_TEMPERATURE, default="333 mireds"): cv.color_temperature, + cv.Optional(CONF_CONSTANT_BRIGHTNESS, default=False): cv.boolean, + cv.Optional(CONF_MIN_BRIGHTNESS, default="1%"): cv.percentage, + cv.Optional(CONF_BLE_ADV_SPLIT_DIM_CCT, default=False): cv.boolean, + # override default value of default_transition_length to 0s as mostly not supported by those lights + cv.Optional(CONF_DEFAULT_TRANSITION_LENGTH, default="0s"): cv.positive_time_period_milliseconds, + # override default value for restore mode, to always restore as it was if possible + cv.Optional(CONF_RESTORE_MODE, default="RESTORE_DEFAULT_OFF"): cv.enum(light.RESTORE_MODES, upper=True, space="_"), + } + ).extend(ENTITY_BASE_CONFIG_SCHEMA), + light.RGB_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(BleAdvSecLight), + cv.Required(CONF_BLE_ADV_SECONDARY): cv.one_of(True), + } + ).extend(ENTITY_BASE_CONFIG_SCHEMA), + ), + cv.has_none_or_all_keys( + [CONF_COLD_WHITE_COLOR_TEMPERATURE, CONF_WARM_WHITE_COLOR_TEMPERATURE] + ), + light.validate_color_temperature_channels, +) + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) + await entity_base_code_gen(var, config) + await light.register_light(var, config) + if CONF_BLE_ADV_SECONDARY in config: + cg.add(var.set_traits()) + else: + cg.add(var.set_traits(config[CONF_COLD_WHITE_COLOR_TEMPERATURE], config[CONF_WARM_WHITE_COLOR_TEMPERATURE])) + cg.add(var.set_constant_brightness(config[CONF_CONSTANT_BRIGHTNESS])) + cg.add(var.set_split_dim_cct(config[CONF_BLE_ADV_SPLIT_DIM_CCT])) + cg.add(var.set_min_brightness(config[CONF_MIN_BRIGHTNESS] * 100, 0, 100, 1)) diff --git a/components/ble_adv_controller/light/ble_adv_light.cpp b/components/ble_adv_controller/light/ble_adv_light.cpp new file mode 100644 index 0000000..929493d --- /dev/null +++ b/components/ble_adv_controller/light/ble_adv_light.cpp @@ -0,0 +1,122 @@ +#include "ble_adv_light.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace bleadvcontroller { + +static const char *TAG = "ble_adv_light"; + +float ensure_range(float f) { + return (f > 1.0) ? 1.0 : ( (f < 0.0) ? 0.0 : f ); +} + +void BleAdvLight::set_min_brightness(int min_brightness, int min, int max, int step) { + this->number_min_brightness_.traits.set_min_value(min); + this->number_min_brightness_.traits.set_max_value(max); + this->number_min_brightness_.traits.set_step(step); + this->number_min_brightness_.state = min_brightness; +} + +void BleAdvLight::set_traits(float cold_white_temperature, float warm_white_temperature) { + this->traits_.set_supported_color_modes({light::ColorMode::COLD_WARM_WHITE}); + this->traits_.set_min_mireds(cold_white_temperature); + this->traits_.set_max_mireds(warm_white_temperature); +} + +void BleAdvLight::setup() { + if (this->get_parent()->is_show_config()) { + this->number_min_brightness_.init("Min Brightness", this->get_name()); + } +} + +void BleAdvLight::dump_config() { + ESP_LOGCONFIG(TAG, "BleAdvLight"); + BleAdvEntity::dump_config_base(TAG); + ESP_LOGCONFIG(TAG, " Base Light '%s'", this->state_->get_name().c_str()); + ESP_LOGCONFIG(TAG, " Cold White Temperature: %f mireds", this->traits_.get_min_mireds()); + ESP_LOGCONFIG(TAG, " Warm White Temperature: %f mireds", this->traits_.get_max_mireds()); + ESP_LOGCONFIG(TAG, " Constant Brightness: %s", this->constant_brightness_ ? "true" : "false"); + ESP_LOGCONFIG(TAG, " Minimum Brightness: 0x%.2X", this->get_min_brightness()); +} + +void BleAdvLight::write_state(light::LightState *state) { + // If target state is off, switch off + if (state->current_values.get_state() == 0) { + ESP_LOGD(TAG, "BleAdvLight::write_state - Switch OFF"); + this->command(CommandType::LIGHT_OFF); + this->is_off_ = true; + return; + } + + // If current state is off, switch on + if (this->is_off_) { + ESP_LOGD(TAG, "BleAdvLight::write_state - Switch ON"); + this->command(CommandType::LIGHT_ON); + this->is_off_ = false; + } + + // Compute Corrected Brigtness / Warm Color Temperature (potentially reversed) as float: 0 -> 1 + float updated_brf = ensure_range(this->get_min_brightness() + state->current_values.get_brightness() * (1.f - this->get_min_brightness())); + float updated_ctf = ensure_range((state->current_values.get_color_temperature() - this->traits_.get_min_mireds()) / (this->traits_.get_max_mireds() - this->traits_.get_min_mireds())); + updated_ctf = this->get_parent()->is_reversed() ? 1.0 - updated_ctf : updated_ctf; + + // During transition(current / remote states are not the same), do not process change + // if Brigtness / Color Temperature was not modified enough + float br_diff = abs(this->brightness_ - updated_brf) * 100; + float ct_diff = abs(this->warm_color_ - updated_ctf) * 100; + bool is_last = (state->current_values == state->remote_values); + if ((br_diff < 3 && ct_diff < 3 && !is_last) || (is_last && br_diff == 0 && ct_diff == 0)) { + return; + } + + this->brightness_ = updated_brf; + this->warm_color_ = updated_ctf; + + if(this->get_parent()->is_supported(CommandType::LIGHT_WCOLOR) && !this->split_dim_cct_) { + light::LightColorValues eff_values = state->current_values; + eff_values.set_brightness(updated_brf); + float cwf, wwf; + if (this->get_parent()->is_reversed()) { + eff_values.as_cwww(&wwf, &cwf, 0, this->constant_brightness_); + } else { + eff_values.as_cwww(&cwf, &wwf, 0, this->constant_brightness_); + } + ESP_LOGD(TAG, "Updating Cold: %.0f%, Warm: %.0f%", cwf*100, wwf*100); + this->command(CommandType::LIGHT_WCOLOR, (uint8_t) (cwf*255), (uint8_t) (wwf*255)); + } else { + if (ct_diff != 0) { + ESP_LOGD(TAG, "Updating warm color temperature: %.0f%", updated_ctf*100); + this->command(CommandType::LIGHT_CCT, (uint8_t) (255*updated_ctf)); + } + if (br_diff != 0) { + ESP_LOGD(TAG, "Updating brightness: %.0f%", updated_brf*100); + this->command(CommandType::LIGHT_DIM, (uint8_t) (255*updated_brf)); + } + } +} + +/********************* +Secondary Light +**********************/ + +void BleAdvSecLight::dump_config() { + ESP_LOGCONFIG(TAG, "BleAdvSecLight"); + BleAdvEntity::dump_config_base(TAG); + ESP_LOGCONFIG(TAG, " Base Light '%s'", this->state_->get_name().c_str()); +} + +void BleAdvSecLight::write_state(light::LightState *state) { + bool binary; + state->current_values_as_binary(&binary); + if (binary) { + ESP_LOGD(TAG, "BleAdvSecLight::write_state - Switch ON"); + this->command(CommandType::LIGHT_SEC_ON); + } else { + ESP_LOGD(TAG, "BleAdvSecLight::write_state - Switch OFF"); + this->command(CommandType::LIGHT_SEC_OFF); + } +} + +} // namespace bleadvcontroller +} // namespace esphome + diff --git a/components/ble_adv_controller/light/ble_adv_light.h b/components/ble_adv_controller/light/ble_adv_light.h new file mode 100644 index 0000000..1cb7d19 --- /dev/null +++ b/components/ble_adv_controller/light/ble_adv_light.h @@ -0,0 +1,55 @@ +#pragma once + +#include "esphome/components/light/light_output.h" +#include "../ble_adv_controller.h" + +namespace esphome { +namespace bleadvcontroller { + +class BleAdvLight : public light::LightOutput, public BleAdvEntity, public EntityBase +{ + public: + void setup() override; + void dump_config() override; + + void set_traits(float cold_white_temperature, float warm_white_temperature); + void set_constant_brightness(bool constant_brightness) { this->constant_brightness_ = constant_brightness; } + void set_min_brightness(int min_brightness, int min, int max, int step); + void set_split_dim_cct(bool split_dim_cct) { this->split_dim_cct_ = split_dim_cct; } + + float get_min_brightness() { return ((float)this->number_min_brightness_.state) / 100.0f; } + + void setup_state(light::LightState *state) override { this->state_ = state; }; + void write_state(light::LightState *state) override; + light::LightTraits get_traits() override { return this->traits_; } + + protected: + light::LightState * state_{nullptr}; + + light::LightTraits traits_; + bool constant_brightness_; + BleAdvNumber number_min_brightness_; + bool split_dim_cct_; + + bool is_off_{true}; + float brightness_{0}; + float warm_color_{0}; +}; + +class BleAdvSecLight : public light::LightOutput, public BleAdvEntity, public EntityBase +{ + public: + void set_traits() { this->traits_.set_supported_color_modes({light::ColorMode::ON_OFF}); }; + void dump_config() override; + + void setup_state(light::LightState *state) override { this->state_ = state; }; + void write_state(light::LightState *state) override; + light::LightTraits get_traits() override { return this->traits_; }; + + protected: + light::LightState * state_{nullptr}; + light::LightTraits traits_; +}; + +} //namespace bleadvcontroller +} //namespace esphome diff --git a/components/ble_adv_controller/zhijia.cpp b/components/ble_adv_controller/zhijia.cpp new file mode 100644 index 0000000..7959b5d --- /dev/null +++ b/components/ble_adv_controller/zhijia.cpp @@ -0,0 +1,370 @@ +#include "zhijia.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace bleadvcontroller { + +static constexpr size_t MAC_LEN = 4; +static uint8_t MAC[MAC_LEN] = {0x19, 0x01, 0x10, 0xAA}; +static constexpr size_t UID_LEN = 3; +static uint8_t UID[UID_LEN] = {0x19, 0x01, 0x10}; + +uint16_t ZhijiaEncoder::crc16(uint8_t* buf, size_t len, uint16_t seed) { + return esphome::crc16(buf, len, seed, 0x8408, true, true); +} + +// {0xAB, 0xCD, 0xEF} => 0xABCDEF +uint32_t ZhijiaEncoder::uuid_to_id(uint8_t * uuid, size_t len) { + uint32_t id = 0; + for (size_t i = 0; i < len; ++i) { + id |= uuid[len - i - 1] << (8*i); + } + return id; +} + +// 0xABCDEF => {0xAB, 0xCD, 0xEF} +void ZhijiaEncoder::id_to_uuid(uint8_t * uuid, uint32_t id, size_t len) { + for (size_t i = 0; i < len; ++i) { + uuid[len - i - 1] = (id >> (8*i)) & 0xFF; + } +} + +std::vector< Command > ZhijiaEncoderV0::translate(const Command & cmd, const ControllerParam_t & cont) { + Command cmd_real(cmd.main_cmd_); + switch(cmd.main_cmd_) + { + case CommandType::PAIR: + cmd_real.cmd_ = 0xB4; // -76 + break; + case CommandType::UNPAIR: + cmd_real.cmd_ = 0xB0; // -80 + break; + case CommandType::LIGHT_ON: + cmd_real.cmd_ = 0xB3; // -77 + break; + case CommandType::LIGHT_OFF: + cmd_real.cmd_ = 0xB2; // -78 + break; + case CommandType::LIGHT_DIM: + { + cmd_real.cmd_ = 0xB5; // -75 + // app software: int i in between 0 -> 1000 + // (byte) ((0xFF0000 & i) >> 16), (byte) ((0x00FF00 & i) >> 8), (byte) (i & 0x0000FF) + uint16_t argBy4 = (1000 * (float)cmd.args_[0]) / 255; // from 0..255 -> 0..1000 + cmd_real.args_[1] = ((argBy4 & 0xFF00) >> 8); + cmd_real.args_[2] = (argBy4 & 0x00FF); + } + break; + case CommandType::LIGHT_CCT: + { + cmd_real.cmd_ = 0xB7; // -73 + // app software: int i in between 0 -> 1000 + // (byte) ((0xFF0000 & i) >> 16), (byte) ((0x00FF00 & i) >> 8), (byte) (i & 0x0000FF) + uint16_t argBy4 = (1000 * (float)cmd.args_[0]) / 255; + cmd_real.args_[1] = ((argBy4 & 0xFF00) >> 8); + cmd_real.args_[2] = (argBy4 & 0x00FF); + } + break; + case CommandType::LIGHT_SEC_ON: + cmd_real.cmd_ = 0xA6; // -90 + cmd_real.args_[0] = 1; + break; + case CommandType::LIGHT_SEC_OFF: + cmd_real.cmd_ = 0xA6; // -90 + cmd_real.args_[0] = 2; + break; + default: + break; + } + std::vector< Command > cmds; + if(cmd_real.cmd_ != 0x00) { + cmds.emplace_back(cmd_real); + } + return cmds; +} + +bool ZhijiaEncoderV0::decode(uint8_t* buf, Command &cmd, ControllerParam_t & cont) { + this->whiten(buf, this->len_, 0x37); + this->whiten(buf, this->len_, 0x7F); + + data_map_t * data = (data_map_t *) buf; + uint16_t crc16 = this->crc16(buf, ADDR_LEN + TXDATA_LEN); + ENSURE_EQ(crc16, data->crc16, "Decoded KO (CRC)"); + + uint8_t addr[ADDR_LEN]; + this->reverse_all(buf, ADDR_LEN); + std::reverse_copy(data->addr, data->addr + ADDR_LEN, addr); + ENSURE_EQ(std::equal(addr, addr + ADDR_LEN, MAC), true, "Decoded KO (MAC)"); + + cont.tx_count_ = data->txdata[0] ^ data->txdata[6]; + cmd.args_[0] = cont.tx_count_ ^ data->txdata[7]; + uint8_t pivot = data->txdata[1] ^ cmd.args_[0]; + uint8_t uuid[UUID_LEN]; + uuid[0] = pivot ^ data->txdata[0]; + uuid[1] = pivot ^ data->txdata[5]; + cont.id_ = this->uuid_to_id(uuid, UUID_LEN); + cont.index_ = pivot ^ data->txdata[2]; + cmd.cmd_ = pivot ^ data->txdata[4]; + cmd.args_[1] = pivot ^ data->txdata[3]; + cmd.args_[2] = uuid[0] ^ data->txdata[6]; + + return true; +} + +void ZhijiaEncoderV0::encode(uint8_t* buf, Command &cmd_real, ControllerParam_t & cont) { + unsigned char uuid[UUID_LEN] = {0}; + this->id_to_uuid(uuid, cont.id_, UUID_LEN); + + data_map_t * data = (data_map_t *) buf; + std::reverse_copy(MAC, MAC + ADDR_LEN, data->addr); + this->reverse_all(data->addr, ADDR_LEN); + + uint8_t pivot = cmd_real.args_[2] ^ cont.tx_count_; + data->txdata[0] = pivot ^ uuid[0]; + data->txdata[1] = pivot ^ cmd_real.args_[0]; + data->txdata[2] = pivot ^ cont.index_; + data->txdata[3] = pivot ^ cmd_real.args_[1]; + data->txdata[4] = pivot ^ cmd_real.cmd_; + data->txdata[5] = pivot ^ uuid[1]; + data->txdata[6] = cmd_real.args_[2] ^ uuid[0]; + data->txdata[7] = cmd_real.args_[0] ^ cont.tx_count_; + + data->crc16 = this->crc16(buf, ADDR_LEN + TXDATA_LEN); + this->whiten(buf, this->len_, 0x7F); + this->whiten(buf, this->len_, 0x37); +} + +std::vector< Command > ZhijiaEncoderV1::translate(const Command & cmd, const ControllerParam_t & cont) { + Command cmd_real(cmd.main_cmd_); + switch(cmd.main_cmd_) + { + case CommandType::PAIR: + cmd_real.cmd_ = 0xA2; // -94 + break; + case CommandType::UNPAIR: + cmd_real.cmd_ = 0xA3; // -93 + break; + case CommandType::LIGHT_ON: + cmd_real.cmd_ = 0xA5; // -91 + break; + case CommandType::LIGHT_OFF: + cmd_real.cmd_ = 0xA6; // -90 + break; + case CommandType::LIGHT_WCOLOR: + cmd_real.cmd_ = 0xA8; // -88 + cmd_real.args_[0] = (250 * (float)cmd.args_[1]) / 255; + cmd_real.args_[1] = (250 * (float)cmd.args_[0]) / 255; + break; + case CommandType::LIGHT_DIM: + cmd_real.cmd_ = 0xAD; // -83 + // app software: value in between 0 -> 250 + cmd_real.args_[0] = (250 * (float)cmd.args_[0]) / 255; + break; + case CommandType::LIGHT_CCT: + cmd_real.cmd_ = 0xAE; // -82 + // app software: value in between 0 -> 250 + cmd_real.args_[0] = (250 * (float)cmd.args_[0]) / 255; + break; + case CommandType::LIGHT_SEC_ON: + cmd_real.cmd_ = 0xAF; // -81 + break; + case CommandType::LIGHT_SEC_OFF: + cmd_real.cmd_ = 0xB0; // -80 + break; + default: + break; + } + std::vector< Command > cmds; + if(cmd_real.cmd_ != 0x00) { + cmds.emplace_back(cmd_real); + } + return cmds; +} + +bool ZhijiaEncoderV1::decode(uint8_t* buf, Command &cmd, ControllerParam_t & cont) { + this->whiten(buf, this->len_, 0x37); + + data_map_t * data = (data_map_t *) buf; + uint16_t crc16 = this->crc16(buf, ADDR_LEN + TXDATA_LEN); + ENSURE_EQ(crc16, data->crc16, "Decoded KO (CRC)"); + + uint8_t addr[ADDR_LEN]; + this->reverse_all(data->addr, ADDR_LEN); + std::reverse_copy(data->addr, data->addr + ADDR_LEN, addr); + ENSURE_EQ(std::equal(addr, addr + ADDR_LEN, addr), true, "Decoded KO (MAC)"); + + ENSURE_EQ(data->txdata[7], data->txdata[14], "Decoded KO (Dupe 7/14)"); + ENSURE_EQ(data->txdata[8], data->txdata[11], "Decoded KO (Dupe 8/11)"); + ENSURE_EQ(data->txdata[11], data->txdata[16], "Decoded KO (Dupe 11/16)"); + + uint8_t pivot = data->txdata[16]; + uint8_t uid[UID_LEN]; + uid[0] = data->txdata[7] ^ pivot; + uid[1] = data->txdata[10] ^ pivot; + uid[2] = data->txdata[4] ^ data->txdata[13]; + ENSURE_EQ(std::equal(uid, uid + UID_LEN, UID), true, "Decoded KO (UID)"); + + cmd.cmd_ = (CommandType) (data->txdata[9] ^ pivot); + cmd.args_[0] = data->txdata[0] ^ pivot; + cmd.args_[1] = data->txdata[3] ^ pivot; + cmd.args_[2] = data->txdata[5] ^ pivot; + cont.tx_count_ = data->txdata[4] ^ pivot; + cont.index_ = data->txdata[6] ^ pivot; + uint8_t uuid[UUID_LEN]; + uuid[0] = data->txdata[2] ^ pivot; + uuid[1] = data->txdata[2] ^ data->txdata[12]; + uuid[2] = data->txdata[9] ^ data->txdata[15]; + cont.id_ = this->uuid_to_id(uuid, UUID_LEN); + + uint8_t key = pivot ^ cmd.args_[0] ^ cmd.args_[1] ^ cmd.args_[2] ^ uid[0] ^ uid[1] ^ uid[2]; + key ^= uuid[0] ^ uuid[1] ^ uuid[2] ^ cont.tx_count_ ^ cont.index_ ^ cmd.cmd_; + ENSURE_EQ(key, data->txdata[1], "Decoded KO (Key)"); + + uint8_t re_pivot = uuid[1] ^ uuid[2] ^ uid[2]; + re_pivot ^= ((re_pivot & 1) - 1); + ENSURE_EQ(pivot, re_pivot, "Decoded KO (Pivot)"); + + return true; +} + +void ZhijiaEncoderV1::encode(uint8_t* buf, Command &cmd_real, ControllerParam_t & cont) { + unsigned char uuid[UUID_LEN] = {0}; + this->id_to_uuid(uuid, cont.id_, UUID_LEN); + + data_map_t * data = (data_map_t *) buf; + std::reverse_copy(MAC, MAC + ADDR_LEN, data->addr); + this->reverse_all(data->addr, ADDR_LEN); + + uint8_t pivot = uuid[1] ^ uuid[2] ^ UID[2]; + pivot ^= (pivot & 1) - 1; + + uint8_t key = cmd_real.args_[0] ^ cmd_real.args_[1] ^ cmd_real.args_[2]; + key ^= uuid[0] ^ uuid[1] ^ uuid[2] ^ cont.tx_count_ ^ cont.index_ ^ cmd_real.cmd_ ^ UID[0] ^ UID[1] ^ UID[2]; + + data->txdata[0] = pivot ^ cmd_real.args_[0]; + data->txdata[1] = pivot ^ key; + data->txdata[2] = pivot ^ uuid[0]; + data->txdata[3] = pivot ^ cmd_real.args_[1]; + data->txdata[4] = pivot ^ cont.tx_count_; + data->txdata[5] = pivot ^ cmd_real.args_[2]; + data->txdata[6] = pivot ^ cont.index_; + data->txdata[7] = pivot ^ UID[0]; + data->txdata[8] = pivot; + data->txdata[9] = pivot ^ cmd_real.cmd_; + data->txdata[10] = pivot ^ UID[1]; + data->txdata[11] = pivot; + data->txdata[12] = uuid[1] ^ data->txdata[2]; + data->txdata[13] = UID[2] ^ data->txdata[4]; + data->txdata[14] = data->txdata[7]; + data->txdata[15] = uuid[2] ^ data->txdata[9]; + data->txdata[16] = pivot; + + data->crc16 = this->crc16(buf, ADDR_LEN + TXDATA_LEN); + this->whiten(buf, this->len_, 0x37); +} + +std::vector< Command > ZhijiaEncoderV2::translate(const Command & cmd, const ControllerParam_t & cont) { + auto cmds = ZhijiaEncoderV1::translate(cmd, cont); + if (!cmds.empty()) return cmds; + + Command cmd_real(cmd.main_cmd_); + switch(cmd.main_cmd_) + { + case CommandType::FAN_ON: + cmd_real.cmd_ = 0xD2; // -47 + break; + case CommandType::FAN_OFF: + cmd_real.cmd_ = 0xD3; // -46 + break; + case CommandType::FAN_SPEED: + // -37 + speed(1..6) => -36 -> -31 + cmd_real.cmd_ = 0xDB + ((cmd.args_[1] == 3) ? 2 * cmd.args_[0] : cmd.args_[0]); + break; + default: + break; + } + if(cmd_real.cmd_ != 0x00) { + cmds.emplace_back(cmd_real); + } + return cmds; +} + +bool ZhijiaEncoderV2::decode(uint8_t* buf, Command &cmd, ControllerParam_t & cont) { + this->whiten(buf, this->len_, 0x6F); + this->whiten(buf, this->len_ - 2, 0xD3); + + data_map_t * data = (data_map_t *) buf; + for (size_t i = 0; i < TXDATA_LEN; ++i) { + data->txdata[i] ^= data->pivot; + } + + cont.tx_count_ = data->txdata[4]; + cont.index_ = data->txdata[6]; + cmd.cmd_ = (CommandType) (data->txdata[9]); + uint8_t addr[ADDR_LEN]; + addr[0] = data->txdata[7]; + addr[1] = data->txdata[10]; + addr[2] = data->txdata[13] ^ cont.tx_count_; + cmd.args_[0] = data->txdata[0]; + cmd.args_[1] = data->txdata[3]; + cmd.args_[2] = data->txdata[5]; + uint8_t uuid[UUID_LEN]{0}; + uuid[0] = data->txdata[2]; + uuid[1] = data->txdata[12] ^ uuid[0]; + uuid[2] = data->txdata[15] ^ cmd.cmd_; + cont.id_ = this->uuid_to_id(uuid, UUID_LEN); + + ENSURE_EQ(std::equal(addr, addr + ADDR_LEN, MAC), true, "Decoded KO (MAC)"); + + uint8_t key = addr[0] ^ addr[1] ^ addr[2] ^ cont.index_ ^ cont.tx_count_ ^ cmd.args_[0] ^ cmd.args_[1] ^ cmd.args_[2] ^ uuid[0] ^ uuid[1] ^ uuid[2]; + ENSURE_EQ(key, data->txdata[1], "Decoded KO (Key)"); + + uint8_t re_pivot = uuid[0] ^ uuid[1] ^ uuid[2] ^ cont.tx_count_ ^ cmd.args_[1] ^ addr[0] ^ addr[2] ^ cmd.cmd_; + re_pivot = ((re_pivot & 1) - 1) ^ re_pivot; + ENSURE_EQ(data->pivot, re_pivot, "Decoded KO (Pivot)"); + + ENSURE_EQ(data->txdata[8], uuid[0] ^ cont.tx_count_ ^ cmd.args_[1] ^ addr[0], "Decoded KO (txdata 8)"); + ENSURE_EQ(data->txdata[11], 0x00, "Decoded KO (txdata 11)"); + ENSURE_EQ(data->txdata[14], uuid[0] ^ cont.tx_count_ ^ cmd.args_[1] ^ cmd.cmd_, "Decoded KO (txdata 14)"); + + return true; +} + +void ZhijiaEncoderV2::encode(uint8_t* buf, Command &cmd_real, ControllerParam_t & cont) { + unsigned char uuid[UUID_LEN] = {0}; + this->id_to_uuid(uuid, cont.id_, UUID_LEN); + + data_map_t * data = (data_map_t *) buf; + uint8_t key = MAC[0] ^ MAC[1] ^ MAC[2] ^ cont.index_ ^ cont.tx_count_; + key ^= cmd_real.args_[0] ^ cmd_real.args_[1] ^ cmd_real.args_[2] ^ uuid[0] ^ uuid[1] ^ uuid[2]; + + data->pivot = uuid[0] ^ uuid[1] ^ uuid[2] ^ cont.tx_count_ ^ cmd_real.args_[1] ^ MAC[0] ^ MAC[2] ^ cmd_real.cmd_; + data->pivot = ((data->pivot & 1) - 1) ^ data->pivot; + + data->txdata[0] = cmd_real.args_[0]; + data->txdata[1] = key; + data->txdata[2] = uuid[0]; + data->txdata[3] = cmd_real.args_[1]; + data->txdata[4] = cont.tx_count_; + data->txdata[5] = cmd_real.args_[2]; + data->txdata[6] = cont.index_; + data->txdata[7] = MAC[0]; + data->txdata[8] = uuid[0] ^ cont.tx_count_ ^ cmd_real.args_[1] ^ MAC[0]; + data->txdata[9] = cmd_real.cmd_; + data->txdata[10] = MAC[1]; + data->txdata[11] = 0x00; + data->txdata[12] = uuid[1] ^ uuid[0]; + data->txdata[13] = MAC[2] ^ cont.tx_count_; + data->txdata[14] = uuid[0] ^ cont.tx_count_ ^ cmd_real.args_[1] ^ cmd_real.cmd_; + data->txdata[15] = uuid[2] ^ cmd_real.cmd_; + + for (size_t i = 0; i < TXDATA_LEN; ++i) { + data->txdata[i] ^= data->pivot; + } + + this->whiten(buf, this->len_ - 2, 0xD3); + this->whiten(buf, this->len_, 0x6F); +} + +} // namespace bleadvcontroller +} // namespace esphome diff --git a/components/ble_adv_controller/zhijia.h b/components/ble_adv_controller/zhijia.h new file mode 100644 index 0000000..379a8cb --- /dev/null +++ b/components/ble_adv_controller/zhijia.h @@ -0,0 +1,85 @@ +#pragma once + +#include "ble_adv_controller.h" + +namespace esphome { +namespace bleadvcontroller { + +class ZhijiaEncoder: public BleAdvEncoder +{ +public: + ZhijiaEncoder(const std::string & encoding, const std::string & variant): BleAdvEncoder(encoding, variant) {} + +protected: + uint16_t crc16(uint8_t* buf, size_t len, uint16_t seed = 0); + uint32_t uuid_to_id(uint8_t * uuid, size_t len); + void id_to_uuid(uint8_t * uuid, uint32_t id, size_t len); +}; + +class ZhijiaEncoderV0: public ZhijiaEncoder +{ +public: + ZhijiaEncoderV0(const std::string & encoding, const std::string & variant): + ZhijiaEncoder(encoding, variant) { { this->len_ = sizeof(data_map_t); }} + +protected: + static constexpr size_t UUID_LEN = 2; + static constexpr size_t ADDR_LEN = 3; + static constexpr size_t TXDATA_LEN = 8; + struct data_map_t { + uint8_t addr[ADDR_LEN]; + uint8_t txdata[TXDATA_LEN]; + uint16_t crc16; + }__attribute__((packed, aligned(1))); + + virtual std::vector< Command > translate(const Command & cmd, const ControllerParam_t & cont) override; + virtual bool decode(uint8_t* buf, Command &cmd, ControllerParam_t & cont) override; + virtual void encode(uint8_t* buf, Command &cmd, ControllerParam_t & cont) override; +}; + +class ZhijiaEncoderV1: public ZhijiaEncoder +{ +public: + ZhijiaEncoderV1(const std::string & encoding, const std::string & variant): + ZhijiaEncoder(encoding, variant) { this->len_ = sizeof(data_map_t); } + +protected: + static constexpr size_t UUID_LEN = 3; + static constexpr size_t ADDR_LEN = 4; + static constexpr size_t TXDATA_LEN = 17; + struct data_map_t { + uint8_t addr[ADDR_LEN]; + uint8_t txdata[TXDATA_LEN]; + uint16_t crc16; + }__attribute__((packed, aligned(1))); + + virtual std::vector< Command > translate(const Command & cmd, const ControllerParam_t & cont) override; + virtual bool decode(uint8_t* buf, Command &cmd, ControllerParam_t & cont) override; + virtual void encode(uint8_t* buf, Command &cmd, ControllerParam_t & cont) override; +}; + +class ZhijiaEncoderV2: public ZhijiaEncoderV1 +{ +public: + ZhijiaEncoderV2(const std::string & encoding, const std::string & variant): + ZhijiaEncoderV1(encoding, variant) { this->len_ = sizeof(data_map_t); } + +protected: + static constexpr size_t UUID_LEN = 3; + static constexpr size_t ADDR_LEN = 3; + static constexpr size_t TXDATA_LEN = 16; + static constexpr size_t SPARE_LEN = 7; + struct data_map_t { + uint8_t txdata[TXDATA_LEN]; + uint8_t pivot; + uint8_t spare[SPARE_LEN]; + }__attribute__((packed, aligned(1))); + + virtual std::vector< Command > translate(const Command & cmd, const ControllerParam_t & cont) override; + virtual bool decode(uint8_t* buf, Command &cmd, ControllerParam_t & cont) override; + virtual void encode(uint8_t* buf, Command &cmd, ControllerParam_t & cont) override; +}; + + +} //namespace bleadvcontroller +} //namespace esphome diff --git a/components/ble_adv_light/README.md b/components/ble_adv_light/README.md deleted file mode 100644 index f4fcfba..0000000 --- a/components/ble_adv_light/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# ble_adv_light - -## Known issues - -* Only tested with Marpou Ceiling CCT light, and a certain aftermarket LED driver (definitely doesn't support RGB lights currently, but that could be added in the future). -* All lights are controlled at the same time (does not support controlling different lamps individually - need help with ESPHome internals to figure this one out). -* ZhiJia based lights pairing functionality hasn't been tested - if controlling a light that's been paired using the ZhiJia app doesn't work, please let me know. Unpairing will definitely not work (not supported in the app). - -## How to try it - -1. Create an ESPHome configuration that references the repo (using `external_components`) -2. Add a light entity to the configuration with the `ble_adv_light` platform -3. Build the configuration and flash it to an ESP32 device (since BLE is used, ESP8266-based devices are a no-go) -4. Add the new ESPHome node to your Home Assistant instance -5. Use the newly exposed service (`esphome._pair`) to pair with your light (call the service withing 5 seconds of powering it with a switch). -6. Enjoy controlling your BLE light with Home Assistant! - -## Example configuration (ZhiJia) - -```yaml -light: - - platform: ble_adv_light - type: zhijia - name: Kitchen Light - duration: 750 - default_transition_length: 0s -``` - -## Example configuration (LampSmart Pro) - -```yaml -light: - - platform: ble_adv_light - type: lampsmart_pro - name: Kitchen Light - duration: 750 - default_transition_length: 0s -``` - -## Potentially fixable issues - -If this component works, but the cold and warm temperatures are reversed (that is, setting the temperature in Home Assistant to warm results in cold/blue light, and setting it to cold results in warm/yellow light), add a `reversed: true` line to your `ble_adv_light` config. - -If the minimum brightness is too bright, and you know that your light can go darker - try changing the minimum brightness via the `min_brightness` configuration option (it takes a number between 1 and 255). diff --git a/components/ble_adv_light/__init__.py b/components/ble_adv_light/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/components/ble_adv_light/ble_adv_light.cpp b/components/ble_adv_light/ble_adv_light.cpp deleted file mode 100644 index f4d86ea..0000000 --- a/components/ble_adv_light/ble_adv_light.cpp +++ /dev/null @@ -1,88 +0,0 @@ -#include "ble_adv_light.h" -#include "msc26a.h" -#include "esphome/core/log.h" - -#ifdef USE_ESP32 - -#include -#include - -namespace esphome { -namespace bleadvlight { - -static const char *TAG = "ble_adv_light"; - -void BleAdvLight::setup() { -#ifdef USE_API - register_service(&BleAdvLight::on_pair, light_state_ ? "pair_" + light_state_->get_object_id() : "pair"); - register_service(&BleAdvLight::on_unpair, light_state_ ? "unpair_" + light_state_->get_object_id() : "unpair"); -#endif -} - -light::LightTraits BleAdvLight::get_traits() { - auto traits = light::LightTraits(); - - traits.set_supported_color_modes({light::ColorMode::COLD_WARM_WHITE}); - traits.set_min_mireds(this->cold_white_temperature_); - traits.set_max_mireds(this->warm_white_temperature_); - - return traits; -} - -void BleAdvLight::dump_config() { - ESP_LOGCONFIG(TAG, "BleAdvLight '%s'", light_state_ ? light_state_->get_name().c_str() : ""); - ESP_LOGCONFIG(TAG, " Cold White Temperature: %f mireds", cold_white_temperature_); - ESP_LOGCONFIG(TAG, " Warm White Temperature: %f mireds", warm_white_temperature_); - ESP_LOGCONFIG(TAG, " Constant Brightness: %s", constant_brightness_ ? "true" : "false"); - ESP_LOGCONFIG(TAG, " Minimum Brightness: %d", min_brightness_); - ESP_LOGCONFIG(TAG, " Transmission Duratoin: %d millis", tx_duration_); -} - -void BleAdvLight::write_state(light::LightState *state) { - float cwf, wwf; - state->current_values_as_cwww(&cwf, &wwf, this->constant_brightness_); - - if (!cwf && !wwf) { - send_packet(CMD_TURN_OFF()); - _is_off = true; - - return; - } - - uint8_t cwi = (uint8_t)(0xff * cwf); - uint8_t wwi = (uint8_t)(0xff * wwf); - - if ((cwi < min_brightness_) && (wwi < min_brightness_)) { - if (cwf > 0.000001) { - cwi = min_brightness_; - } - - if (wwf > 0.000001) { - wwi = min_brightness_; - } - } - - ESP_LOGD(TAG, "BleAdvLight::write_state called! Requested cw: %d, ww: %d", cwi, wwi); - - if (_is_off) { - send_packet(CMD_TURN_ON()); - _is_off = false; - } - - update_channels(cwi, wwi); -} - -void BleAdvLight::on_pair() { - ESP_LOGD(TAG, "BleAdvLight::on_pair called!"); - send_packet(CMD_PAIR()); -} - -void BleAdvLight::on_unpair() { - ESP_LOGD(TAG, "BleAdvLight::on_unpair called!"); - send_packet(CMD_UNPAIR()); -} - -} // namespace bleadvlight -} // namespace esphome - -#endif diff --git a/components/ble_adv_light/ble_adv_light.h b/components/ble_adv_light/ble_adv_light.h deleted file mode 100644 index 075de49..0000000 --- a/components/ble_adv_light/ble_adv_light.h +++ /dev/null @@ -1,119 +0,0 @@ -#pragma once - -#include "esphome.h" -#include "esphome/core/log.h" -#ifdef USE_API -#include "esphome/components/api/custom_api_device.h" -#endif -#include "esphome/components/light/light_output.h" - -namespace esphome { -namespace bleadvlight { - -class BleAdvLight : public light::LightOutput, public Component, public EntityBase -#ifdef USE_API - , public api::CustomAPIDevice -#endif -{ - public: - void setup() override; - void dump_config() override; - - void set_cold_white_temperature(float cold_white_temperature) { cold_white_temperature_ = cold_white_temperature; } - void set_warm_white_temperature(float warm_white_temperature) { warm_white_temperature_ = warm_white_temperature; } - void set_constant_brightness(bool constant_brightness) { constant_brightness_ = constant_brightness; } - void set_reversed(bool reversed) { reversed_ = reversed; } - void set_min_brightness(uint8_t min_brightness) { min_brightness_ = min_brightness; } - void set_tx_duration(uint32_t tx_duration) { tx_duration_ = tx_duration; } - void setup_state(light::LightState *state) override { this->light_state_ = state; } - void write_state(light::LightState *state) override; - light::LightTraits get_traits() override; - void on_pair(); - void on_unpair(); - - protected: - virtual void update_channels(uint8_t cold, uint8_t warm) = 0; - virtual void send_packet(uint8_t cmd, uint8_t *args = NULL) = 0; - - virtual uint8_t CMD_PAIR() = 0; - virtual uint8_t CMD_UNPAIR() = 0; - virtual uint8_t CMD_TURN_ON() = 0; - virtual uint8_t CMD_TURN_OFF() = 0; - virtual uint8_t CMD_DIM() = 0; - virtual uint8_t CMD_CCT() = 0; - - float cold_white_temperature_{167}; - float warm_white_temperature_{333}; - bool constant_brightness_; - bool reversed_; - uint8_t min_brightness_; - bool _is_off; - uint8_t tx_count_; - uint32_t tx_duration_; - light::LightState *light_state_; -}; - -class ZhiJiaLight : public BleAdvLight -{ - protected: - void send_packet(uint8_t cmd, uint8_t *args) override; - - uint8_t CMD_PAIR() override { return 0xA2; }; - uint8_t CMD_UNPAIR() override { return 0x45; }; - uint8_t CMD_TURN_ON() override { return 0xA5; }; - uint8_t CMD_TURN_OFF() override { return 0xA6; }; - uint8_t CMD_DIM() override { return 0xAD; }; - uint8_t CMD_CCT() override { return 0xAE; }; - - void update_channels(uint8_t cold, uint8_t warm) override { - uint8_t cct_args[1] = {(uint8_t) (255 * ((float) warm / (cold + warm)))}; - uint8_t dim_args[1] = {(uint8_t) (cold + warm > 255 ? 255 : cold + warm)}; - send_packet(CMD_CCT(), cct_args); - send_packet(CMD_DIM(), dim_args); - }; -}; - -class LampSmartProLight : public BleAdvLight -{ - protected: - void send_packet(uint8_t cmd, uint8_t *args) override; - - uint8_t CMD_PAIR() override { return 0x28; }; - uint8_t CMD_UNPAIR() override { return 0x45; }; - uint8_t CMD_TURN_ON() override { return 0x10; }; - uint8_t CMD_TURN_OFF() override { return 0x11; }; - uint8_t CMD_DIM() override { return 0x21; }; - uint8_t CMD_CCT() override { return 0; }; - - void update_channels(uint8_t cold, uint8_t warm) override { - uint8_t args[2] = {cold, warm}; - send_packet(CMD_DIM(), args); - }; -}; - -template class PairAction : public Action { - public: - explicit PairAction(esphome::light::LightState *state) : state_(state) {} - - void play(Ts... x) override { - ((BleAdvLight *)this->state_->get_output())->on_pair(); - } - - protected: - esphome::light::LightState *state_; -}; - -template class UnpairAction : public Action { - public: - explicit UnpairAction(esphome::light::LightState *state) : state_(state) {} - - void play(Ts... x) override { - ((BleAdvLight *)this->state_->get_output())->on_unpair(); - } - - protected: - esphome::light::LightState *state_; -}; - -} //namespace bleadvlight -} //namespace esphome diff --git a/components/ble_adv_light/lampsmart_pro.cpp b/components/ble_adv_light/lampsmart_pro.cpp deleted file mode 100644 index a8f2012..0000000 --- a/components/ble_adv_light/lampsmart_pro.cpp +++ /dev/null @@ -1,123 +0,0 @@ -#include "ble_adv_light.h" - -#ifdef USE_ESP32 - -#include -#include - -namespace esphome { -namespace bleadvlight { - -static const char *TAG = "lampsmartpro"; - -#pragma pack(1) -typedef union { - struct { /* Advertising Data */ - uint8_t prefix[10]; - uint8_t packet_number; - uint16_t type; - uint32_t identifier; - uint8_t var2; - uint16_t command; - uint16_t _20; - uint8_t channel1; - uint8_t channel2; - uint16_t _24; - uint8_t _26; - uint16_t rand; - uint16_t crc16; - }; - uint8_t raw[31]; -} adv_data_t; - -static esp_ble_adv_params_t ADVERTISING_PARAMS = { - .adv_int_min = 0x20, - .adv_int_max = 0x20, - .adv_type = ADV_TYPE_NONCONN_IND, - .own_addr_type = BLE_ADDR_TYPE_PUBLIC, - .peer_addr = - { - 0x00, - }, - .peer_addr_type = BLE_ADDR_TYPE_PUBLIC, - .channel_map = ADV_CHNL_ALL, - .adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY, -}; - -static uint8_t XBOXES[128] = { - 0xB7, 0xFD, 0x93, 0x26, 0x36, 0x3F, 0xF7, 0xCC, - 0x34, 0xA5, 0xE5, 0xF1, 0x71, 0xD8, 0x31, 0x15, - 0x04, 0xC7, 0x23, 0xC3, 0x18, 0x96, 0x05, 0x9A, - 0x07, 0x12, 0x80, 0xE2, 0xEB, 0x27, 0xB2, 0x75, - 0xD0, 0xEF, 0xAA, 0xFB, 0x43, 0x4D, 0x33, 0x85, - 0x45, 0xF9, 0x02, 0x7F, 0x50, 0x3C, 0x9F, 0xA8, - 0x51, 0xA3, 0x40, 0x8F, 0x92, 0x9D, 0x38, 0xF5, - 0xBC, 0xB6, 0xDA, 0x21, 0x10, 0xFF, 0xF3, 0xD2, - 0xE0, 0x32, 0x3A, 0x0A, 0x49, 0x06, 0x24, 0x5C, - 0xC2, 0xD3, 0xAC, 0x62, 0x91, 0x95, 0xE4, 0x79, - 0xE7, 0xC8, 0x37, 0x6D, 0x8D, 0xD5, 0x4E, 0xA9, - 0x6C, 0x56, 0xF4, 0xEA, 0x65, 0x7A, 0xAE, 0x08, - 0xE1, 0xF8, 0x98, 0x11, 0x69, 0xD9, 0x8E, 0x94, - 0x9B, 0x1E, 0x87, 0xE9, 0xCE, 0x55, 0x28, 0xDF, - 0x8C, 0xA1, 0x89, 0x0D, 0xBF, 0xE6, 0x42, 0x68, - 0x41, 0x99, 0x2D, 0x0F, 0xB0, 0x54, 0xBB, 0x16 -}; - -void ble_whiten(uint8_t *buf, uint8_t size, uint8_t seed, uint8_t salt) { - for (uint8_t i = 0; i < size; ++i) { - buf[i] ^= XBOXES[(seed + i + 9) & 0x1f + (salt & 0x3) * 0x20]; - buf[i] ^= seed; - } -} - -uint16_t v2_crc16_ccitt(uint8_t *src, uint8_t size, uint16_t crc16_result) { - for (uint8_t i = 0; i < size; ++i) { - crc16_result = crc16_result ^ (*(uint16_t*) &src[i]) << 8; - for (uint8_t j = 8; j != 0; --j) { - if ((crc16_result & 0x8000) == 0) { - crc16_result <<= 1; - } else { - crc16_result = crc16_result << 1 ^ 0x1021; - } - } - } - - return crc16_result; -} - -void LampSmartProLight::send_packet(uint8_t cmd, uint8_t *args) { - uint16_t seed = (uint16_t) rand(); - - adv_data_t packet = {{ - .prefix = {0x02, 0x01, 0x02, 0x1B, 0x16, 0xF0, 0x08, 0x10, 0x80, 0x00}, - .packet_number = ++(this->tx_count_), - .type = 0x100, - .identifier = light_state_ ? light_state_->get_object_id_hash() : 0xcafebabe, - .var2 = 0x0, - .command = cmd, - ._20 = 0, - .channel1 = 0, - .channel2 = 0, - ._24 = 0, - ._26 = 0, - .rand = seed, - }}; - - if (args) { - packet.channel1 = reversed_ ? args[1] : args[0]; - packet.channel2 = reversed_ ? args[0] : args[1]; - } - - ble_whiten(&packet.raw[9], 0x12, (uint8_t) seed, 0); - packet.crc16 = v2_crc16_ccitt(&packet.raw[7], 0x16, ~seed); - - ESP_ERROR_CHECK_WITHOUT_ABORT(esp_ble_gap_config_adv_data_raw(packet.raw, sizeof(packet))); - ESP_ERROR_CHECK_WITHOUT_ABORT(esp_ble_gap_start_advertising(&ADVERTISING_PARAMS)); - delay(tx_duration_); - ESP_ERROR_CHECK_WITHOUT_ABORT(esp_ble_gap_stop_advertising()); -} - -} // namespace bleadvlight -} // namespace esphome - -#endif diff --git a/components/ble_adv_light/light.py b/components/ble_adv_light/light.py deleted file mode 100644 index 11d2a59..0000000 --- a/components/ble_adv_light/light.py +++ /dev/null @@ -1,98 +0,0 @@ -import esphome.codegen as cg -import esphome.config_validation as cv -from esphome.components import light, output -from esphome import automation -from esphome.const import ( - CONF_DURATION, - CONF_CONSTANT_BRIGHTNESS, - CONF_OUTPUT_ID, - CONF_COLD_WHITE_COLOR_TEMPERATURE, - CONF_WARM_WHITE_COLOR_TEMPERATURE, - CONF_REVERSED, - CONF_MIN_BRIGHTNESS, - CONF_TYPE, - CONF_ID, -) - -AUTO_LOAD = ["esp32_ble"] -DEPENDENCIES = ["esp32"] - -bleadvlight_ns = cg.esphome_ns.namespace('bleadvlight') -BleAdvLight = bleadvlight_ns.class_('BleAdvLight', cg.Component, light.LightOutput) -ZhiJiaLight = bleadvlight_ns.class_('ZhiJiaLight', BleAdvLight) -LampSmartProLight = bleadvlight_ns.class_('LampSmartProLight', BleAdvLight) -PairAction = bleadvlight_ns.class_("PairAction", automation.Action) -UnpairAction = bleadvlight_ns.class_("UnpairAction", automation.Action) - - -ACTION_ON_PAIR_SCHEMA = cv.All( - automation.maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(light.LightState), - } - ) -) - -ACTION_ON_UNPAIR_SCHEMA = cv.All( - automation.maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(light.LightState), - } - ) -) - -CONFIG_SCHEMA = cv.All( - light.RGB_LIGHT_SCHEMA.extend( - { - cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(BleAdvLight), - cv.Required(CONF_TYPE): cv.one_of("zhijia", "lampsmart_pro", lower=True), - cv.Optional(CONF_DURATION, default=100): cv.positive_int, - cv.Optional(CONF_COLD_WHITE_COLOR_TEMPERATURE): cv.color_temperature, - cv.Optional(CONF_WARM_WHITE_COLOR_TEMPERATURE): cv.color_temperature, - cv.Optional(CONF_CONSTANT_BRIGHTNESS, default=False): cv.boolean, - cv.Optional(CONF_REVERSED, default=False): cv.boolean, - cv.Optional(CONF_MIN_BRIGHTNESS, default=0x7): cv.hex_uint8_t, - } - ), - cv.has_none_or_all_keys( - [CONF_COLD_WHITE_COLOR_TEMPERATURE, CONF_WARM_WHITE_COLOR_TEMPERATURE] - ), - light.validate_color_temperature_channels, -) - - -async def to_code(config): - match config[CONF_TYPE]: - case "zhijia": - subclass = ZhiJiaLight.new - case "lampsmart_pro": - subclass = LampSmartProLight.new - var = cg.Pvariable(config[CONF_OUTPUT_ID], subclass) - await cg.register_component(var, config) - await light.register_light(var, config) - - if CONF_COLD_WHITE_COLOR_TEMPERATURE in config: - cg.add( - var.set_cold_white_temperature(config[CONF_COLD_WHITE_COLOR_TEMPERATURE]) - ) - - if CONF_WARM_WHITE_COLOR_TEMPERATURE in config: - cg.add( - var.set_warm_white_temperature(config[CONF_WARM_WHITE_COLOR_TEMPERATURE]) - ) - - cg.add(var.set_constant_brightness(config[CONF_CONSTANT_BRIGHTNESS])) - cg.add(var.set_reversed(config[CONF_REVERSED])) - cg.add(var.set_min_brightness(config[CONF_MIN_BRIGHTNESS])) - cg.add(var.set_tx_duration(config[CONF_DURATION])) - - -@automation.register_action( - "bleadvlight.pair", PairAction, ACTION_ON_PAIR_SCHEMA -) -@automation.register_action( - "bleadvlight.unpair", UnpairAction, ACTION_ON_UNPAIR_SCHEMA -) -async def bleadvlight_pair_to_code(config, action_id, template_arg, args): - parent = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, parent) diff --git a/components/ble_adv_light/msc26a.cpp b/components/ble_adv_light/msc26a.cpp deleted file mode 100644 index 48c677e..0000000 --- a/components/ble_adv_light/msc26a.cpp +++ /dev/null @@ -1,264 +0,0 @@ -#include "msc26a.h" - -void stage1(unsigned char *x0, unsigned char w20, unsigned char w21, - unsigned char w22, unsigned char *x23, const unsigned char *x24, - unsigned char *buffer) { - unsigned short w8 = 0xa6aa; - size_t xzr = 0; - unsigned char sp[0x100] = {0}; - unsigned short wzr = 0; - size_t x8 = 0xa6aa; - unsigned char w11, w13, w14, w15, w16, w17, w1, w12, w9, w10, w0, w3, w2, w4, - w6, w5; - - w8 = 0xa6aa; - // sp = sp - 0x30; - *(size_t *)(sp + 0x40) = wzr; - *(size_t *)(sp + 0x28) = x8; - w11 = *(unsigned char *)(x24); - w13 = *(unsigned char *)(x0 + 1); - w14 = *(unsigned char *)(x0 + 2); - w15 = *(unsigned char *)(x23); - w16 = w11 ^ w21; - w17 = w11 ^ w13; - w8 = *(unsigned char *)(x24 + 1); - w1 = w16 ^ w13; - w16 = w17 ^ w21; - w12 = *(unsigned char *)(x23 + 1); - w9 = *(unsigned char *)(x23 + 2); - w2 = w16 ^ w14; - w10 = *(unsigned char *)(x24 + 2); - w17 = w1 ^ w15; - w2 = w2 ^ w22; - w16 = w17 ^ w20; - w1 = w2 ^ w1; - w2 = w8 ^ w16; - w1 = w1 ^ w20; - w2 = w2 ^ w9; - w1 = w1 ^ w12; - w2 = w2 ^ w10; - w0 = *(unsigned char *)(x0); - w1 = w1 ^ w8; - w3 = w2 & 1; - w1 = w1 ^ w9; - w3 = w3 - 1; - w1 = w1 ^ w16; - w2 = w3 ^ w2; - w1 = w1 ^ w10; - w3 = *(unsigned char *)(sp + 0x35); - w1 = w0 ^ w1; - w0 = w2 ^ w0; - *(unsigned char *)(sp + 0x2a) = w0; - w0 = *(unsigned char *)(sp + 0x3a); - w11 = w2 ^ w11; - w13 = w2 ^ w13; - w4 = w2 ^ w21; - w15 = w2 ^ w15; - w6 = w2 ^ w20; - w12 = w2 ^ w12; - w3 = w2 ^ w3; - w14 = w2 ^ w14; - w5 = w2 ^ w22; - w17 = w2 ^ w17; - *(unsigned char *)(sp + 0x2c) = w11; - *(unsigned char *)(sp + 0x2d) = w13; - *(unsigned char *)(sp + 0x34) = w12; - *(unsigned char *)(sp + 0x35) = w3; - w8 = w8 ^ w11; - w9 = w9 ^ w4; - w11 = w16 ^ w15; - w10 = w10 ^ w6; - w12 = w0 ^ w3; - w13 = w2 ^ w1; - x0 = sp + 0x28; - *(size_t *)(sp + 0x10) = xzr; - *(size_t *)(sp + 8) = xzr; - *(unsigned char *)(sp + 0x2e) = w4; - *(unsigned char *)(sp + 0x2f) = w14; - *(unsigned char *)(sp + 0x30) = w5; - *(unsigned char *)(sp + 0x31) = w15; - *(unsigned char *)(sp + 0x32) = w17; - *(unsigned char *)(sp + 0x33) = w6; - *(unsigned char *)(sp + 0x36) = w8; - *(unsigned char *)(sp + 0x37) = w9; - *(unsigned char *)(sp + 0x38) = w11; - *(unsigned char *)(sp + 0x39) = w10; - *(unsigned char *)(sp + 0x3a) = w12; - *(unsigned char *)(sp + 0x2b) = w13; - *(unsigned short *)(sp + 0x20) = wzr; - - memcpy(buffer, (sp + 0x28), 0x18); -} - -unsigned char *stage2(unsigned char *d, unsigned int n, unsigned int md) { - int8_t cVar1; - uint8_t *pbVar2; - unsigned int in_w3 = 0xa6; - long lVar3; - unsigned int uVar4; - unsigned int uVar5; - - if (((unsigned int)md & 0xff) < (unsigned int)(uint8_t)n) { - lVar3 = (n & 0xff) - ((size_t)md & 0xff); - cVar1 = (int8_t)in_w3; - pbVar2 = d + ((size_t)md & 0xff); - while (1) { - if (cVar1 < '\0') { - in_w3 ^= 0x11; - *pbVar2 = *pbVar2 ^ 1; - } - uVar5 = (unsigned int)(int8_t)(in_w3 << 1); - if ((int)uVar5 < 0) { - uVar5 ^= 0x11; - *pbVar2 = *pbVar2 ^ 2; - } - uVar5 = (unsigned int)(int8_t)(uVar5 << 1); - if ((int)uVar5 < 0) { - uVar5 ^= 0x11; - *pbVar2 = *pbVar2 ^ 4; - } - uVar5 = (unsigned int)(int8_t)(uVar5 << 1); - if ((int)uVar5 < 0) { - uVar5 ^= 0x11; - *pbVar2 = *pbVar2 ^ 8; - } - uVar5 = (unsigned int)(int8_t)(uVar5 << 1); - if ((int)uVar5 < 0) { - uVar5 ^= 0x11; - *pbVar2 = *pbVar2 ^ 0x10; - } - uVar5 = (unsigned int)(int8_t)(uVar5 << 1); - if ((int)uVar5 < 0) { - uVar5 ^= 0x11; - *pbVar2 = *pbVar2 ^ 0x20; - } - uVar5 = (unsigned int)(int8_t)(uVar5 << 1); - if ((int)uVar5 < 0) { - uVar5 ^= 0x11; - *pbVar2 = *pbVar2 ^ 0x40; - } - uVar5 = (unsigned int)(int8_t)(uVar5 << 1); - if ((int)uVar5 < 0) { - uVar5 ^= 0x11; - uVar4 = *pbVar2 ^ 0xffffff80; - *pbVar2 = (uint8_t)uVar4; - } else { - uVar4 = (unsigned int)*pbVar2; - } - in_w3 = uVar5 << 1; - lVar3 += -1; - *pbVar2 = - (uint8_t)(uVar4 >> 7) & 1 | - (uint8_t)((uVar4 >> 6 & 1 | - (uVar4 >> 5 & 1 | - ((uVar4 & 0xff) >> 4 & 1 | - (uVar4 >> 3 & 1 | - (uVar4 & 2 | (uVar4 & 1) << 2 | (uVar4 & 0xff) >> 2 & 1) - << 1) - << 1) - << 1) - << 1) - << 1); - if (lVar3 == 0) - break; - cVar1 = (int8_t)in_w3; - pbVar2 = pbVar2 + 1; - } - } - return d; -} - -void whitening_init(unsigned char value, unsigned int *seed) { - unsigned int *puVar1; - unsigned int uVar2; - - *seed = 1; - puVar1 = seed + 1; - for (uVar2 = 5; uVar2 != 0xffffffff; uVar2 -= 1) { - *puVar1 = value >> (uVar2 & 0xff) & 1; - puVar1++; - } - return; -} - -unsigned char invert_8(unsigned char param_1) { - unsigned int uVar1; - unsigned int uVar2; - unsigned int uVar3; - - uVar2 = 7; - uVar3 = 0; - for (uVar1 = 0; uVar1 != 8; uVar1 += 1) { - if ((1 << (uVar1 & 0xff) & param_1) != 0) { - uVar3 |= 1 << (uVar2 & 0xff); - } - uVar2 -= 1; - } - return uVar3 & 0xff; -} - -unsigned int whitening_output(unsigned int *param_1) { - unsigned int uVar1; - unsigned int uVar2; - unsigned int uVar3; - - uVar1 = param_1[1]; - uVar2 = param_1[2]; - uVar3 = param_1[3]; - param_1[1] = *param_1; - param_1[2] = uVar1; - param_1[3] = uVar2; - uVar1 = param_1[6]; - *param_1 = uVar1; - uVar2 = param_1[5]; - param_1[5] = param_1[4]; - param_1[6] = uVar2; - param_1[4] = uVar1 ^ uVar3; - return uVar1; -} - -void whitening_encode(unsigned char *buffer, int length, unsigned int *seed) { - unsigned char bVar1; - unsigned int uVar2; - unsigned int uVar3; - int iVar4; - int iVar5; - - for (iVar5 = 0; iVar5 < length; iVar5 += 1) { - bVar1 = *(unsigned char *)(buffer + iVar5); - iVar4 = 0; - for (uVar3 = 0; uVar3 != 8; uVar3 += 1) { - uVar2 = whitening_output(seed); - iVar4 += (uVar2 ^ bVar1 >> (uVar3 & 0xff) & 1) << (uVar3 & 0xff); - } - *(char *)(buffer + iVar5) = (char)iVar4; - } - return; -} - -void stage3(unsigned char *buffer, size_t length, unsigned char *dest) { - unsigned char whitened[39]; - unsigned char seed[28] = {0}; - - whitening_init(0x25, (unsigned int *)seed); - - for (int i = 0; i < length; ++i) { - whitened[13 + i] = invert_8(buffer[i]); - } - - whitening_encode(whitened, length + 0xd, (unsigned int *)seed); - - for (int i = 0; i < length; ++i) { - dest[i] = whitened[13 + i]; - } -} - -void get_adv_data(uint8_t *mfg_data, uint8_t *args, uint8_t cmd, uint8_t sn, - const uint8_t *uuid) { - unsigned char MAC[] = {0x19, 0x01, 0x10}; - unsigned char GROUP = 127; - - stage1(args, cmd, sn, GROUP, MAC, uuid, mfg_data); - stage2(mfg_data, 0x18, 0x2); - stage3(mfg_data, 0x1a, mfg_data); -} \ No newline at end of file diff --git a/components/ble_adv_light/msc26a.h b/components/ble_adv_light/msc26a.h deleted file mode 100644 index 5dab515..0000000 --- a/components/ble_adv_light/msc26a.h +++ /dev/null @@ -1,6 +0,0 @@ -#pragma once - -#include -#include - -void get_adv_data(uint8_t* mfg_data, uint8_t* args, uint8_t cmd, uint8_t sn, const uint8_t* uuid); \ No newline at end of file diff --git a/components/ble_adv_light/zhijia_light.cpp b/components/ble_adv_light/zhijia_light.cpp deleted file mode 100644 index f1577cc..0000000 --- a/components/ble_adv_light/zhijia_light.cpp +++ /dev/null @@ -1,75 +0,0 @@ -#include "ble_adv_light.h" -#include "msc26a.h" - -// #define CMD_PAIR (0xA2) -// #define CMD_UNPAIR (0x45) -// #define CMD_TURN_ON (0xA5) -// #define CMD_TURN_OFF (0xA6) -// #define CMD_DIM (0xAD) -// #define CMD_CCT (0xAE) - -#ifdef USE_ESP32 - -#include -#include - -namespace esphome { -namespace bleadvlight { - -static const char *TAG = "zhijia_light"; -static const unsigned char UUID1[] = {0xc6, 0x30, 0xb8}; -static const unsigned char MAC[] = {0x19, 0x01, 0x10}; -static const unsigned char GROUP = 127; - -static esp_ble_adv_params_t ADVERTISING_PARAMS = { - .adv_int_min = 0x20, - .adv_int_max = 0x20, - .adv_type = ADV_TYPE_NONCONN_IND, - .own_addr_type = BLE_ADDR_TYPE_PUBLIC, - .peer_addr = - { - 0x00, - }, - .peer_addr_type = BLE_ADDR_TYPE_PUBLIC, - .channel_map = ADV_CHNL_ALL, - .adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY, -}; - -void ZhiJiaLight::send_packet(uint8_t cmd, uint8_t *val) { - unsigned char args[] = {0, 0, 0}; - unsigned char mfg_data[0x1a] = {0}; - char mfg_data_dump[3 * sizeof(mfg_data) + 1] = {0}; - esp_ble_adv_data_t adv_data = { - .set_scan_rsp = false, - .include_name = false, - .include_txpower = false, - .min_interval = 0x0001, - .max_interval = 0x0004, - .appearance = 0x00, - .manufacturer_len = sizeof(mfg_data), - .p_manufacturer_data = mfg_data, - .flag = (ESP_BLE_ADV_FLAG_LIMIT_DISC | ESP_BLE_ADV_FLAG_GEN_DISC | ESP_BLE_ADV_FLAG_DMT_CONTROLLER_SPT), - }; - - if (val) { - args[0] = val[0]; - } - - ESP_LOGD(TAG, "ZhiJiaLight::send_packet called!"); - ESP_LOGD(TAG, " command: %02x", cmd); - ESP_LOGD(TAG, " args: [%02x, %02x, %02x]", args[0], args[1], args[2]); - ESP_LOGD(TAG, "ZhiJiaLight::send_packet called! Sending mfg_data:"); - get_adv_data(mfg_data, args, cmd, ++(this->tx_count_), UUID1); - for (int i = 0; i < sizeof(mfg_data); ++i) snprintf(mfg_data_dump + i * 3, 4, "%02x ", mfg_data[i]); - ESP_LOGD(TAG, " %s", mfg_data_dump); - - ESP_ERROR_CHECK_WITHOUT_ABORT(esp_ble_gap_config_adv_data(&adv_data)); - ESP_ERROR_CHECK_WITHOUT_ABORT(esp_ble_gap_start_advertising(&ADVERTISING_PARAMS)); - delay(tx_duration_); - ESP_ERROR_CHECK_WITHOUT_ABORT(esp_ble_gap_stop_advertising()); -} - -} // namespace bleadvlight -} // namespace esphome - -#endif diff --git a/components/lampsmart_pro_light/README.md b/components/lampsmart_pro_light/README.md deleted file mode 100644 index ad8cc03..0000000 --- a/components/lampsmart_pro_light/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# Known issues - -* Only tested with Marpou Ceiling CCT light (definitely doesn't support RGB lights currently, but that could be added in the future). -* All lights are controlled at the same time (does not support controlling different lamps individually - need help with ESPHome internals to figure this one out). - -# How to try it - -1. Create an ESPHome configuration that references the repo (using `external_components`) -2. Add a light entity to the configuration with the `lampsmart_pro_light` platform -3. Build the configuration and flash it to an ESP32 device (since BLE is used, ESP8266-based devices are a no-go) -4. Add the new ESPHome node to your Home Assistant instance -5. Use the newly exposed service (`esphome._pair`) to pair with your light (call the service withing 5 seconds of powering it with a switch. -6. Enjoy controlling your Marpou light with Home Assistant! - -# Example configuration - -```yaml -light: - - platform: lampsmart_pro_light - name: Kitchen Light - duration: 1000 - default_transition_length: 0s -``` - -# Potentially fixable issues - -If this component works, but the cold and warm temperatures are reversed (that is, setting the temperature in Home Assistant to warm results in cold/blue light, and setting it to cold results in warm/yellow light), add a `reversed: true` line to your `lightsmart_pro_light` config. - -If the minimum brightness is too bright, and you know that your light can go darker - try changing the minimum brightness via the `min_brightness` configuration option (it takes a number between 1 and 255). \ No newline at end of file diff --git a/components/lampsmart_pro_light/__init__.py b/components/lampsmart_pro_light/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/components/lampsmart_pro_light/lampsmart_pro_light.cpp b/components/lampsmart_pro_light/lampsmart_pro_light.cpp deleted file mode 100644 index 0ac002b..0000000 --- a/components/lampsmart_pro_light/lampsmart_pro_light.cpp +++ /dev/null @@ -1,210 +0,0 @@ -#include "lampsmart_pro_light.h" -#include "esphome/core/log.h" - -#ifdef USE_ESP32 - -#include -#include -#include - -namespace esphome { -namespace lampsmartpro { - -static const char *TAG = "lampsmartpro"; - -#pragma pack(1) -typedef union { - struct { /* Advertising Data */ - uint8_t prefix[10]; - uint8_t packet_number; - uint16_t type; - uint32_t identifier; - uint8_t var2; - uint16_t command; - uint16_t _20; - uint8_t channel1; - uint8_t channel2; - uint16_t signature_v3; - uint8_t _26; - uint16_t rand; - uint16_t crc16; - }; - uint8_t raw[31]; -} adv_data_t; - -static esp_ble_adv_params_t ADVERTISING_PARAMS = { - .adv_int_min = 0x20, - .adv_int_max = 0x20, - .adv_type = ADV_TYPE_NONCONN_IND, - .own_addr_type = BLE_ADDR_TYPE_PUBLIC, - .peer_addr = - { - 0x00, - }, - .peer_addr_type = BLE_ADDR_TYPE_PUBLIC, - .channel_map = ADV_CHNL_ALL, - .adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY, -}; - -static uint8_t XBOXES[128] = { - 0xB7, 0xFD, 0x93, 0x26, 0x36, 0x3F, 0xF7, 0xCC, - 0x34, 0xA5, 0xE5, 0xF1, 0x71, 0xD8, 0x31, 0x15, - 0x04, 0xC7, 0x23, 0xC3, 0x18, 0x96, 0x05, 0x9A, - 0x07, 0x12, 0x80, 0xE2, 0xEB, 0x27, 0xB2, 0x75, - 0xD0, 0xEF, 0xAA, 0xFB, 0x43, 0x4D, 0x33, 0x85, - 0x45, 0xF9, 0x02, 0x7F, 0x50, 0x3C, 0x9F, 0xA8, - 0x51, 0xA3, 0x40, 0x8F, 0x92, 0x9D, 0x38, 0xF5, - 0xBC, 0xB6, 0xDA, 0x21, 0x10, 0xFF, 0xF3, 0xD2, - 0xE0, 0x32, 0x3A, 0x0A, 0x49, 0x06, 0x24, 0x5C, - 0xC2, 0xD3, 0xAC, 0x62, 0x91, 0x95, 0xE4, 0x79, - 0xE7, 0xC8, 0x37, 0x6D, 0x8D, 0xD5, 0x4E, 0xA9, - 0x6C, 0x56, 0xF4, 0xEA, 0x65, 0x7A, 0xAE, 0x08, - 0xE1, 0xF8, 0x98, 0x11, 0x69, 0xD9, 0x8E, 0x94, - 0x9B, 0x1E, 0x87, 0xE9, 0xCE, 0x55, 0x28, 0xDF, - 0x8C, 0xA1, 0x89, 0x0D, 0xBF, 0xE6, 0x42, 0x68, - 0x41, 0x99, 0x2D, 0x0F, 0xB0, 0x54, 0xBB, 0x16 -}; - -void ble_whiten(uint8_t *buf, uint8_t size, uint8_t seed, uint8_t salt) { - for (uint8_t i = 0; i < size; ++i) { - buf[i] ^= XBOXES[(seed + i + 9) & 0x1f + (salt & 0x3) * 0x20]; - buf[i] ^= seed; - } -} - -uint16_t v2_crc16_ccitt(uint8_t *src, uint8_t size, uint16_t crc16_result) { - for (uint8_t i = 0; i < size; ++i) { - crc16_result = crc16_result ^ (*(uint16_t*) &src[i]) << 8; - for (uint8_t j = 8; j != 0; --j) { - if ((crc16_result & 0x8000) == 0) { - crc16_result <<= 1; - } else { - crc16_result = crc16_result << 1 ^ 0x1021; - } - } - } - - return crc16_result; -} - -void LampSmartProLight::setup() { -#ifdef USE_API - register_service(&LampSmartProLight::on_pair, light_state_ ? "pair_" + light_state_->get_object_id() : "pair"); - register_service(&LampSmartProLight::on_unpair, light_state_ ? "unpair_" + light_state_->get_object_id() : "unpair"); -#endif -} - -light::LightTraits LampSmartProLight::get_traits() { - auto traits = light::LightTraits(); - traits.set_supported_color_modes({light::ColorMode::COLD_WARM_WHITE}); - traits.set_min_mireds(this->cold_white_temperature_); - traits.set_max_mireds(this->warm_white_temperature_); - return traits; -} - -void LampSmartProLight::write_state(light::LightState *state) { - float cwf, wwf; - state->current_values_as_cwww(&cwf, &wwf, this->constant_brightness_); - - if (!cwf && !wwf) { - send_packet(CMD_TURN_OFF, 0, 0); - _is_off = true; - - return; - } - - uint8_t cwi = (uint8_t)(0xff * cwf); - uint8_t wwi = (uint8_t)(0xff * wwf); - - if ((cwi < min_brightness_) && (wwi < min_brightness_)) { - if (cwf > 0.000001) { - cwi = min_brightness_; - } - - if (wwf > 0.000001) { - wwi = min_brightness_; - } - } - - ESP_LOGD(TAG, "LampSmartProLight::write_state called! Requested cw: %d, ww: %d", cwi, wwi); - - if (_is_off) { - send_packet(CMD_TURN_ON, 0, 0); - _is_off = false; - } - - send_packet(CMD_DIM, cwi, wwi); -} - -void LampSmartProLight::dump_config() { - ESP_LOGCONFIG(TAG, "LampSmartProLight '%s'", light_state_ ? light_state_->get_name().c_str() : ""); - ESP_LOGCONFIG(TAG, " Cold White Temperature: %f mireds", cold_white_temperature_); - ESP_LOGCONFIG(TAG, " Warm White Temperature: %f mireds", warm_white_temperature_); - ESP_LOGCONFIG(TAG, " Constant Brightness: %s", constant_brightness_ ? "true" : "false"); - ESP_LOGCONFIG(TAG, " Minimum Brightness: %d", min_brightness_); - ESP_LOGCONFIG(TAG, " Transmission Duratoin: %d millis", tx_duration_); -} - -void LampSmartProLight::on_pair() { - ESP_LOGD(TAG, "LampSmartProLight::on_pair called!"); - send_packet(CMD_PAIR, 0, 0); -} - -void LampSmartProLight::on_unpair() { - ESP_LOGD(TAG, "LampSmartProLight::on_unpair called!"); - send_packet(CMD_UNPAIR, 0, 0); -} - -void sign_packet_v3(adv_data_t* packet) { - uint16_t seed = packet->rand; - uint8_t sigkey[16] = {0, 0, 0, 0x0D, 0xBF, 0xE6, 0x42, 0x68, 0x41, 0x99, 0x2D, 0x0F, 0xB0, 0x54, 0xBB, 0x16}; - uint8_t tx_count = (uint8_t) packet->packet_number; - - sigkey[0] = seed & 0xff; - sigkey[1] = (seed >> 8) & 0xff; - sigkey[2] = tx_count; - mbedtls_aes_context aes_ctx; - mbedtls_aes_init(&aes_ctx); - mbedtls_aes_setkey_enc(&aes_ctx, sigkey, sizeof(sigkey)*8); - uint8_t aes_in[16], aes_out[16]; - memcpy(aes_in, &(packet->raw[8]), 16); - mbedtls_aes_crypt_ecb(&aes_ctx, ESP_AES_ENCRYPT, aes_in, aes_out); - mbedtls_aes_free(&aes_ctx); - packet->signature_v3 = ((uint16_t*) aes_out)[0]; - if (packet->signature_v3 == 0) { - packet->signature_v3 = 0xffff; - } -} - -void LampSmartProLight::send_packet(uint16_t cmd, uint8_t cold, uint8_t warm) { - uint16_t seed = (uint16_t) rand(); - - adv_data_t packet = {{ - .prefix = {0x02, 0x01, 0x02, 0x1B, 0x16, 0xF0, 0x08, 0x10, 0x80, 0x00}, - .packet_number = ++(this->tx_count_), - .type = 0x100, - .identifier = light_state_ ? light_state_->get_object_id_hash() : 0xcafebabe, - .var2 = 0x0, - .command = cmd, - ._20 = 0, - .channel1 = reversed_ ? warm : cold, - .channel2 = reversed_ ? cold : warm, - .signature_v3 = 0, - ._26 = 0, - .rand = seed, - }}; - - sign_packet_v3(&packet); - ble_whiten(&packet.raw[9], 0x12, (uint8_t) seed, 0); - packet.crc16 = v2_crc16_ccitt(&packet.raw[7], 0x16, ~seed); - - ESP_ERROR_CHECK_WITHOUT_ABORT(esp_ble_gap_config_adv_data_raw(packet.raw, sizeof(packet))); - ESP_ERROR_CHECK_WITHOUT_ABORT(esp_ble_gap_start_advertising(&ADVERTISING_PARAMS)); - delay(tx_duration_); - ESP_ERROR_CHECK_WITHOUT_ABORT(esp_ble_gap_stop_advertising()); -} - -} // namespace lampsmartpro -} // namespace esphome - -#endif diff --git a/components/lampsmart_pro_light/lampsmart_pro_light.h b/components/lampsmart_pro_light/lampsmart_pro_light.h deleted file mode 100644 index 75931bb..0000000 --- a/components/lampsmart_pro_light/lampsmart_pro_light.h +++ /dev/null @@ -1,78 +0,0 @@ -#pragma once - -#include "esphome.h" -#ifdef USE_API -#include "esphome/components/api/custom_api_device.h" -#endif -#include "esphome/components/light/light_output.h" - -#define CMD_PAIR (0x28) -#define CMD_UNPAIR (0x45) -#define CMD_TURN_ON (0x10) -#define CMD_TURN_OFF (0x11) -#define CMD_DIM (0x21) - -namespace esphome { -namespace lampsmartpro { - -class LampSmartProLight : public light::LightOutput, public Component, public EntityBase -#ifdef USE_API - , public api::CustomAPIDevice -#endif -{ - public: - void setup() override; - void dump_config() override; - - void set_cold_white_temperature(float cold_white_temperature) { cold_white_temperature_ = cold_white_temperature; } - void set_warm_white_temperature(float warm_white_temperature) { warm_white_temperature_ = warm_white_temperature; } - void set_constant_brightness(bool constant_brightness) { constant_brightness_ = constant_brightness; } - void set_reversed(bool reversed) { reversed_ = reversed; } - void set_min_brightness(uint8_t min_brightness) { min_brightness_ = min_brightness; } - void set_tx_duration(uint32_t tx_duration) { tx_duration_ = tx_duration; } - void setup_state(light::LightState *state) override { this->light_state_ = state; } - void write_state(light::LightState *state) override; - light::LightTraits get_traits() override; - void on_pair(); - void on_unpair(); - - protected: - void send_packet(uint16_t cmd, uint8_t cold, uint8_t warm); - - float cold_white_temperature_{167}; - float warm_white_temperature_{333}; - bool constant_brightness_; - bool reversed_; - uint8_t min_brightness_; - bool _is_off; - uint8_t tx_count_; - uint32_t tx_duration_; - light::LightState *light_state_; -}; - -template class PairAction : public Action { - public: - explicit PairAction(esphome::light::LightState *state) : state_(state) {} - - void play(Ts... x) override { - ((LampSmartProLight *)this->state_->get_output())->on_pair(); - } - - protected: - esphome::light::LightState *state_; -}; - -template class UnpairAction : public Action { - public: - explicit UnpairAction(esphome::light::LightState *state) : state_(state) {} - - void play(Ts... x) override { - ((LampSmartProLight *)this->state_->get_output())->on_unpair(); - } - - protected: - esphome::light::LightState *state_; -}; - -} //namespace lampsmartpro -} //namespace esphome diff --git a/components/lampsmart_pro_light/light.py b/components/lampsmart_pro_light/light.py deleted file mode 100644 index 39511fd..0000000 --- a/components/lampsmart_pro_light/light.py +++ /dev/null @@ -1,89 +0,0 @@ -import esphome.codegen as cg -import esphome.config_validation as cv -from esphome.components import light, output -from esphome import automation -from esphome.const import ( - CONF_DURATION, - CONF_CONSTANT_BRIGHTNESS, - CONF_OUTPUT_ID, - CONF_COLD_WHITE_COLOR_TEMPERATURE, - CONF_WARM_WHITE_COLOR_TEMPERATURE, - CONF_REVERSED, - CONF_MIN_BRIGHTNESS, # New in 2023.5 - CONF_ID, -) - -AUTO_LOAD = ["esp32_ble"] -DEPENDENCIES = ["esp32"] - -lampsmartpro_ns = cg.esphome_ns.namespace('lampsmartpro') -LampSmartProLight = lampsmartpro_ns.class_('LampSmartProLight', cg.Component, light.LightOutput) -PairAction = lampsmartpro_ns.class_("PairAction", automation.Action) -UnpairAction = lampsmartpro_ns.class_("UnpairAction", automation.Action) - - -ACTION_ON_PAIR_SCHEMA = cv.All( - automation.maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(light.LightState), - } - ) -) - -ACTION_ON_UNPAIR_SCHEMA = cv.All( - automation.maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(light.LightState), - } - ) -) - -CONFIG_SCHEMA = cv.All( - light.RGB_LIGHT_SCHEMA.extend( - { - cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(LampSmartProLight), - cv.Optional(CONF_DURATION, default=100): cv.positive_int, - cv.Optional(CONF_COLD_WHITE_COLOR_TEMPERATURE): cv.color_temperature, - cv.Optional(CONF_WARM_WHITE_COLOR_TEMPERATURE): cv.color_temperature, - cv.Optional(CONF_CONSTANT_BRIGHTNESS, default=False): cv.boolean, - cv.Optional(CONF_REVERSED, default=False): cv.boolean, - cv.Optional(CONF_MIN_BRIGHTNESS, default=0x7): cv.hex_uint8_t, - } - ), - cv.has_none_or_all_keys( - [CONF_COLD_WHITE_COLOR_TEMPERATURE, CONF_WARM_WHITE_COLOR_TEMPERATURE] - ), - light.validate_color_temperature_channels, -) - - -async def to_code(config): - var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) - await cg.register_component(var, config) - await light.register_light(var, config) - - if CONF_COLD_WHITE_COLOR_TEMPERATURE in config: - cg.add( - var.set_cold_white_temperature(config[CONF_COLD_WHITE_COLOR_TEMPERATURE]) - ) - - if CONF_WARM_WHITE_COLOR_TEMPERATURE in config: - cg.add( - var.set_warm_white_temperature(config[CONF_WARM_WHITE_COLOR_TEMPERATURE]) - ) - - cg.add(var.set_constant_brightness(config[CONF_CONSTANT_BRIGHTNESS])) - cg.add(var.set_reversed(config[CONF_REVERSED])) - cg.add(var.set_min_brightness(config[CONF_MIN_BRIGHTNESS])) - cg.add(var.set_tx_duration(config[CONF_DURATION])) - - -@automation.register_action( - "lampsmartpro.pair", PairAction, ACTION_ON_PAIR_SCHEMA -) -@automation.register_action( - "lampsmartpro.unpair", UnpairAction, ACTION_ON_UNPAIR_SCHEMA -) -async def lampsmartpro_pair_to_code(config, action_id, template_arg, args): - parent = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, parent) diff --git a/doc/images/BleAdvService.jpg b/doc/images/BleAdvService.jpg new file mode 100644 index 0000000..ff7c9c4 Binary files /dev/null and b/doc/images/BleAdvService.jpg differ diff --git a/doc/images/Choice_encoding.jpg b/doc/images/Choice_encoding.jpg new file mode 100644 index 0000000..5e483b0 Binary files /dev/null and b/doc/images/Choice_encoding.jpg differ diff --git a/example_lampsmart_pro_light.yaml b/example_lampsmart_pro_light.yaml deleted file mode 100644 index bc3694c..0000000 --- a/example_lampsmart_pro_light.yaml +++ /dev/null @@ -1,10 +0,0 @@ -external_components: - # shorthand - source: github://aronsky/esphome-components - -light: - - platform: ble_adv_light - type: zhijia - name: Kitchen Light - duration: 750 - default_transition_length: 0s