diff --git a/.gitignore b/.gitignore index 52ac65f6..2f3344de 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,19 @@ .pioenvs .piolibdeps +.pio .clang_complete .gcc-flags.json +.sconsign.dblite /web/node_modules /web/build +/web/package-lock.json /dist/*.bin +/dist/docs .vscode/ .vscode/.browse.c_cpp.db* .vscode/c_cpp_properties.json .vscode/launch.json +/test/remote/settings.json +/test/remote/espmh.env + +web/package-lock\.json diff --git a/.prepare_docs b/.prepare_docs new file mode 100755 index 00000000..be9d67c8 --- /dev/null +++ b/.prepare_docs @@ -0,0 +1,72 @@ +#!/usr/bin/env bash + +# This script sets up API documentation bundles for deployment to Github Pages. +# It expects the following structure: +# +# In development branches: +# +# * ./docs/openapi.yaml - OpenAPI spec in +# * ./docs/gh-pages - Any assets that should be copied to gh-pages root +# +# In Github Pages, it will generate: +# +# * ./ - Files from ./docs/gh-pages will be copied +# * ./branches//... - Deployment bundles including an index.html +# and a snapshot of the Open API spec. + +set -eo pipefail + +prepare_docs_log() { + echo "[prepare docs release] -- $@" +} + +# Only run for tagged commits +if [ -z "$(git tag -l --points-at HEAD)" ]; then + prepare_docs_log "Skipping non-tagged commit." + exit 0 +fi + +DOCS_DIR="./docs" +DIST_DIR="./dist/docs" +BRANCHES_DIR="${DIST_DIR}/branches" +API_SPEC_FILE="${DOCS_DIR}/openapi.yaml" + +rm -rf "${DIST_DIR}" + +redoc_bundle_file=$(mktemp) +git_ref_version=$(git describe --always) +branch_docs_dir="${BRANCHES_DIR}/${git_ref_version}" + +# Build Redoc bundle (a single HTML file) +redoc-cli bundle ${API_SPEC_FILE} -o ${redoc_bundle_file} --title 'Milight Hub API Documentation' + +# Check out current stuff from gh-pages (we'll append to it) +git fetch origin 'refs/heads/gh-pages:refs/heads/gh-pages' +git checkout gh-pages -- branches || prepare_docs_log "Failed to checkout branches from gh-pages, skipping..." + +if [ -e "./branches" ]; then + mkdir -p "${DIST_DIR}" + mv "./branches" "${BRANCHES_DIR}" +else + mkdir -p "${BRANCHES_DIR}" +fi + +if [ -e "${DOCS_DIR}/gh-pages" ]; then + cp -r ${DOCS_DIR}/gh-pages/* "${DIST_DIR}" +else + prepare_docs_log "Skipping copy of gh-pages dir, doesn't exist" +fi + +# Create the docs bundle for our ref. This will be the redoc bundle + a +# snapshot of the OpenAPI spec +mkdir -p "${branch_docs_dir}" +cp "${API_SPEC_FILE}" "${branch_docs_dir}" +cp "${redoc_bundle_file}" "${branch_docs_dir}/index.html" + +# Update `latest` symlink to this branch +rm -rf "${BRANCHES_DIR}/latest" +ln -s "${git_ref_version}" "${BRANCHES_DIR}/latest" + +# Create a JSON file containing a list of all branches with docs (we'll +# have an index page that renders the list). +ls "${BRANCHES_DIR}" | jq -Rc '.' | jq -sc '.' > "${DIST_DIR}/branches.json" \ No newline at end of file diff --git a/.prepare_release b/.prepare_release index 855f8de9..83122e31 100755 --- a/.prepare_release +++ b/.prepare_release @@ -6,7 +6,7 @@ prepare_log() { echo "[prepare release] -- $@" } -if ! git describe --exact-match HEAD 2>/dev/null; then +if [ -z "$(git tag -l --points-at HEAD)" ]; then prepare_log "Skipping non-tagged commit." exit 0 fi @@ -17,7 +17,13 @@ prepare_log "Preparing release for tagged version: $VERSION" mkdir -p dist -for file in $(ls .pioenvs/**/firmware.bin); do +if [ -d .pio/build ]; then + firmware_prefix=".pio/build" +else + firmware_prefix=".pioenvs" +fi + +for file in $(ls ${firmware_prefix}/**/firmware.bin); do env_dir=$(dirname "$file") env=$(basename "$env_dir") diff --git a/.travis.yml b/.travis.yml index 0a94585b..667941ef 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,25 +6,36 @@ cache: directories: - "~/.platformio" env: - - NODE_VERSION="6" + - NODE_VERSION="10" before_install: - nvm install $NODE_VERSION install: -- pip install -U https://github.com/platformio/platformio-core/archive/develop.zip +- pip install -U platformio - platformio lib install - cd web && npm install && cd .. +- npm install -g swagger-cli redoc-cli script: +- swagger-cli validate ./docs/openapi.yaml - platformio run before_deploy: - ./.prepare_release + - ./.prepare_docs deploy: - provider: releases - prerelease: true - api_key: - secure: p1BjM1a/u20EES+pl0+w7B/9600pvpcVYTfMiZhyMOXB0MbNm+uZKYeqiG6Tf3A9duVqMtn0R+ROO+YqL5mlnrVSi74kHMxCIF2GGtK7DIReyEI5JeF5oSi5j9bEsXu8602+1Uez8tInWgzdu2uK2G0FJF/og1Ygnk/L3haYIldIo6kL+Yd6Anlu8L2zqiovC3j3r3eO8oB6Ig6sirN+tnK0ah3dn028k+nHQIMtcc/hE7dQjglp4cGOu+NumUolhdwLdFyW7vfAafxwf9z/SL6M14pg0N8qOmT4KEg4AZQDaKn0wT7VhAvPDHjt4CgPE7QsZhEKFmW7J9LGlcWN4X3ORMkBNPnmqrkVeZEE4Vlcm3CF5kvt59ks0qwEgjpvrqxdZZxa/h9ZLEBBEXMIekA4TSAzP/e/opfry11N1lvqXQ562Jc6oEKS+xWerWSALXyZI4K1T+fkgHTZCWGH4EI3weZY/zSCAZ6a7OpgFQWU9uHlJLMkaWrp78fSPqy6zcjxhXoJnBt8BT1BMRdmZum2YX91hfJ9aRvlEmhtxKgAcPgpJ0ITwB317lKh5VqAfMNZW7pXJEYdLCmUEKXv/beTvNmRIGgu1OjZ3BWchOgh/TwX46+Lrx1zL69sfE+6cBFbC+T2QIv4dxxSQNC1K0JnRVhbD1cOpSXz+amsLS0= - file_glob: true - skip_cleanup: true - file: dist/*.bin - on: - repo: sidoh/esp8266_milight_hub - tags: true + - provider: releases + prerelease: true + api_key: + secure: p1BjM1a/u20EES+pl0+w7B/9600pvpcVYTfMiZhyMOXB0MbNm+uZKYeqiG6Tf3A9duVqMtn0R+ROO+YqL5mlnrVSi74kHMxCIF2GGtK7DIReyEI5JeF5oSi5j9bEsXu8602+1Uez8tInWgzdu2uK2G0FJF/og1Ygnk/L3haYIldIo6kL+Yd6Anlu8L2zqiovC3j3r3eO8oB6Ig6sirN+tnK0ah3dn028k+nHQIMtcc/hE7dQjglp4cGOu+NumUolhdwLdFyW7vfAafxwf9z/SL6M14pg0N8qOmT4KEg4AZQDaKn0wT7VhAvPDHjt4CgPE7QsZhEKFmW7J9LGlcWN4X3ORMkBNPnmqrkVeZEE4Vlcm3CF5kvt59ks0qwEgjpvrqxdZZxa/h9ZLEBBEXMIekA4TSAzP/e/opfry11N1lvqXQ562Jc6oEKS+xWerWSALXyZI4K1T+fkgHTZCWGH4EI3weZY/zSCAZ6a7OpgFQWU9uHlJLMkaWrp78fSPqy6zcjxhXoJnBt8BT1BMRdmZum2YX91hfJ9aRvlEmhtxKgAcPgpJ0ITwB317lKh5VqAfMNZW7pXJEYdLCmUEKXv/beTvNmRIGgu1OjZ3BWchOgh/TwX46+Lrx1zL69sfE+6cBFbC+T2QIv4dxxSQNC1K0JnRVhbD1cOpSXz+amsLS0= + file_glob: true + skip_cleanup: true + file: dist/*.bin + on: + repo: sidoh/esp8266_milight_hub + tags: true + - provider: pages + skip_cleanup: true + local_dir: dist/docs + github_token: $GITHUB_TOKEN + keep_history: true + on: + repo: sidoh/esp8266_milight_hub + tags: true \ No newline at end of file diff --git a/README.md b/README.md index 3df8262f..6f349f76 100644 --- a/README.md +++ b/README.md @@ -26,13 +26,14 @@ Support has been added for the following [bulb types](http://futlight.com/produc Model #|Name|Compatible Bulbs -------|-----------|---------------- |FUT096|RGB/W|
  1. FUT014
  2. FUT016
  3. FUT103
  4. | -|FUT005, FUT006,FUT007
|CCT|
  1. FUT011
  2. FUT017
  3. FUT019
| +|FUT005
FUT006
FUT007|CCT|
  1. FUT011
  2. FUT017
  3. FUT019
| |FUT098|RGB|Most RGB LED Strip Controlers| +|FUT020|RGB|Some other RGB LED strip controllers| |FUT092|RGB/CCT|
  1. FUT012
  2. FUT013
  3. FUT014
  4. FUT015
  5. FUT103
  6. FUT104
  7. FUT105
  8. Many RGB/CCT LED Strip Controllers
| |FUT091|CCT v2|Most newer dual white bulbs and controllers| |FUT089|8-zone RGB/CCT|Most newer rgb + dual white bulbs and controllers| -Other remotes or bulbs, but have not been tested. +Other remotes or bulbs, but have not been tested. ## What you'll need @@ -50,11 +51,28 @@ Both modules are SPI devices and should be connected to the standard SPI pins on ##### NRF24L01+ -[This guide](https://www.mysensors.org/build/connect_radio#nrf24l01+-&-esp8266) details how to connect an NRF24 to an ESP8266. I used GPIO 16 for CE and GPIO 15 for CSN instead. These can be configured later. + +[This guide](https://www.mysensors.org/build/connect_radio#nrf24l01+-&-esp8266) details how to connect an NRF24 to an ESP8266. By default GPIO 4 for CE and GPIO 15 for CSN are used, but these can be configured late in the Web GUI under Settings -> Setup. + + + + +NodeMCU | Radio | Color +-- | -- | -- +GND | GND | Black +3V3 | VCC | Red +D2 (GPIO4) | CE | Orange +D8 (GPIO15) | CSN/CS | Yellow +D5 (GPIO14) | SCK | Green +D7 (GPIO13) | MOSI | Blue +D6 (GPIO12) | MISO | Violet + +_Image source: [MySensors.org](https://mysensors.org)_ + ##### LT8900 -Connect SPI pins (CS, SCK, MOSI, MISO) to appropriate SPI pins on the ESP8266. With default settings, connect RST to GPIO 0, and PKT to GPIO 16. +Connect SPI pins (CE, SCK, MOSI, MISO) to appropriate SPI pins on the ESP8266. With default settings, connect RST to GPIO 0, PKT to GPIO 16, CE to GPIO 4, and CSN to GPIO 15. Make sure to properly configure these if using non-default pinouts. #### Setting up the ESP @@ -89,94 +107,32 @@ Both mDNS and SSDP are supported. The HTTP endpoints (shown below) will be fully functional at this point. You should also be able to navigate to `http://`, or `http://milight-hub.local` if your client supports mDNS. The UI should look like this: -![Web UI](https://user-images.githubusercontent.com/589893/39412360-0d95ab2e-4bd0-11e8-915c-7fef7ee38761.png) +![Web UI](https://user-images.githubusercontent.com/589893/61682228-a8151700-acc5-11e9-8b86-1e21efa6cdbe.png) -## LED Status -Some ESP boards have a built-in LED, on pin #2. This LED will flash to indicate the current status of the hub: +If it does not work as expected see [Troubleshooting](https://github.com/sidoh/esp8266_milight_hub/wiki/Troubleshooting). -* Wifi not configured: Fast flash (on/off once per second). See [Configure Wifi](#configure-wifi) to configure the hub. -* Wifi connected and ready: Occasional blips of light (a flicker of light every 1.5 seconds). -* Packets sending/receiving: Rapid blips of light for brief periods (three rapid flashes). -* Wifi failed to configure: Solid light. +#### Pair Bulbs -In the setup UI, you can turn on "enable_solid_led" to change the LED behavior to: +If you need to pair some bulbs, how to do this is [described in the wiki](https://github.com/sidoh/esp8266_milight_hub/wiki/Pairing-new-bulbs). -* Wifi connected and ready: Solid LED light -* Wifi failed to configure: Light off +## Device Aliases -Note that you must restart the hub to affect the change in "enable_solid_led". +You can configure aliases or labels for a given _(Device Type, Device ID, Group ID)_ tuple. For example, you might want to call the RGB+CCT remote with the ID `0x1111` and the Group ID `1` to be called `living_room`. Aliases are useful in a couple of different ways: -You can configure the LED pin from the web console. Note that pin means the GPIO number, not the D number ... for example, D2 is actually GPIO4 and therefore its pin 4. If you specify the pin as a negative number, it will invert the LED signal (the built-in LED on pin 2 is inverted, so the default is -2). +* **In the UI**: the aliases dropdown shows all previously set aliases. When one is selected, the corresponding Device ID, Device Type, and Group ID are selected. This allows you to not need to memorize the ID parameters for each lighting device if you're controlling them through the UI. +* **In the REST API**: standard CRUD verbs (`GET`, `PUT`, and `DELETE`) allow you to interact with aliases via the `/gateways/:device_alias` route. +* **MQTT**: you can configure topics to listen for commands and publish updates/state using aliases rather than IDs. -If you want to wire up your own LED on a pin, such as on D2/GPIO4, put a wire from D2 to one side of a 220 ohm resister. On the other side, connect it to the positive side (the longer wire) of a 3.3V LED. Then connect the negative side of the LED (the shorter wire) to ground. If you use a different voltage LED, or a high current LED, you will need to add a driver circuit. +## REST API -## REST endpoints - -1. `GET /`. Opens web UI. -1. `GET /about`. Return information about current firmware version. -1. `POST /system`. Post commands in the form `{"comamnd": }`. Currently supports the commands: `restart`. -1. `POST /firmware`. OTA firmware update. -1. `GET /settings`. Gets current settings as JSON. -1. `PUT /settings`. Patches settings (e.g., doesn't overwrite keys that aren't present). Accepts a JSON blob in the body. -1. `GET /radio_configs`. Get a list of supported radio configs (aka `device_type`s). -1. `GET /gateway_traffic(/:device_type)?`. Starts an HTTP long poll. Returns any Milight traffic it hears. Useful if you need to know what your Milight gateway/remote ID is. Since protocols for RGBW/CCT are different, specify one of `rgbw`, `cct`, or `rgb_cct` as `:device_type. The path `/gateway_traffic` without a `:device_type` will sniff for all protocols simultaneously. -1. `PUT /gateways/:device_id/:device_type/:group_id`. Controls or sends commands to `:group_id` from `:device_id`. Accepts a JSON blob. The schema is documented below in the _Bulb commands_ section. -1. `GET /gateways/:device_id/:device_type/:group_id`. Returns a JSON blob describing the state of the the provided group. -1. `POST /raw_commands/:device_type`. Sends a raw RF packet with radio configs associated with `:device_type`. Example body: - ``` - {"packet": "01 02 03 04 05 06 07 08 09", "num_repeats": 10} - ``` - -#### Bulb commands - -Route (5) supports these commands. Note that each bulb type has support for a different subset of these commands: - -1. `status`. Toggles on/off. Can be "on", "off", "true", or "false". -1. `hue`. Sets color. Should be in the range `[0, 359]`. -1. `saturation`. Controls saturation. -1. `level`. Controls brightness. Should be in the range `[0, 100]`. -1. `temperature`. Controls white temperature. Should be in the range `[0, 100]`. -1. `mode`. Sets "disco mode" setting to the specified value. Note that not all bulbs that have modes support this command. Some will only allow you to cycle through next/previous modes using commands. -1. `command`. Sends a command to the group. Can be one of: - * `set_white`. Turns off RGB and enters WW/CW mode. - * `pair`. Emulates the pairing process. Send this command right as you connect an unpaired bulb and it will pair with the device ID being used. - * `unpair`. Emulates the unpairing process. Send as you connect a paired bulb to have it disassociate with the device ID being used. - * `next_mode`. Cycles to the next "disco mode". - * `previous_mode`. Cycles to the previous disco mode. - * `mode_speed_up`. - * `mode_speed_down`. - * `level_down`. Turns down the brightness. Not all dimmable bulbs support this command. - * `level_up`. Turns down the brightness. Not all dimmable bulbs support this command. - * `temperature_down`. Turns down the white temperature. Not all bulbs with adjustable white temperature support this command. - * `temperature_up`. Turns up the white temperature. Not all bulbs with adjustable white temperature support this command. - * `night_mode`. Enable "night mode", which is minimum brightness and bulbs only responding to on/off commands. -1. `commands`. An array containing any number of the above commands (including repeats). - -The following redundant commands are supported for the sake of compatibility with HomeAssistant's [`mqtt_json`](https://home-assistant.io/components/light.mqtt_json/) light platform: - -1. `color`. Hash containing RGB color. All keys for r, g, and b should be present. For example, `{"r":255,"g":200,"b":255}`. -1. `color_temp`. Controls white temperature. Value is in [mireds](https://en.wikipedia.org/wiki/Mired). Milight bulbs are in the range 153-370 mireds (2700K-6500K). -1. `brightness`. Same as `level` with a range of `[0,255]`. -1. `state`. Same as `status`. - -If you'd like to control bulbs in all groups paired with a particular device ID, set `:group_id` to 0. - -#### Examples - -Turn on group 2 for device ID 0xCD86, set hue to 100, and brightness to 50%: +The REST API is specified using -``` -$ curl -X PUT -H 'Content-Type: applicaiton/json' -d '{"status":"on","hue":100,"level":50}' http://esp8266/gateways/0xCD86/rgbw/2 -true% -``` +[openapi.yaml](openapi.yaml) contains the raw spec, created using [OpenAPI v3](https://swagger.io/docs/specification/about/). -Set color to white (disable RGB): +[You can view generated documentation for the master branch here.](https://sidoh.github.io/esp8266_milight_hub/branches/latest) -``` -$ curl -X PUT -H 'Content-Type: applicaiton/json' -d '{"command":"set_white"}' -X PUT http://esp8266/gateways/0xCD86/rgbw/2 -true% -``` +[Docs for other branches can be found here](https://sidoh.github.io/esp8266_milight_hub) ## MQTT @@ -194,6 +150,7 @@ To configure your ESP to integrate with MQTT, fill out the following settings: 1. `:device_id` - Device ID. Can be hexadecimal (e.g. `0x1234`) or decimal (e.g. `4660`). 1. `:device_type` - Remote type. `rgbw`, `fut089`, etc. 1. `:group_id` - Group. 0-4 for most remotes. The "All" group is group 0. +1. `:device_alias` - Alias for the given device. Note that if an alias is not configured, a default token `__unnamed_group` will be substituted instead. Messages should be JSON objects using exactly the same schema that the REST gateway uses for the `/gateways/:device_id/:device_type/:group_id` endpoint. Documented above in the _Bulb commands_ section. @@ -249,14 +206,17 @@ irb(main):007:0> puts client.get.inspect ##### Customize fields -You can select which fields should be included in state updates by configuring the `group_state_fields` parameter. Available fields should be mostly self explanatory, with the possible exceptions of: +You can select which fields should be included in state updates by configuring the `group_state_fields` parameter. Available fields should be mostly self explanatory, but are all documented in the REST API spec under `GroupStateField`. + +#### Client Status -1. `state` / `status` - same value with different keys (useful if your platform expects one or the other). -1. `brightness` / `level` - [0, 255] and [0, 100] scales of the same value. -1. `kelvin / color_temp` - [0, 100] and [153, 370] scales for the same value. The later's unit is mireds. -1. `bulb_mode` - what mode the bulb is in: white, rgb, etc. -1. `color` / `computed_color` - behaves the same when bulb is in rgb mode. `computed_color` will send RGB = 255,255,255 when in white mode. This is useful for HomeAssistant where it always expects the color to be set. -1. `device_id` / `device_type` / `group_id` - this information is in the MQTT topic or REST route, but can be included in the payload in the case that processing the topic or route is more difficult. +To receive updates when the MQTT client connects or disconnects from the broker, confugre the `mqtt_client_status_topic` parameter. A message of the following form will be published: + +```json +{"status":"disconnected_unclean","firmware":"milight-hub","version":"1.9.0-rc3","ip_address":"192.168.1.111","reset_reason":"External System"} +``` + +If you wish to have the simple messages `connected` and `disconnected` instead of the above environmental data, configure `simple_mqtt_client_status` to `true` (or set Client Status Message Mode to "Simple" in the Web UI). ## UDP Gateways @@ -264,6 +224,79 @@ You can add an arbitrary number of UDP gateways through the REST API or through You can select between versions 5 and 6 of the UDP protocol (documented [here](http://www.limitlessled.com/dev/)). Version 6 has support for the newer RGB+CCT bulbs and also includes response packets, which can theoretically improve reliability. Version 5 has much smaller packets and is probably lower latency. +## Transitions + +Transitions between two given states are supported. Depending on how transition commands are being issued, the duration and smoothness of the transition are both configurable. There are a few ways to use transitions: + +#### RESTful `/transitions` routes + +These routes are fully documented in the [REST API documentation](https://sidoh.github.io/esp8266_milight_hub/branches/latest/#tag/Transitions). + +#### `transition` field when issuing commands + +When you issue a command to a bulb either via REST or MQTT, you can include a `transition` field. The value of this field specifies the duration of the transition, in seconds (non-integer values are supported). + +For example, the command: + +```json +{"brightness":255,"transition":60} +``` + +will transition from whatever the current brightness is to `brightness=255` over 60 seconds. + +#### Notes on transitions + +* espMH's transitions should work seamlessly with [HomeAssistant's transition functionality](https://www.home-assistant.io/components/light/). +* You can issue commands specifying transitions between many fields at once. For example: + ```json + {"brightness":255,"kelvin":0,"transition":10.5} + ``` + will transition from current values for brightness and kelvin to the specified values -- 255 and 0 respectively -- over 10.5 seconds. +* Color transitions are supported. Under the hood, this is treated as a transition between current values for r, g, and b to the r, g, b values for the specified color. Because milight uses hue-sat colors, this might not behave exactly as you'd expect for all colors. +* You can transition to a given `status` or `state`. For example, + ```json + {"status":"ON","transition":10} + ``` + will turn the bulb on, immediately set the brightness to 0, and then transition to brightness=255 over 10 seconds. If you specify a brightness value, the transition will stop there instead of 255. + +## LED Status + +Some ESP boards have a built-in LED, on pin #2. This LED will flash to indicate the current status of the hub: + +* Wifi not configured: Fast flash (on/off once per second). See [Configure Wifi](#configure-wifi) to configure the hub. +* Wifi connected and ready: Occasional blips of light (a flicker of light every 1.5 seconds). +* Packets sending/receiving: Rapid blips of light for brief periods (three rapid flashes). +* Wifi failed to configure: Solid light. + +In the setup UI, you can turn on "enable_solid_led" to change the LED behavior to: + +* Wifi connected and ready: Solid LED light +* Wifi failed to configure: Light off + +Note that you must restart the hub to affect the change in "enable_solid_led". + +You can configure the LED pin from the web console. Note that pin means the GPIO number, not the D number ... for example, D2 is actually GPIO4 and therefore its pin 4. If you specify the pin as a negative number, it will invert the LED signal (the built-in LED on pin 2 is inverted, so the default is -2). + +If you want to wire up your own LED on a pin, such as on D2/GPIO4, put a wire from D2 to one side of a 220 ohm resister. On the other side, connect it to the positive side (the longer wire) of a 3.3V LED. Then connect the negative side of the LED (the shorter wire) to ground. If you use a different voltage LED, or a high current LED, you will need to add a driver circuit. + +## Development + +This project is developed and built using [PlatformIO](https://platformio.org/). + +#### Running tests + +On-board unit tests are available using PlatformIO. Run unit tests with this command: + +``` +pio test -e d1_mini +``` + +substituting `d1_mini` for the environment of your choice. + +#### Running integration tests + +A remote integration test suite built using rspec is available under [`./test/remote`](test/remote). + ## Acknowledgements * @WoodsterDK added support for LT8900 radios. diff --git a/dist/index.html.gz.h b/dist/index.html.gz.h index ce5a39a5..ca8332f0 100644 --- a/dist/index.html.gz.h +++ b/dist/index.html.gz.h @@ -1,2 +1,2 @@ -#define index_html_gz_len 9681 -static const char index_html_gz[] PROGMEM = {31,139,8,0,0,0,0,0,0,10,237,125,107,119,219,184,146,224,247,61,103,255,3,204,244,141,197,54,73,201,118,236,36,146,169,76,30,78,39,59,121,120,99,167,123,231,120,60,62,144,4,89,76,40,146,67,82,126,140,91,255,125,171,10,0,9,62,100,43,233,116,207,157,123,110,63,108,146,0,171,10,133,66,189,80,132,15,54,38,241,56,191,73,4,155,229,243,112,120,128,63,89,200,163,11,223,18,145,5,247,130,79,134,7,115,145,115,54,158,241,52,19,185,111,45,242,169,251,4,218,242,32,15,197,240,245,11,239,125,240,46,184,152,229,238,155,197,232,160,43,159,30,132,65,244,149,165,34,244,173,44,191,9,69,54,19,34,183,216,44,21,83,223,154,229,121,146,245,187,221,57,191,30,79,34,111,20,199,121,150,167,60,193,155,113,60,239,22,15,186,187,222,174,247,184,59,206,178,242,153,55,15,160,87,150,89,10,71,21,228,69,144,35,20,248,53,91,140,188,32,46,223,115,243,248,226,34,20,221,29,15,254,173,130,84,77,5,228,6,221,173,168,0,207,151,204,27,135,241,98,50,13,121,42,136,116,254,133,95,119,195,96,100,66,207,194,96,34,210,238,83,239,177,215,171,33,150,77,63,22,113,38,66,49,206,131,255,18,222,151,172,219,243,182,119,188,71,132,181,124,94,224,223,253,211,134,76,184,186,219,37,246,122,219,93,152,73,220,34,62,23,190,117,25,136,171,36,78,65,116,198,113,148,139,8,196,239,42,152,228,51,127,34,46,131,177,112,233,198,9,162,32,15,120,232,102,99,30,10,127,27,64,108,184,238,105,48,101,97,206,222,30,178,167,103,195,255,253,191,24,252,115,144,141,211,32,201,89,150,142,215,30,20,174,136,189,108,22,92,130,48,62,246,118,203,123,96,47,32,234,74,144,132,224,96,227,84,68,147,96,122,230,186,195,3,26,209,208,195,5,36,82,55,141,175,110,71,113,138,151,163,56,207,227,121,127,59,185,102,89,12,243,207,30,140,199,227,101,200,71,34,188,157,4,89,18,242,155,254,40,140,199,95,151,94,202,39,65,236,198,73,30,196,209,109,194,39,147,32,186,232,247,216,94,114,61,24,47,210,44,78,251,73,28,0,91,210,37,18,61,231,209,196,29,45,0,122,148,221,134,65,150,187,68,67,63,138,35,49,152,243,244,34,136,250,189,65,1,166,241,14,11,131,130,128,32,130,233,23,46,209,161,222,117,83,92,228,253,109,49,31,196,151,34,157,134,241,85,159,47,242,120,233,77,227,116,238,194,220,164,55,183,26,13,235,177,157,30,140,176,103,182,50,121,141,51,153,198,225,45,205,93,127,167,39,230,149,78,85,78,152,132,72,38,193,120,242,142,39,210,52,78,109,38,127,187,65,52,141,139,87,112,188,75,179,97,28,135,192,170,84,76,6,83,64,237,102,176,6,250,222,99,68,91,118,234,143,4,208,32,110,149,152,245,55,59,155,149,102,62,5,54,151,173,54,180,170,169,29,229,145,30,54,14,121,233,93,240,92,92,241,27,23,24,77,141,192,41,158,247,137,123,203,7,10,2,243,38,105,156,76,226,43,152,215,56,11,112,130,251,74,138,107,220,109,190,225,206,69,180,48,39,75,50,231,1,172,174,233,52,24,187,89,20,76,167,85,110,60,160,103,98,226,170,62,64,240,181,59,19,52,161,123,61,99,70,221,27,53,167,26,217,93,211,225,69,252,114,196,211,219,17,31,127,189,72,227,5,136,146,98,245,197,136,119,30,109,59,248,223,158,227,61,181,55,130,57,174,98,30,229,3,181,10,80,180,23,25,74,161,4,226,142,82,16,69,167,114,215,159,33,89,106,250,30,76,167,211,165,7,252,4,5,2,44,153,112,144,182,6,98,236,164,48,212,23,152,39,73,95,49,146,171,89,144,11,55,23,243,196,77,130,241,87,192,170,184,179,3,204,41,209,244,241,29,32,239,2,201,135,73,233,228,49,163,137,117,30,240,253,201,246,116,202,122,14,210,192,246,122,127,195,11,222,235,245,216,118,175,247,55,123,208,186,178,244,98,220,149,75,101,182,16,45,232,229,58,65,40,131,154,126,40,251,131,144,70,109,111,129,66,28,119,240,85,230,178,93,49,95,65,70,85,153,172,53,94,88,77,114,172,48,190,125,111,127,127,255,49,12,184,7,119,187,96,179,225,31,186,83,124,232,245,166,108,191,232,52,133,187,39,186,19,130,33,254,200,193,92,242,16,126,42,26,111,155,19,9,236,212,218,40,20,211,188,239,237,21,35,197,49,27,195,111,159,230,36,92,100,46,88,30,248,137,3,76,110,43,107,205,228,244,56,132,113,247,65,87,207,234,76,175,195,96,82,125,246,167,65,10,42,55,158,186,232,78,85,149,189,84,157,133,204,235,53,144,199,73,189,69,234,10,28,219,186,104,67,190,18,43,130,105,69,90,109,184,15,145,71,46,157,134,173,120,132,2,171,65,170,181,7,171,76,63,145,134,135,38,173,144,113,176,90,76,255,255,24,44,88,97,145,212,172,237,62,130,135,52,89,234,193,182,247,232,233,29,12,153,6,34,156,64,223,48,105,183,90,165,174,223,246,118,80,78,234,130,3,154,58,188,73,102,1,104,19,47,75,96,1,1,149,183,60,10,230,156,212,49,62,98,219,25,3,245,143,154,89,48,185,22,6,238,149,24,125,13,114,183,218,115,167,165,235,18,125,133,82,191,131,163,3,253,47,193,22,3,21,122,140,32,107,203,81,60,209,150,83,123,7,59,61,52,37,83,112,154,140,85,77,207,254,101,46,38,1,103,113,20,222,48,240,61,132,136,24,168,74,214,65,160,52,55,172,191,139,118,200,190,109,3,187,135,32,214,129,241,232,201,74,24,189,117,97,60,222,127,178,2,198,246,222,147,53,97,60,125,186,179,10,198,246,62,194,240,230,241,4,92,63,236,192,188,12,61,220,56,114,43,18,219,116,184,132,16,90,44,117,171,7,74,67,153,73,86,202,133,75,122,72,201,247,254,116,191,165,71,6,11,170,48,81,251,251,203,69,232,113,73,196,42,87,204,240,192,170,61,87,57,96,45,253,200,9,82,42,103,60,11,194,137,125,219,16,239,127,249,42,110,166,41,184,209,25,67,9,189,157,166,241,252,22,28,128,40,67,111,171,79,206,114,103,219,102,105,156,131,199,210,233,217,203,60,190,163,125,119,191,55,17,23,54,204,154,94,1,85,248,59,18,129,110,44,1,85,224,175,108,46,192,131,75,77,174,243,65,87,6,159,56,179,195,86,207,29,157,116,239,34,142,33,118,227,73,144,213,28,247,47,255,185,16,233,13,120,237,16,133,168,27,10,58,42,110,123,43,220,117,227,210,47,245,176,244,126,208,235,198,167,95,86,132,167,247,99,184,39,156,1,163,46,210,108,12,174,46,134,135,219,222,19,8,105,202,103,238,15,193,177,34,244,109,13,123,255,48,178,182,112,23,174,50,112,54,65,47,192,122,51,122,252,232,209,85,162,220,47,43,130,220,86,132,50,197,146,139,235,188,251,133,95,114,249,212,26,78,23,17,173,116,6,30,244,73,252,38,11,59,220,17,78,106,223,242,174,191,179,183,231,8,249,43,165,95,131,75,158,178,204,201,157,177,255,158,231,51,15,196,86,117,119,102,234,73,16,233,39,145,223,25,111,205,236,238,206,32,152,118,198,190,63,179,51,63,247,123,3,17,102,226,22,1,141,252,177,59,27,100,87,32,160,179,78,238,71,67,111,239,217,168,219,217,113,225,177,221,135,43,124,221,25,219,183,99,158,9,198,251,153,223,17,110,106,119,71,91,29,113,144,62,219,239,247,236,193,40,21,28,188,73,236,32,176,67,234,114,236,176,99,54,164,216,192,93,129,13,143,150,89,215,223,95,166,34,95,164,209,237,172,159,57,89,63,119,32,194,91,46,11,78,124,250,229,69,30,191,57,254,85,115,98,219,247,125,208,116,11,8,130,242,204,11,69,116,145,207,30,62,236,8,159,123,23,78,10,63,71,14,135,159,169,173,217,211,96,206,184,193,156,153,15,90,20,88,212,3,216,249,179,94,127,214,205,157,145,159,119,145,201,154,35,106,224,99,32,191,103,142,7,57,1,140,216,154,253,92,240,193,193,81,253,60,171,179,3,184,177,181,243,243,172,165,21,121,2,44,217,122,84,180,86,121,18,57,151,253,17,105,198,181,164,8,7,254,249,195,219,147,243,163,231,159,158,191,63,246,111,97,168,239,3,112,181,51,48,188,187,14,176,66,221,237,62,238,225,221,11,114,66,35,145,101,224,32,236,45,157,207,111,207,79,158,191,56,246,79,111,115,126,209,183,114,62,2,107,60,13,44,103,154,66,16,48,9,111,250,214,111,120,191,116,202,14,25,208,155,152,61,142,233,129,217,37,20,19,179,195,187,195,87,149,102,74,118,152,29,62,209,3,179,203,252,63,243,220,236,241,254,255,158,156,88,203,51,36,248,245,219,195,119,175,10,146,249,4,134,124,190,200,68,138,105,36,243,149,231,216,194,202,22,244,31,251,214,103,117,207,192,34,177,16,212,45,216,103,240,230,32,218,201,103,65,198,192,102,37,252,2,122,163,147,221,183,96,137,67,59,220,241,145,193,29,77,168,196,157,240,44,187,2,255,195,196,125,84,60,147,88,245,253,143,192,58,22,231,96,131,77,108,47,15,89,151,29,253,235,9,163,231,10,35,140,29,214,212,225,241,209,147,157,253,125,100,131,68,190,249,242,112,147,117,240,234,195,167,215,59,143,222,245,182,25,133,130,83,62,22,54,195,14,0,72,245,216,60,122,7,174,215,227,238,187,147,39,79,123,189,77,163,227,74,74,179,138,44,140,179,168,65,235,241,135,245,200,60,254,176,185,38,150,84,192,125,29,207,167,195,227,195,245,24,66,61,215,197,5,146,93,199,4,210,93,199,3,243,10,240,9,60,182,130,133,202,23,25,83,14,31,235,244,124,184,228,35,128,101,15,88,36,46,40,86,0,238,66,132,154,161,139,21,92,68,60,100,157,84,96,218,14,176,48,119,135,96,161,95,24,115,144,34,0,186,238,20,36,16,225,3,119,82,145,8,158,103,85,25,197,22,86,180,72,242,79,102,130,69,139,249,72,0,186,41,203,3,244,249,48,21,64,189,216,167,215,76,2,4,42,49,83,5,45,163,69,56,202,86,18,147,86,86,54,218,93,69,202,57,200,81,30,167,38,65,111,78,78,142,52,34,221,42,137,122,191,8,243,32,9,131,177,228,148,108,196,217,172,142,142,152,148,10,112,0,179,28,67,52,76,178,229,48,205,163,27,88,102,130,17,252,231,71,111,61,246,249,21,93,48,32,26,96,134,16,147,0,175,5,128,134,73,90,72,60,66,15,212,129,80,130,205,193,99,210,156,98,217,44,94,132,0,85,148,66,132,160,215,212,25,139,60,6,122,65,36,82,144,89,145,6,113,69,113,60,135,102,87,53,51,221,44,153,128,77,24,134,106,130,101,31,28,152,204,144,51,1,242,115,99,76,30,70,246,57,185,236,98,12,36,136,9,12,28,164,178,71,20,107,9,92,83,140,160,251,24,51,40,55,231,148,163,55,40,126,165,91,152,108,81,106,22,56,140,247,40,33,24,21,65,188,167,176,234,206,74,140,32,34,103,175,196,148,195,12,147,160,225,120,50,212,207,244,54,49,24,166,79,237,59,169,145,194,156,60,122,242,228,233,83,61,30,120,77,13,103,189,57,64,227,114,14,118,0,8,169,219,24,166,31,203,97,188,2,142,163,222,72,217,219,35,6,193,28,112,61,67,214,82,215,81,26,127,5,223,150,125,164,172,61,205,138,100,245,13,227,146,124,112,40,102,172,35,174,249,60,9,65,187,206,111,240,61,245,218,24,3,213,39,79,30,173,36,153,76,96,133,100,136,63,131,49,216,155,28,212,112,212,160,156,90,89,209,170,13,15,221,18,243,203,94,153,49,45,56,1,135,146,194,62,60,3,46,103,221,190,228,243,121,48,41,46,145,200,110,159,210,68,240,216,99,199,66,176,79,135,207,95,189,63,36,216,211,69,10,51,7,19,44,114,30,132,171,245,65,115,80,139,100,2,171,237,158,177,201,78,119,15,17,134,148,44,70,48,170,25,51,222,201,60,118,164,68,45,159,129,98,225,180,144,105,177,195,210,133,48,150,197,138,112,37,89,152,150,72,234,111,144,178,163,222,100,175,213,130,187,10,194,16,23,34,200,46,67,87,67,34,4,197,128,214,29,95,249,6,54,160,141,184,143,11,212,231,91,230,217,228,137,126,217,99,191,205,48,251,194,100,202,111,60,3,207,18,149,4,54,59,180,252,166,11,24,214,215,8,183,4,228,75,104,8,224,185,124,129,6,13,186,79,193,5,46,106,47,166,70,216,250,18,208,226,188,201,25,132,6,86,119,222,232,1,201,111,140,46,20,94,85,22,238,218,104,219,252,54,130,148,172,114,222,10,164,223,131,147,44,225,121,225,66,209,130,106,248,192,165,139,197,100,187,164,0,130,78,246,203,155,255,98,4,3,140,209,68,132,160,118,48,151,38,167,79,242,63,152,178,155,120,65,51,135,23,155,32,183,81,140,58,20,197,145,71,133,195,183,161,9,110,39,105,133,237,150,234,162,205,145,120,39,21,73,205,145,120,27,141,33,236,33,220,146,58,121,143,238,4,200,18,159,199,11,88,82,202,197,64,189,9,119,18,5,190,129,98,172,22,33,170,26,50,35,166,158,47,187,22,214,131,1,142,93,111,61,3,32,23,219,52,92,100,51,57,252,75,30,86,34,26,18,123,106,103,101,187,242,69,192,175,152,47,230,21,59,27,2,57,180,85,133,139,63,191,194,236,38,189,76,122,64,173,59,120,194,179,89,251,104,96,62,193,47,68,205,19,204,41,81,154,11,152,91,48,255,25,12,179,6,97,77,131,109,40,149,20,127,132,193,60,104,68,84,10,52,118,96,170,195,55,13,210,212,179,216,9,125,65,5,179,51,49,140,250,94,175,183,218,95,173,172,146,138,67,119,158,207,210,56,207,67,129,23,2,220,174,112,178,210,129,101,186,47,51,250,202,193,188,148,187,208,25,155,193,194,72,42,94,47,41,119,253,38,88,181,194,86,144,194,39,243,29,162,201,39,33,213,163,6,249,69,59,192,35,41,216,180,147,197,58,160,138,76,30,217,82,81,142,57,6,2,53,164,232,53,27,104,25,238,190,2,238,247,49,17,83,133,75,80,96,169,232,209,45,18,232,105,58,76,59,189,222,124,93,255,123,5,119,97,176,184,113,113,25,228,55,107,240,183,210,251,155,57,252,6,60,12,144,40,26,92,86,97,79,147,47,96,109,112,77,208,230,244,148,195,130,79,171,98,213,115,216,156,95,147,160,74,102,109,247,122,240,172,167,215,85,182,90,232,238,226,203,92,10,255,106,86,20,29,90,70,63,229,169,30,1,174,254,49,204,230,68,72,205,71,138,175,92,81,18,152,40,189,141,202,216,118,215,165,93,186,100,74,163,225,70,89,69,55,255,66,54,91,46,73,221,42,169,62,166,188,101,198,174,102,193,120,166,218,140,208,6,212,117,184,152,0,117,129,90,230,18,134,94,236,56,47,16,56,159,160,251,147,196,17,170,117,212,217,165,2,208,212,183,145,215,190,244,69,132,115,118,206,117,152,115,142,118,238,92,38,229,136,1,229,168,14,169,43,43,186,146,73,100,70,87,57,196,215,64,209,167,95,94,252,246,155,12,82,89,71,154,66,120,180,245,242,229,9,122,245,175,63,159,244,158,60,181,29,38,145,103,6,200,2,90,177,238,17,9,218,49,70,155,77,56,73,124,58,5,30,22,62,20,58,66,98,14,106,27,194,125,144,122,228,81,134,151,180,119,8,140,70,111,27,157,205,43,80,18,44,0,253,66,204,70,27,13,238,197,87,205,177,123,249,176,202,64,139,137,236,138,166,238,28,148,208,52,184,168,231,41,136,79,147,5,10,20,195,110,76,119,147,12,43,186,16,173,70,104,73,246,155,61,31,143,81,25,30,97,241,128,234,199,65,19,160,133,143,21,36,28,55,101,12,213,96,52,81,6,213,24,116,182,211,60,133,208,161,145,57,52,8,34,138,101,39,133,49,2,246,223,75,252,140,103,205,183,116,172,73,48,35,152,97,99,6,214,37,58,166,169,174,201,102,149,12,163,203,90,44,206,22,196,99,116,193,33,198,95,208,78,245,183,146,37,245,73,43,77,69,202,36,91,135,158,12,107,220,96,118,41,171,130,33,19,222,20,239,127,15,81,32,150,139,168,66,218,107,244,105,216,88,122,131,13,234,62,212,51,81,64,31,82,76,86,145,220,33,73,183,86,161,104,108,104,53,26,108,107,232,79,162,239,204,249,229,211,199,207,71,231,199,39,207,79,14,207,255,245,240,223,142,253,83,75,105,46,75,38,236,224,98,84,228,203,225,38,20,151,2,220,64,107,182,160,62,197,202,134,27,218,21,134,223,146,19,214,87,17,94,6,197,243,115,84,10,8,12,148,144,226,21,52,204,147,5,40,255,115,253,166,32,85,2,23,69,232,13,215,58,220,46,31,147,139,126,230,0,15,206,223,127,124,117,136,68,127,156,78,161,253,56,4,227,35,183,15,225,238,53,88,203,242,142,218,32,90,75,116,139,190,14,169,162,8,174,62,70,0,244,243,171,163,243,163,79,31,79,62,190,252,248,238,252,215,195,79,199,111,63,126,0,4,123,206,254,153,243,234,240,245,243,207,239,78,206,203,62,69,23,127,207,41,246,221,28,218,52,7,118,251,27,219,184,253,100,89,27,126,24,143,137,77,222,44,206,114,12,222,108,218,133,186,18,163,227,24,103,205,143,196,21,251,77,223,117,172,43,220,153,179,182,26,175,109,89,253,39,219,150,61,40,94,244,226,8,100,34,227,23,194,215,123,72,29,0,14,104,53,21,18,83,238,11,15,172,22,31,252,212,177,234,229,113,150,237,37,104,136,163,73,199,58,128,171,161,181,149,111,89,7,93,186,180,151,203,37,1,136,223,136,235,10,14,185,103,99,245,174,173,45,225,229,241,49,137,88,103,123,223,134,155,207,9,172,249,151,96,239,59,246,210,193,237,252,75,241,57,13,203,215,37,81,194,71,106,228,172,190,157,48,89,249,217,151,140,4,249,180,61,240,104,58,182,147,83,55,146,3,232,229,73,112,160,40,64,120,160,15,142,170,99,145,239,99,217,14,247,47,68,254,114,145,166,224,187,190,7,49,235,216,114,10,124,95,216,232,146,92,89,239,23,48,249,2,3,26,189,202,223,190,178,176,83,129,68,149,160,22,176,193,168,195,117,150,132,1,204,140,3,151,202,47,200,58,220,254,253,247,78,238,247,108,199,194,93,101,96,55,144,237,251,40,160,184,92,155,24,101,250,0,17,42,238,117,85,249,100,6,179,13,179,11,63,57,253,204,151,78,117,32,38,239,228,187,12,9,38,181,21,6,138,41,117,118,44,29,233,171,144,7,228,159,123,218,173,236,152,243,136,133,172,63,121,184,227,219,185,93,164,97,191,152,46,96,253,92,228,179,120,2,190,223,231,19,203,65,208,253,255,115,252,241,131,39,213,73,48,189,129,247,29,85,177,121,66,138,134,39,42,69,29,71,221,47,25,234,5,165,205,251,38,74,208,78,147,80,80,132,249,153,8,132,135,203,165,189,132,23,229,230,35,15,69,154,195,197,114,233,108,139,93,219,65,45,252,82,214,239,174,26,134,26,130,213,205,110,192,75,6,199,244,182,32,254,227,241,247,82,15,52,73,2,212,52,29,83,178,227,83,124,101,172,4,39,119,184,148,231,192,183,14,242,116,8,210,180,133,87,147,161,229,192,213,230,1,137,170,42,241,214,210,158,157,158,89,108,12,218,59,243,45,179,78,216,146,110,188,111,109,130,60,108,90,221,225,166,67,208,186,26,220,42,192,152,108,93,3,104,126,47,208,73,112,169,97,96,253,41,137,172,197,144,125,170,52,3,158,203,202,28,107,184,57,0,52,29,28,123,228,247,6,209,65,171,250,84,219,214,131,104,107,75,242,41,246,91,251,157,70,103,78,230,115,223,143,127,255,61,246,253,59,212,237,195,135,27,237,152,140,149,57,160,193,80,61,175,49,28,86,41,169,221,220,234,100,207,44,166,22,79,223,178,108,224,142,100,142,102,46,174,101,95,102,137,44,93,165,143,57,9,24,62,50,27,61,84,180,99,161,200,161,37,6,51,36,57,85,240,59,6,136,76,226,25,207,4,168,236,137,196,99,13,153,181,21,171,105,32,42,135,150,218,6,103,242,33,204,195,176,125,134,36,251,235,163,154,160,3,142,94,202,60,190,20,174,46,203,86,233,185,98,80,250,173,162,164,139,149,197,93,242,85,172,223,8,10,9,145,184,52,13,6,57,93,148,244,165,19,198,124,114,44,114,116,239,50,83,71,129,114,82,85,33,242,151,139,91,81,168,70,233,78,150,241,130,130,105,53,142,15,31,254,228,129,246,195,197,138,235,89,65,7,143,169,106,225,62,142,190,32,252,175,226,38,131,39,88,84,127,200,65,125,20,189,114,41,109,28,204,199,230,3,13,69,218,141,83,57,147,114,57,156,109,218,3,174,132,116,216,123,248,176,163,166,27,75,47,60,158,231,105,199,34,151,195,126,198,189,105,16,130,30,239,108,158,150,171,244,52,63,147,64,188,49,250,18,29,187,207,201,112,97,131,109,47,109,7,44,175,246,103,50,128,94,22,232,80,213,173,220,57,201,58,213,126,205,209,192,152,203,55,249,100,34,223,235,220,98,61,68,159,172,51,170,51,34,171,47,150,136,183,236,158,138,41,230,131,10,84,136,171,25,148,106,79,1,216,37,95,85,92,106,9,95,145,101,121,117,46,45,202,15,182,194,93,194,100,9,175,45,54,91,133,178,53,142,187,3,105,43,236,54,180,50,6,90,15,173,10,197,214,70,171,96,215,208,22,209,207,189,72,203,56,105,29,148,37,220,26,66,25,7,220,139,77,69,72,235,160,82,16,151,122,53,129,159,84,209,46,174,100,120,6,203,27,107,128,193,221,178,7,66,127,24,162,102,3,36,191,241,168,85,200,97,201,37,228,139,214,77,110,71,9,249,105,239,12,228,247,116,251,12,126,236,208,10,67,43,157,241,75,241,139,124,227,165,132,222,238,109,98,240,179,152,36,90,63,2,205,224,95,130,163,14,109,210,163,116,64,149,72,69,248,18,53,101,199,162,207,98,44,89,96,69,186,196,212,32,166,57,63,219,196,119,231,60,49,198,83,186,6,63,129,77,146,14,237,64,171,249,236,3,255,208,9,236,103,224,66,110,244,28,234,0,11,187,138,214,137,32,12,182,251,1,172,231,160,142,92,155,252,63,27,113,132,136,43,126,247,105,195,18,182,210,144,27,190,106,94,247,77,201,51,223,128,46,218,133,136,253,83,116,0,122,131,236,64,171,227,65,6,62,67,124,154,157,249,167,28,126,58,1,254,136,224,199,217,160,244,247,10,251,80,120,124,52,143,247,248,165,109,254,224,109,77,64,251,49,9,215,210,209,211,124,136,188,169,68,65,226,89,167,12,98,220,96,226,146,45,183,218,24,218,210,207,252,162,75,175,30,97,219,253,118,144,109,98,169,93,124,140,17,148,126,111,10,126,61,38,162,144,106,83,7,15,167,85,143,5,221,206,51,198,55,21,57,54,46,13,73,129,163,35,14,11,109,93,156,137,12,2,162,226,219,41,203,86,111,228,52,84,172,84,23,101,40,85,145,12,116,15,48,191,127,127,132,37,236,103,186,107,54,3,29,128,172,145,183,21,102,20,225,207,44,192,241,161,66,72,240,251,221,95,165,128,86,102,76,135,195,115,10,54,186,157,127,159,108,217,255,238,153,191,58,110,199,251,217,182,159,117,245,138,185,157,243,47,113,218,207,81,233,204,131,136,46,119,206,0,5,64,128,203,221,51,39,133,201,66,76,112,183,135,13,176,48,251,167,212,159,122,82,31,108,58,3,97,130,229,39,174,68,218,164,205,41,60,22,147,120,52,235,65,245,73,94,44,101,238,17,174,97,32,127,47,29,25,90,189,68,87,243,117,156,202,232,170,69,34,112,113,131,20,208,47,110,54,195,154,196,135,27,62,122,97,48,221,99,41,55,174,90,234,133,144,122,234,193,150,197,58,152,0,0,176,1,143,242,45,203,86,170,99,195,207,37,132,16,41,40,0,48,15,101,29,139,233,1,148,168,40,127,77,91,126,90,149,14,234,123,230,160,238,53,31,39,32,119,160,150,121,33,32,248,0,107,92,75,25,81,235,105,169,41,130,49,41,210,200,65,109,144,166,114,94,165,25,115,154,189,244,90,149,226,40,173,194,128,251,213,73,237,228,94,206,47,206,81,75,58,5,171,236,103,22,244,97,26,27,191,4,127,1,211,220,27,16,17,252,155,220,171,142,101,250,113,14,142,48,165,27,163,92,119,247,228,234,211,68,100,11,8,134,211,27,77,42,151,132,4,126,238,193,122,192,29,99,229,168,154,206,176,18,153,220,67,178,96,26,38,226,250,35,122,14,106,234,236,13,223,221,70,173,108,184,194,136,114,26,164,243,43,158,10,23,63,145,182,20,155,45,252,84,26,34,1,48,198,222,40,141,175,192,17,56,71,37,128,225,192,249,34,13,81,105,6,53,209,6,165,154,197,33,248,21,241,133,138,188,139,91,112,46,6,45,210,230,220,51,67,70,60,163,63,105,98,237,129,13,249,191,42,178,177,239,226,228,221,147,46,213,139,83,216,30,62,138,209,212,220,54,146,28,48,92,16,100,135,99,254,11,253,194,69,42,200,148,234,55,139,143,58,146,64,127,30,129,117,247,169,72,98,44,172,159,143,40,88,236,138,44,193,74,205,243,121,64,85,73,231,208,15,250,132,84,67,208,149,36,182,97,199,172,142,47,26,216,181,118,48,18,47,245,172,161,32,14,211,218,175,184,26,138,239,24,229,200,249,215,193,172,99,125,252,128,217,53,249,158,93,126,70,114,66,185,130,14,120,72,112,27,35,127,235,77,24,133,80,234,87,235,228,226,51,0,245,220,75,117,15,239,162,184,26,97,146,175,40,147,191,187,231,160,62,138,50,95,141,35,145,31,102,96,160,154,255,74,78,137,179,221,235,253,204,189,204,86,70,245,8,213,186,184,122,137,224,240,147,157,159,115,111,70,125,192,99,86,191,67,229,126,151,89,110,237,119,97,123,165,193,53,170,212,189,162,72,221,238,118,42,207,117,185,250,138,222,245,33,25,155,109,43,198,20,40,10,203,60,190,164,48,242,205,103,63,227,119,179,221,26,37,101,169,124,3,175,220,8,104,199,24,225,234,111,114,176,145,51,203,41,123,10,120,97,74,225,146,251,123,61,233,58,52,62,142,69,135,3,236,188,241,9,52,65,236,91,51,144,22,202,156,58,148,180,254,155,67,9,212,191,217,148,185,195,143,213,142,117,50,190,53,26,160,76,56,24,17,35,101,143,4,4,205,40,160,250,9,92,197,191,171,126,63,39,245,71,150,240,136,128,72,165,114,76,117,170,154,22,139,104,131,39,223,74,92,239,94,226,20,179,218,136,147,116,183,18,23,39,6,109,164,254,42,95,215,23,214,14,149,116,53,199,83,57,180,193,210,41,144,22,71,47,1,65,232,52,148,199,70,15,227,0,57,100,96,125,222,234,168,225,103,1,255,207,213,128,226,233,20,36,13,64,225,167,127,160,10,242,110,103,251,103,221,72,159,81,98,126,35,144,95,189,144,168,208,202,5,11,217,178,166,3,219,204,144,119,110,65,240,72,81,14,180,20,154,223,155,163,23,27,99,53,46,216,185,114,136,28,53,61,204,76,238,97,81,50,209,1,178,189,84,125,23,73,167,106,19,54,182,117,19,88,142,149,109,56,183,85,20,15,31,214,17,208,74,145,139,207,42,86,161,124,154,242,43,87,142,11,167,132,118,230,155,110,214,237,18,248,45,167,98,20,95,131,14,215,76,172,164,188,244,195,0,100,169,175,231,173,244,176,100,92,41,78,171,239,82,189,162,125,6,6,208,228,174,208,68,23,39,113,228,173,34,83,153,17,213,183,95,245,253,212,83,139,130,127,201,8,202,250,175,2,105,236,31,172,13,82,175,191,58,52,12,250,72,156,193,81,81,69,72,192,118,189,72,159,153,74,7,194,149,202,58,239,104,208,149,229,229,82,44,213,74,118,5,86,235,194,212,1,15,54,226,183,163,240,8,94,181,8,20,40,197,7,120,44,135,202,210,32,111,156,123,134,97,58,101,214,44,80,218,96,101,186,103,85,170,198,2,212,150,173,71,171,226,204,117,89,137,241,102,128,238,174,210,28,28,93,66,88,212,237,33,95,209,175,212,121,69,83,35,52,246,70,224,246,202,254,119,48,205,107,207,161,59,45,106,173,8,131,243,180,8,208,17,114,177,47,194,253,222,128,31,60,29,240,173,45,187,136,138,203,144,89,115,112,243,32,12,134,7,92,157,64,244,64,237,34,80,231,34,48,231,180,57,129,246,237,160,203,193,149,133,55,44,138,206,219,134,80,193,3,204,103,188,58,247,42,24,168,172,53,124,167,182,42,74,10,44,153,202,110,204,22,42,45,167,230,28,80,142,224,204,49,31,73,11,1,30,164,83,108,248,110,54,117,83,177,29,169,211,255,230,102,228,189,59,136,122,3,17,218,151,102,254,187,178,205,92,236,65,64,3,232,130,20,235,207,250,160,191,179,56,205,95,99,190,186,79,159,24,90,78,28,73,161,121,62,153,244,171,129,76,238,201,57,161,152,252,45,158,19,34,31,128,89,151,240,94,83,240,213,111,225,119,145,118,248,143,78,239,250,148,187,211,231,238,235,158,251,244,236,118,219,121,180,252,253,84,93,238,45,237,159,186,246,179,78,129,0,194,165,222,1,134,175,226,192,223,223,219,219,221,123,214,169,164,164,58,27,219,54,26,211,126,237,177,220,123,30,97,29,22,21,241,226,6,145,46,229,234,93,247,240,188,22,172,208,234,93,191,134,127,44,156,76,251,71,192,168,114,191,184,194,96,177,184,81,1,235,230,193,162,216,159,139,248,37,131,255,221,156,143,50,139,5,19,223,162,122,222,19,188,29,110,58,145,175,87,246,64,125,179,217,154,64,150,251,126,69,96,184,185,21,225,186,41,215,214,38,150,43,240,11,220,151,171,108,106,2,86,92,94,194,211,133,57,230,58,67,228,22,230,96,37,112,3,92,35,161,220,2,245,243,171,163,2,148,222,88,91,132,45,27,174,8,76,229,45,43,35,102,88,73,115,239,160,107,128,192,203,195,239,196,64,247,74,30,16,71,203,209,75,248,86,249,57,105,235,14,26,132,14,152,203,24,97,136,7,239,233,4,85,5,89,121,156,21,238,8,115,28,30,110,94,90,14,47,183,96,161,15,237,183,21,200,225,210,100,180,218,2,5,55,10,171,158,30,62,236,240,218,136,202,3,80,20,135,241,210,197,165,42,225,226,45,77,52,109,156,66,144,207,139,93,84,208,135,173,101,254,248,253,179,135,87,207,234,200,140,141,111,100,90,235,219,43,182,196,239,217,114,214,155,205,195,182,253,101,196,21,165,211,157,71,122,171,185,29,113,203,166,179,50,18,17,126,222,96,177,238,144,209,149,102,235,61,52,173,38,38,204,241,171,215,239,165,70,126,51,75,228,84,190,162,45,200,146,83,213,90,32,91,76,141,20,4,169,54,138,100,143,177,93,197,204,253,101,182,114,199,146,205,229,247,147,2,196,190,94,245,214,190,15,133,226,35,173,214,144,34,204,131,174,190,91,106,225,146,168,193,10,27,53,128,127,136,112,115,129,148,5,110,63,128,62,188,191,175,168,182,160,252,174,165,112,31,144,63,105,85,40,180,117,100,146,107,247,210,180,90,68,243,20,60,27,20,80,89,78,253,135,23,140,170,186,255,241,132,78,121,152,73,74,95,73,20,245,69,84,45,88,33,23,166,189,20,168,46,105,88,13,100,234,74,48,37,124,41,77,157,126,166,13,223,58,6,166,97,15,165,197,107,134,17,153,75,103,25,49,227,72,35,195,133,86,155,79,38,21,149,107,10,208,244,206,95,89,192,24,216,245,150,108,49,154,7,121,167,145,231,108,11,59,90,118,135,189,25,207,106,97,133,221,220,107,198,24,88,31,45,34,203,21,77,10,68,26,240,16,92,158,231,105,202,111,58,56,198,201,98,44,106,27,165,210,174,138,83,153,158,63,43,54,120,158,209,91,16,127,203,183,57,86,161,36,139,108,86,56,157,253,226,29,255,148,59,234,233,153,241,84,61,114,196,210,185,93,226,110,127,89,102,226,159,211,198,28,5,208,218,49,211,146,194,60,157,213,105,11,28,126,194,146,155,218,134,46,110,43,122,32,120,56,17,183,77,29,220,63,61,91,58,185,145,61,255,129,59,183,57,122,255,70,93,164,6,45,101,140,50,55,240,27,67,90,220,189,212,65,75,35,178,197,149,210,46,51,10,118,219,212,183,69,69,43,197,125,37,41,198,9,111,43,119,198,160,219,65,64,155,24,237,57,62,99,203,131,14,0,160,253,141,224,2,227,204,60,142,209,254,117,110,147,16,76,55,30,39,3,113,78,156,128,195,133,135,135,213,162,190,194,181,178,100,129,36,15,34,8,103,100,156,185,196,144,83,69,220,50,118,213,121,157,7,234,195,152,74,18,166,117,63,210,174,148,141,117,90,2,117,140,161,203,3,96,48,48,40,99,132,17,38,175,229,193,153,234,188,8,208,199,134,106,42,40,174,62,86,175,200,115,76,161,73,85,209,73,125,41,111,172,90,95,85,61,94,181,105,227,56,12,121,146,21,143,121,122,129,231,68,235,179,60,139,230,225,1,230,89,11,203,159,186,120,240,155,53,148,155,31,56,128,224,130,164,26,134,9,253,134,172,210,157,230,16,192,209,97,74,127,188,185,168,227,99,16,184,84,199,72,103,143,234,179,170,187,86,227,132,107,174,44,76,149,195,114,140,172,57,230,70,52,135,237,240,139,142,88,54,194,166,98,237,87,153,43,215,72,149,179,245,197,60,212,130,83,196,84,53,208,148,26,147,241,35,93,14,239,46,123,164,156,57,173,42,201,197,97,53,91,95,176,176,68,166,161,21,121,28,3,59,29,136,99,213,123,180,139,82,241,62,3,125,107,72,33,110,200,186,96,120,146,56,89,36,218,73,161,135,226,26,104,153,136,137,118,8,134,242,132,159,202,236,143,121,74,7,75,27,116,151,179,82,57,86,183,62,41,68,187,43,143,223,91,107,102,42,47,232,233,121,46,111,43,12,147,227,203,68,194,83,142,135,154,20,228,4,151,129,92,142,205,121,164,108,179,202,99,171,124,128,169,97,214,32,79,119,87,132,145,22,162,111,244,148,30,90,33,64,5,202,117,49,184,197,30,185,194,36,225,179,215,234,241,10,60,184,155,213,168,173,110,69,35,123,186,245,133,240,130,30,151,208,49,169,96,94,201,18,98,249,19,214,224,10,45,73,156,45,178,14,102,31,252,122,161,190,240,221,108,238,238,22,14,61,133,245,69,106,77,186,192,181,186,165,225,43,245,153,197,164,42,166,70,229,83,41,171,202,167,85,81,83,9,14,129,147,229,194,143,154,5,224,60,164,239,41,102,139,17,126,77,49,44,66,157,86,93,133,36,63,170,14,196,44,78,34,52,149,47,63,36,247,105,108,227,113,238,164,23,163,43,252,113,142,55,211,69,222,123,242,148,126,61,221,174,242,65,125,162,98,13,41,159,90,12,102,117,48,165,95,248,19,130,166,106,214,245,190,58,245,109,88,144,114,111,199,47,54,231,134,108,251,15,133,67,223,70,193,14,224,219,249,11,241,129,8,179,221,191,16,31,200,31,91,55,31,195,42,210,89,202,162,20,189,31,65,205,30,80,179,247,119,67,205,62,80,179,255,119,67,205,99,160,230,241,223,13,53,79,128,154,39,127,161,156,246,0,223,243,48,172,102,23,42,63,219,117,171,161,6,41,13,54,252,4,129,60,152,64,12,226,218,52,161,225,58,173,248,156,69,29,105,210,240,160,170,14,123,187,63,165,232,25,98,68,81,26,149,59,124,36,229,30,175,116,148,72,93,203,129,161,59,99,114,12,173,131,233,0,194,208,127,121,241,91,197,251,49,187,131,13,169,246,126,249,242,100,101,103,101,116,10,119,169,208,248,85,108,91,247,192,104,188,176,178,115,33,184,70,127,121,64,0,235,178,23,79,238,124,143,204,97,237,189,167,219,248,222,78,221,69,185,75,168,86,57,29,219,59,119,152,240,114,233,85,140,53,252,118,138,33,173,139,99,182,55,124,179,0,177,129,223,223,74,227,126,45,234,43,75,72,86,54,168,218,146,246,64,174,89,8,85,138,172,250,213,96,229,10,146,87,179,203,112,107,190,141,75,199,69,57,221,119,51,75,234,43,29,35,83,13,11,51,170,87,88,163,110,79,146,46,123,226,249,205,168,177,170,207,248,53,248,51,189,218,83,237,231,192,243,239,227,147,146,166,86,23,240,27,88,70,101,71,236,164,44,218,251,110,206,25,79,27,127,206,99,109,198,154,213,131,127,14,103,191,109,121,3,47,202,154,195,239,98,13,129,89,111,240,178,132,241,71,15,251,155,132,129,74,142,254,192,64,75,115,85,251,235,70,42,178,55,221,129,162,210,139,85,74,122,11,60,6,127,170,59,216,202,238,54,3,4,169,209,219,215,14,147,71,250,175,212,204,133,190,65,16,119,165,226,154,62,151,89,62,38,193,171,39,84,47,112,78,171,193,26,254,134,191,74,195,46,141,15,49,119,93,140,24,154,222,133,44,162,146,108,233,20,124,192,235,26,182,181,71,38,203,183,239,66,149,240,32,189,63,141,181,200,84,26,235,8,186,127,39,49,234,43,226,59,104,89,68,235,80,99,126,75,204,62,211,43,245,201,64,79,32,129,223,201,221,114,252,45,242,165,228,201,120,165,254,247,93,238,73,252,214,189,79,188,86,199,85,221,53,61,88,83,26,47,50,37,12,21,19,222,198,28,162,167,45,57,107,190,72,137,121,75,185,176,202,55,248,227,164,51,250,232,120,26,92,55,164,89,92,231,235,210,175,69,173,230,60,155,74,74,99,169,89,131,114,21,254,0,181,241,215,133,19,56,9,119,39,89,239,9,32,88,181,238,175,234,7,127,31,83,218,210,82,255,77,242,47,183,177,19,33,38,231,106,120,63,114,5,28,35,224,31,184,4,238,160,158,248,243,87,72,127,35,12,194,183,41,37,216,186,105,104,8,5,15,105,203,93,111,25,76,2,30,198,23,13,53,201,67,119,117,75,107,174,87,54,173,181,59,38,235,149,37,23,33,46,153,7,197,251,214,240,33,29,82,53,40,217,50,219,169,98,80,147,170,54,81,153,172,25,70,23,104,167,125,17,232,210,128,123,157,34,102,58,70,184,145,219,194,79,185,193,59,60,200,233,236,188,178,148,1,41,58,200,229,95,224,201,83,188,44,178,214,175,14,186,112,135,79,240,60,239,163,56,205,139,7,71,105,140,185,148,144,169,175,254,138,134,114,147,173,110,227,107,127,45,81,230,21,106,53,218,107,219,121,25,85,35,62,60,140,3,175,228,0,232,175,67,181,12,94,215,109,99,87,249,119,134,186,52,118,248,141,124,185,47,152,44,102,103,248,48,26,101,201,160,38,185,181,205,137,127,60,145,45,183,29,215,146,213,38,151,214,241,232,73,112,229,134,26,213,129,155,156,173,229,247,100,149,194,42,119,178,168,28,58,150,221,190,117,146,139,148,19,30,197,196,232,167,123,197,83,250,198,82,222,209,95,235,82,69,63,149,175,18,214,154,138,198,107,234,51,136,150,233,120,52,60,145,29,153,58,185,140,117,232,108,90,44,59,214,231,152,219,48,43,143,12,97,172,29,113,182,106,144,229,166,98,125,15,239,31,79,126,27,155,146,223,164,114,91,228,192,66,255,253,149,250,234,151,105,254,177,81,16,241,52,192,211,80,229,97,241,130,21,89,65,227,15,131,233,79,94,215,251,216,21,12,70,144,191,89,140,152,126,192,240,171,48,84,128,30,43,183,117,57,139,140,15,172,71,16,42,98,13,10,29,223,40,63,172,182,26,91,192,150,250,203,146,30,197,33,240,223,104,248,158,127,21,44,91,164,242,176,90,26,205,141,62,76,124,145,224,88,233,232,80,78,17,91,66,103,106,34,64,232,145,50,250,219,36,27,48,21,195,130,207,242,8,229,32,194,206,60,15,70,116,124,176,58,140,104,76,167,159,170,234,33,70,31,200,122,236,237,84,30,126,60,163,250,154,204,129,49,203,99,30,137,28,250,227,39,4,242,243,241,11,131,234,35,226,11,75,226,43,244,118,110,198,161,48,250,211,223,243,149,135,69,170,163,74,1,48,157,207,138,39,213,34,73,234,56,153,56,34,226,9,172,20,141,138,66,234,234,73,182,152,172,216,194,67,72,50,208,65,34,26,75,185,149,101,182,60,205,73,219,184,40,176,53,181,53,13,194,34,5,67,215,56,0,173,43,147,118,21,167,50,22,119,43,188,154,134,107,200,180,252,251,146,223,230,0,175,88,114,47,113,57,26,241,236,29,106,212,44,91,248,199,214,44,235,42,20,228,72,253,235,123,253,222,44,37,125,175,78,43,41,29,43,83,183,215,143,164,168,115,187,250,233,190,69,0,223,209,179,38,60,157,35,148,185,176,187,33,233,90,5,185,157,84,128,106,238,109,21,39,92,72,174,194,21,58,153,242,56,136,2,135,124,237,147,84,103,236,21,241,111,61,80,197,95,157,56,231,249,10,112,31,98,170,110,81,240,146,84,220,5,79,77,13,157,247,73,142,75,233,190,214,186,227,215,187,190,60,250,193,120,31,43,117,241,204,7,107,248,107,0,234,23,4,77,234,234,106,237,88,43,84,100,114,245,124,137,210,158,84,139,103,238,119,84,254,187,23,121,123,141,206,255,224,197,126,191,135,123,48,219,45,86,144,249,183,104,117,101,18,51,92,230,221,114,111,176,107,20,255,169,217,46,157,92,143,138,124,235,51,149,164,129,212,19,13,192,85,25,91,73,208,39,88,200,248,183,5,170,20,85,141,91,73,214,55,25,183,228,94,251,166,140,244,31,177,108,73,171,255,254,119,36,254,109,165,128,255,131,133,127,133,165,147,229,150,69,133,227,253,6,175,204,7,86,255,170,113,117,231,163,61,9,79,34,48,103,198,167,237,181,204,149,250,3,104,82,186,121,122,199,6,196,119,34,160,68,86,229,16,70,16,11,120,68,231,219,51,89,1,223,150,214,255,167,116,254,83,58,255,41,157,247,74,103,87,101,225,208,131,26,254,127,82,76,228,161,157,138,0,0}; \ No newline at end of file +#define index_html_gz_len 11615 +static const char index_html_gz[] PROGMEM = {31,139,8,0,0,0,0,0,0,10,237,125,107,119,219,56,146,232,247,123,206,254,7,132,233,137,165,54,69,201,78,236,36,178,165,172,227,56,29,223,205,195,27,59,211,187,199,227,171,3,73,144,197,132,34,53,36,21,219,227,214,127,223,122,0,36,248,144,173,100,50,179,125,250,78,122,198,34,9,16,168,42,20,234,133,2,184,255,96,28,141,210,155,185,18,211,116,22,244,247,241,175,8,100,120,217,115,84,232,192,189,146,227,254,254,76,165,82,140,166,50,78,84,218,115,22,233,164,245,12,202,82,63,13,84,255,245,75,239,157,255,214,191,156,166,173,55,139,225,126,155,159,238,7,126,248,69,196,42,232,57,73,122,19,168,100,170,84,234,136,105,172,38,61,103,154,166,243,164,219,110,207,228,245,104,28,122,195,40,74,147,52,150,115,188,25,69,179,118,246,160,253,216,123,236,61,109,143,146,36,127,230,205,124,168,149,36,142,238,163,216,228,165,159,98,43,240,51,93,12,61,63,202,223,107,165,209,229,101,160,218,219,30,252,87,108,82,23,101,45,87,224,174,237,10,250,249,156,120,163,32,90,140,39,129,140,21,129,46,63,203,235,118,224,15,237,214,147,192,31,171,184,253,220,123,234,117,74,29,115,209,143,237,56,81,129,26,165,254,223,148,247,57,105,119,188,173,109,111,151,122,205,159,103,253,63,254,135,161,76,125,181,183,176,247,39,101,156,169,236,174,158,137,221,66,57,83,61,231,171,175,174,230,81,12,172,51,138,194,84,133,192,126,87,254,56,157,246,198,234,171,63,82,45,186,113,253,208,79,125,25,180,146,145,12,84,111,11,154,120,208,106,157,251,19,17,164,226,248,72,60,191,232,255,219,255,17,240,111,63,25,197,254,60,21,73,60,90,27,41,156,17,59,201,212,255,10,204,248,212,123,156,223,3,121,161,163,54,55,73,29,236,63,56,87,225,216,159,92,180,90,253,125,194,168,239,225,4,82,113,43,142,174,110,135,81,140,151,195,40,77,163,89,119,107,126,45,146,8,198,95,60,28,141,70,203,64,14,85,112,59,246,147,121,32,111,186,195,32,26,125,89,122,177,28,251,81,43,154,167,126,20,222,206,229,120,236,135,151,221,142,216,153,95,239,141,22,113,18,197,221,121,228,3,89,226,37,2,61,147,225,184,53,92,64,235,97,114,27,248,73,218,34,24,186,97,20,170,189,153,140,47,253,176,219,217,203,154,169,188,35,2,63,3,192,15,97,248,85,139,224,208,239,182,98,156,228,221,45,53,219,139,190,170,120,18,68,87,93,185,72,163,165,55,137,226,89,11,198,38,190,185,53,221,136,142,216,238,0,134,29,187,84,240,53,142,100,28,5,183,52,118,221,237,142,154,21,42,21,41,97,3,194,68,2,124,210,134,167,226,56,138,155,130,127,91,126,56,137,178,87,16,223,165,93,48,138,2,32,85,172,198,123,19,232,186,149,192,28,232,122,79,177,219,188,82,119,168,0,6,117,171,217,172,187,209,216,40,20,203,9,144,57,47,109,66,169,30,218,97,26,26,180,17,229,165,119,41,83,117,37,111,90,64,104,42,4,74,201,180,75,212,91,62,212,45,8,111,28,71,243,113,116,5,227,26,37,62,14,112,87,115,113,137,186,213,55,90,51,21,46,236,193,98,226,60,132,217,53,153,248,163,86,18,250,147,73,145,26,15,233,153,26,183,116,29,0,248,186,53,85,52,160,59,29,107,68,91,55,122,76,77,103,119,13,135,23,202,175,67,25,223,14,229,232,203,101,28,45,128,149,52,169,47,135,178,241,100,203,197,255,237,184,222,243,230,3,127,134,179,88,134,233,158,158,5,200,218,139,4,185,144,27,105,13,99,96,69,183,112,215,157,34,88,122,248,30,78,38,147,165,7,244,4,1,2,36,25,75,224,182,74,199,88,73,247,80,158,96,30,131,190,2,147,171,169,159,170,86,170,102,243,214,220,31,125,129,94,53,117,182,129,56,121,55,93,124,7,192,187,68,240,97,80,26,105,36,104,96,221,135,114,119,188,53,153,136,142,139,48,136,157,206,159,240,66,118,58,29,177,213,233,252,169,185,87,59,179,204,100,124,204,83,101,186,80,53,221,243,60,193,86,246,74,242,33,175,15,76,26,214,189,5,2,113,212,192,87,69,75,60,86,179,21,96,20,133,201,90,248,194,108,98,92,1,191,93,111,119,119,247,41,32,220,129,187,199,160,179,225,31,221,105,58,116,58,19,177,155,85,154,192,221,51,83,9,155,33,250,48,50,95,101,0,127,53,140,183,213,129,4,114,26,105,20,168,73,218,245,118,50,76,17,103,11,253,250,97,158,7,139,164,5,154,7,254,34,130,243,219,194,92,179,41,61,10,0,239,46,200,234,105,153,232,229,54,4,139,207,238,196,143,65,228,70,147,22,154,83,69,97,207,162,51,227,121,51,7,210,104,94,46,97,89,129,184,173,219,109,32,87,246,138,205,212,118,90,44,184,175,35,143,76,58,211,182,166,17,50,172,105,82,207,61,152,101,230,9,43,30,26,180,140,199,65,107,9,243,255,167,160,193,50,141,164,71,237,241,19,120,72,131,165,31,108,121,79,158,223,65,144,137,175,130,49,212,13,230,245,90,43,151,245,91,222,54,242,73,153,113,64,82,7,55,243,169,15,210,196,75,230,48,129,0,202,91,25,250,51,73,226,24,31,137,173,68,128,248,71,201,172,4,207,133,189,214,149,26,126,241,211,86,177,230,118,77,213,37,218,10,185,124,7,67,7,234,127,5,93,12,80,24,28,129,215,150,195,104,108,52,167,177,14,182,59,168,74,38,96,52,89,179,154,158,253,251,76,141,125,41,162,48,184,17,96,123,40,21,10,16,149,162,129,141,210,216,136,238,99,212,67,205,219,186,102,119,176,137,117,218,120,242,108,101,27,157,117,219,120,186,251,108,69,27,91,59,207,214,108,227,249,243,237,85,109,108,237,98,27,222,44,26,131,233,135,21,132,151,160,133,27,133,173,2,199,86,13,46,165,148,97,75,83,234,129,208,208,106,82,228,124,209,34,57,164,249,123,119,178,91,83,35,129,9,149,169,168,221,221,229,34,240,36,3,177,202,20,179,44,176,98,205,85,6,88,77,61,50,130,180,200,25,77,253,96,220,188,173,176,247,191,127,81,55,147,24,204,232,68,32,135,222,78,226,104,118,11,6,64,152,160,181,213,37,99,185,177,213,20,113,148,130,197,210,232,52,151,105,116,71,249,227,221,206,88,93,54,97,212,204,12,40,182,191,205,29,152,194,188,161,66,251,43,139,179,230,189,204,81,105,141,225,42,85,5,3,202,27,181,242,114,152,106,179,220,228,4,203,116,9,246,56,217,221,251,109,246,92,145,45,250,181,102,63,90,248,222,101,20,129,227,39,231,126,82,178,250,63,255,117,161,226,27,48,249,193,133,209,55,228,177,20,108,254,218,118,215,117,106,63,151,125,218,251,155,94,215,185,253,188,194,183,189,191,135,123,124,33,176,8,84,156,140,192,78,70,223,114,203,123,6,254,80,254,172,245,67,250,88,225,55,215,250,204,127,119,103,117,190,50,92,37,96,169,130,80,129,201,106,213,248,209,216,21,92,228,207,43,60,228,218,14,57,62,147,170,235,180,253,89,126,149,252,212,233,79,22,33,137,9,1,230,247,89,244,38,9,26,210,85,110,220,188,149,237,222,246,206,142,171,248,39,166,159,189,175,50,22,137,155,186,163,222,59,153,78,61,96,91,93,221,157,234,39,126,104,158,132,189,198,104,115,218,108,111,239,249,147,198,168,215,155,54,147,94,218,235,236,169,32,81,183,216,208,176,55,106,77,247,146,43,96,208,105,35,237,133,125,111,231,197,176,221,216,110,193,227,102,23,174,240,117,119,212,188,29,201,68,9,217,77,122,13,213,138,155,237,225,102,67,237,199,47,118,187,157,230,222,48,86,18,76,81,172,160,176,66,220,146,88,97,219,46,136,177,64,182,20,22,60,89,38,237,222,238,50,86,233,34,14,111,167,221,196,77,186,169,11,238,225,114,153,81,226,227,47,47,211,232,205,233,159,13,37,182,122,189,30,136,139,5,120,80,105,226,5,42,188,76,167,143,30,53,84,79,122,151,110,12,127,135,174,132,191,113,211,144,167,66,156,81,133,56,211,30,136,96,32,81,7,218,78,95,116,186,211,118,234,14,123,105,27,137,108,40,162,17,31,1,248,29,27,31,164,4,16,98,115,250,115,70,7,23,177,250,121,90,38,7,80,99,115,251,231,105,77,41,210,4,72,178,249,36,43,45,210,36,116,191,118,135,203,229,186,92,244,83,195,80,175,209,188,253,169,49,142,70,68,173,166,7,15,156,209,20,94,82,142,235,128,234,9,224,215,170,138,4,83,189,159,26,233,212,79,154,64,56,229,129,49,223,104,122,177,2,125,54,82,141,246,95,254,210,190,116,157,182,99,61,242,126,254,75,187,237,58,78,115,79,121,41,200,247,75,21,55,28,108,153,103,128,227,158,167,23,205,101,211,181,193,0,196,199,55,69,24,53,52,12,162,253,122,86,75,185,41,67,40,13,132,222,28,38,39,48,65,195,1,199,112,190,72,217,214,133,54,192,130,27,67,139,72,23,167,233,250,189,116,79,102,140,34,9,39,31,64,194,255,136,71,62,189,63,62,27,156,28,124,60,120,119,218,187,5,174,120,231,131,75,147,128,129,243,216,5,174,209,119,143,159,118,240,238,37,169,176,80,37,9,24,98,59,75,247,211,241,224,236,224,229,105,239,252,54,149,151,93,39,149,67,176,122,38,62,192,29,131,179,53,14,110,186,206,175,120,191,116,243,10,9,12,237,220,174,113,74,15,236,42,129,26,219,21,222,30,189,42,20,83,80,201,174,240,145,30,216,85,102,127,77,83,187,198,187,255,60,59,115,150,23,8,240,235,227,163,183,175,50,144,229,24,80,30,44,18,21,99,184,206,126,229,0,75,68,94,130,118,122,215,249,164,239,5,104,126,17,128,102,2,59,8,172,102,240,42,113,80,4,216,6,115,137,252,133,206,76,215,1,105,8,229,112,39,135,22,117,12,160,220,247,92,38,201,21,216,121,118,223,39,217,51,238,213,220,255,136,94,167,81,146,150,113,125,147,61,227,254,78,85,48,105,1,147,71,113,10,238,173,121,67,64,127,9,188,33,80,177,92,10,144,12,83,241,234,240,205,137,136,21,216,23,73,186,94,255,120,51,0,253,148,250,163,129,95,100,4,122,40,142,79,196,193,120,28,3,147,101,224,100,5,146,11,68,3,108,158,175,74,12,65,4,124,65,176,96,152,196,171,55,135,39,205,239,129,97,16,170,116,38,147,47,245,176,188,55,133,12,139,190,53,157,18,17,178,202,223,213,187,142,182,213,247,254,11,23,150,41,98,145,66,3,34,19,161,27,18,87,83,116,67,114,168,132,143,246,173,242,71,254,4,39,214,218,32,130,95,162,138,83,249,181,47,222,209,67,134,226,44,190,129,190,145,23,127,17,88,89,248,19,113,19,45,54,98,37,166,242,43,62,7,44,135,126,224,167,55,98,30,71,195,64,205,18,211,63,199,132,7,218,33,112,92,190,79,186,183,0,206,75,199,5,24,126,113,220,16,232,13,48,213,195,56,82,3,48,218,109,0,15,143,68,91,156,252,199,153,160,231,122,234,192,36,6,61,122,116,122,242,108,123,119,23,73,197,179,104,227,240,104,67,52,240,234,253,199,215,219,79,222,118,182,4,197,142,38,32,215,155,2,43,64,67,186,198,198,201,91,240,213,158,182,223,158,61,123,222,233,108,88,21,87,82,51,41,8,181,81,18,86,96,61,125,191,30,152,167,239,55,214,236,5,216,65,165,229,126,62,30,157,30,173,71,16,170,185,110,95,32,162,203,61,129,152,46,247,163,121,19,155,199,82,228,249,69,34,180,135,40,26,157,30,92,74,96,139,113,115,79,132,234,146,130,11,64,221,175,96,143,163,79,230,95,134,50,16,141,88,97,156,31,5,79,107,155,218,66,71,50,146,32,14,161,209,117,135,96,46,71,95,128,58,32,212,148,76,147,162,176,197,18,145,149,104,222,158,42,17,46,102,67,5,221,77,68,234,163,147,136,177,67,170,37,62,190,22,220,96,130,18,49,197,146,225,34,24,38,43,129,137,11,42,170,8,204,96,174,226,65,16,69,243,213,80,9,168,34,184,138,22,67,25,104,166,134,17,206,62,206,125,156,148,129,18,151,145,39,196,27,208,217,80,149,34,147,137,152,41,25,194,100,133,41,154,78,193,96,184,156,130,233,224,98,40,78,4,40,77,102,139,32,245,83,16,112,208,128,183,38,54,232,57,104,92,6,48,43,210,40,46,232,150,179,179,19,67,54,83,202,56,188,195,190,230,129,63,226,113,231,66,228,205,34,121,104,200,181,138,193,8,21,174,49,160,90,26,222,0,10,74,80,251,7,39,199,158,248,244,138,46,4,0,13,109,6,193,13,188,53,82,208,52,176,220,130,251,81,102,216,92,145,68,76,6,211,75,50,141,22,1,180,170,242,41,129,77,175,169,202,23,105,4,240,2,131,199,41,14,167,31,21,244,249,1,20,183,116,177,48,197,76,4,44,194,40,156,1,152,235,32,98,188,64,40,20,204,134,27,139,21,49,176,153,82,196,66,129,64,247,213,24,16,135,57,214,33,136,205,124,90,115,82,64,245,17,6,144,111,6,180,68,105,65,252,202,148,8,46,209,214,15,80,24,239,145,217,48,40,4,122,70,247,106,42,235,73,17,133,158,120,165,38,18,70,152,56,19,241,73,208,132,160,183,137,192,48,124,122,217,93,99,10,99,242,228,217,179,231,207,13,62,240,154,70,103,189,49,64,155,111,0,230,25,0,82,54,253,132,121,204,104,188,2,138,163,20,140,109,147,2,72,75,85,135,113,244,5,188,115,241,129,20,18,141,10,147,250,6,230,21,129,79,58,191,161,174,229,108,30,128,174,152,221,224,123,250,181,17,198,233,158,61,123,178,18,100,178,76,11,32,167,17,176,43,152,129,41,40,149,176,2,57,149,138,172,212,216,131,116,75,196,207,107,37,214,176,224,0,28,49,132,93,120,6,84,78,218,93,166,243,192,31,103,151,8,100,187,75,158,3,60,246,196,169,82,226,227,209,193,171,119,71,212,246,100,17,167,40,59,198,42,149,126,176,90,186,85,145,90,204,199,48,219,238,193,141,43,221,141,34,160,52,95,12,1,171,169,176,222,73,60,113,162,89,45,157,130,96,145,52,145,105,178,195,212,141,163,153,136,52,224,154,179,48,42,59,47,191,65,162,155,106,147,25,173,39,220,149,31,4,56,17,129,119,81,152,234,14,65,48,144,65,3,175,124,3,25,80,227,221,71,5,170,243,45,227,108,211,196,188,236,137,95,217,234,227,21,15,118,115,19,46,118,105,250,77,22,128,214,151,16,87,68,249,37,84,107,240,156,95,32,164,65,246,233,118,129,138,198,185,40,1,182,62,7,212,248,84,60,130,80,32,202,62,149,48,14,6,120,55,130,45,135,194,196,93,187,219,58,119,138,90,154,175,242,169,178,78,191,187,207,81,128,75,139,3,54,111,120,180,43,221,31,82,29,50,202,193,4,58,227,58,12,201,97,20,134,28,246,55,22,18,24,27,137,196,241,187,103,88,64,187,159,225,141,31,142,130,197,24,234,191,253,245,140,88,125,232,195,196,133,210,59,231,243,106,253,94,64,49,241,81,138,12,170,152,22,204,217,2,122,239,12,252,182,183,112,12,232,81,83,228,43,184,188,104,66,138,129,186,79,196,198,136,9,161,198,27,132,196,6,233,149,236,81,133,22,128,31,180,201,184,0,101,184,85,152,172,82,200,97,180,96,37,138,166,36,16,214,181,36,189,43,84,10,148,51,205,105,218,141,51,106,172,244,76,156,52,94,40,7,188,51,194,194,113,157,137,12,18,124,240,74,131,96,187,42,5,18,78,163,153,26,0,191,249,24,151,77,7,150,218,141,213,196,191,46,186,226,51,117,96,106,50,63,230,186,248,68,87,215,4,157,0,171,130,241,70,152,168,16,53,37,185,38,151,49,173,234,177,158,42,180,183,145,112,139,185,194,54,1,38,73,78,26,48,23,40,188,232,10,116,53,10,82,120,40,19,182,123,135,104,143,164,52,18,66,218,38,203,122,60,68,6,227,32,243,155,72,239,84,34,56,185,95,37,184,156,209,220,246,158,136,95,222,252,77,80,27,52,202,1,104,103,100,30,150,114,60,31,216,245,36,1,103,124,208,48,74,181,123,10,38,175,241,242,30,220,59,204,33,214,236,58,244,227,184,236,243,129,184,176,93,64,123,160,139,182,112,60,217,126,2,198,212,21,90,244,96,185,5,54,146,212,162,56,193,66,241,150,11,181,40,162,71,84,159,166,105,168,97,189,23,212,119,199,239,65,188,160,207,245,246,195,175,224,129,69,87,142,251,230,248,151,55,192,70,160,242,29,247,221,193,127,65,185,188,190,7,94,182,25,6,72,206,176,14,228,183,108,83,28,154,114,134,250,215,169,63,154,10,253,82,82,50,9,51,9,6,124,136,243,80,83,243,30,124,8,11,194,231,221,241,43,188,26,23,240,185,27,11,3,73,21,254,83,244,139,14,179,226,85,224,147,251,100,3,142,136,100,192,131,180,249,68,204,52,81,56,90,217,139,96,28,170,113,2,102,2,189,159,172,20,35,48,85,83,20,115,221,219,25,251,61,170,251,160,179,252,251,113,215,131,87,227,216,234,97,43,57,182,199,225,40,86,146,48,73,181,238,192,123,156,230,128,170,156,69,11,144,59,218,229,69,228,224,142,187,32,220,129,34,218,140,66,99,145,28,1,219,82,207,171,102,246,63,70,159,30,175,86,53,5,19,158,205,165,73,176,72,166,44,44,192,105,45,199,197,192,146,193,114,145,151,107,111,18,60,195,217,98,86,240,148,2,0,135,114,173,208,124,75,175,112,121,158,94,214,161,41,178,156,224,137,76,166,245,216,128,168,145,55,164,139,252,25,173,244,167,10,196,206,28,117,74,146,150,90,88,211,229,178,204,194,24,255,4,254,204,175,132,170,117,211,88,65,232,10,223,132,164,109,41,99,37,140,77,232,54,27,99,203,45,219,233,116,86,199,79,10,2,188,224,146,15,48,116,144,166,96,20,192,133,2,199,57,24,175,12,93,8,83,87,88,117,51,187,7,211,40,19,49,5,153,61,47,198,59,164,14,80,224,155,168,232,79,236,104,11,41,54,10,87,16,147,26,172,129,127,209,146,151,33,51,54,5,60,68,3,140,73,155,70,77,86,151,35,137,129,169,82,167,172,231,178,110,5,166,15,66,223,239,56,90,82,108,151,90,129,169,98,176,91,204,161,166,237,242,110,119,58,179,239,138,7,229,212,5,100,49,243,230,43,168,230,53,232,91,168,253,205,20,46,70,137,108,242,84,233,2,178,14,231,4,101,87,78,36,76,248,184,200,86,29,87,204,228,53,49,42,19,107,171,211,129,103,29,51,175,146,213,76,119,23,93,102,204,252,171,73,145,85,168,193,126,34,99,131,1,206,254,145,68,203,145,37,31,203,248,82,64,77,229,254,98,1,183,199,235,194,206,78,181,150,104,152,233,85,144,205,191,144,215,197,83,210,148,102,107,47,96,102,129,233,79,250,137,203,172,224,148,49,87,209,73,178,68,133,153,236,56,46,31,143,78,207,208,129,157,131,94,81,28,62,203,5,128,129,190,14,188,250,169,207,150,229,32,179,250,104,93,96,192,11,195,68,0,75,62,211,67,242,79,99,112,166,35,116,101,112,93,128,114,143,193,168,76,23,108,154,182,41,221,72,232,244,237,12,245,215,240,236,227,47,47,127,253,149,131,169,162,193,214,27,60,218,60,60,60,195,120,205,235,79,103,157,103,207,155,110,246,170,118,230,199,159,23,32,144,185,213,188,27,124,131,242,98,5,230,197,170,24,11,244,204,101,240,205,26,26,64,56,159,199,209,60,70,9,175,29,137,153,252,194,156,193,86,38,135,16,244,107,152,95,90,193,18,230,255,175,7,31,223,31,191,255,165,203,130,98,162,184,67,184,68,83,20,64,6,47,218,71,189,66,2,108,131,192,221,200,80,89,223,1,57,10,57,42,150,59,32,58,78,182,218,72,80,99,30,54,90,216,1,65,56,241,47,203,177,123,26,169,241,2,153,90,96,53,97,170,241,224,100,85,104,121,201,10,80,146,13,33,14,70,35,20,200,39,152,129,171,235,73,63,37,43,35,210,45,33,45,104,57,88,227,105,128,178,24,143,157,168,58,152,39,236,97,213,193,76,0,17,196,92,73,247,24,210,10,250,61,192,79,101,82,125,203,68,44,169,205,16,180,75,20,127,249,86,160,35,226,183,210,252,40,130,97,85,89,139,196,201,130,104,140,129,156,27,17,47,40,221,243,91,193,98,153,86,11,83,22,120,79,214,129,7,141,93,28,93,138,205,99,224,13,111,178,247,191,7,40,96,203,69,88,0,237,53,218,85,98,196,22,105,5,186,247,229,213,25,128,15,33,166,249,77,38,25,195,109,196,56,42,60,154,203,22,217,42,50,156,224,187,112,127,249,248,225,211,201,224,244,236,224,236,104,240,31,71,255,125,218,59,119,180,244,116,76,236,195,25,102,201,16,112,163,157,61,103,186,160,58,153,8,130,27,154,230,240,203,148,112,190,168,224,171,159,61,31,160,100,194,198,64,224,105,90,57,40,40,22,160,128,6,230,77,53,153,16,47,59,89,0,23,174,77,208,54,127,204,78,179,19,77,179,23,167,234,90,95,95,184,64,154,193,187,15,175,142,16,151,15,147,9,148,158,162,167,207,217,117,112,247,26,20,121,126,71,101,195,0,23,232,185,196,92,7,148,173,15,87,31,66,104,244,211,171,147,193,201,199,15,103,31,14,63,188,29,252,249,232,227,233,241,135,247,208,193,142,187,123,225,190,58,122,125,240,233,237,217,32,175,147,85,233,237,184,89,90,154,171,163,12,167,217,3,202,80,133,97,233,61,216,114,131,72,34,155,157,106,231,9,31,145,170,131,27,82,161,199,99,251,209,1,54,5,15,48,203,203,113,30,244,130,104,68,163,224,153,20,6,206,161,185,82,195,211,8,153,162,23,170,43,241,171,185,107,56,87,152,0,231,108,86,94,219,116,186,207,182,156,230,94,246,162,23,133,218,81,236,229,41,58,205,91,232,214,0,207,61,97,22,17,70,164,246,126,106,56,229,45,44,78,211,155,163,173,129,217,58,251,112,213,119,54,211,77,103,191,77,151,205,229,114,73,13,68,111,212,117,161,15,78,141,114,58,215,206,166,242,210,232,148,56,184,177,181,219,132,155,79,115,16,41,135,64,205,70,115,201,68,81,134,74,118,19,21,10,118,242,225,240,192,93,249,51,26,109,13,128,156,248,234,120,220,68,231,85,87,134,199,151,124,69,79,49,192,151,213,60,3,6,108,214,141,79,172,38,104,251,211,19,242,225,16,190,188,205,2,112,63,53,54,30,234,30,4,37,54,157,35,1,121,99,69,207,217,216,84,155,27,206,197,70,211,27,33,39,234,118,16,138,74,35,36,185,2,255,190,215,47,85,122,184,136,49,149,234,149,70,183,103,229,102,49,181,65,134,196,137,58,14,211,70,78,167,75,67,167,102,161,145,10,74,89,27,200,2,6,49,202,135,166,181,110,64,16,24,1,97,108,56,4,36,12,189,139,41,92,7,225,152,167,4,51,181,213,30,112,217,131,18,145,77,246,218,109,62,16,221,28,38,26,164,166,107,134,179,91,69,25,74,53,104,221,10,42,72,164,180,247,97,248,25,83,58,113,135,155,175,146,70,121,210,122,218,72,193,244,179,0,140,189,60,191,45,205,8,48,240,252,228,232,175,11,25,52,210,243,173,11,143,98,140,212,199,137,140,229,44,113,21,166,164,149,166,114,199,77,117,242,90,191,243,162,210,105,198,171,233,121,231,162,217,173,148,211,30,152,70,179,34,31,150,75,151,7,224,83,28,244,42,41,128,181,228,73,123,53,132,113,101,175,76,101,22,61,189,158,106,162,183,113,229,188,67,195,84,97,172,194,40,207,227,87,14,86,202,216,65,111,143,204,184,0,236,117,184,78,230,129,15,18,201,133,75,19,221,111,200,230,111,191,53,210,94,167,233,58,152,180,12,92,2,218,170,215,67,185,143,90,176,218,35,175,237,96,135,90,106,180,117,142,80,2,82,14,164,26,252,149,244,55,93,86,231,168,77,152,18,187,253,246,219,79,200,255,255,247,244,195,251,70,70,72,32,135,61,5,65,211,142,3,69,45,125,34,73,212,192,241,181,167,74,113,214,22,230,137,158,187,122,154,84,39,136,37,219,122,3,207,120,166,13,187,127,220,204,249,147,135,137,203,141,219,69,28,116,45,56,55,157,23,180,45,225,67,248,159,11,5,98,129,204,106,119,166,210,105,52,6,159,242,211,153,227,98,127,93,196,207,99,19,193,159,220,64,163,174,222,202,72,243,203,1,159,129,147,23,192,161,249,156,160,174,215,22,90,247,94,58,0,33,224,69,78,172,149,129,138,83,184,0,166,220,82,143,81,170,134,227,67,246,9,86,225,166,241,114,218,201,13,120,223,224,240,222,102,192,127,56,253,94,232,113,112,8,0,205,35,167,180,12,246,49,186,234,217,89,168,174,228,105,226,247,156,253,52,238,3,43,111,226,213,184,239,184,112,181,177,79,18,77,239,125,54,2,39,57,191,112,196,8,44,178,164,231,216,27,104,29,81,16,203,237,254,134,75,173,181,77,115,171,26,198,101,248,53,26,77,239,109,116,236,127,53,109,224,198,76,78,161,165,53,36,189,237,0,158,107,55,172,191,177,7,221,52,16,247,176,215,217,11,247,107,109,31,45,172,246,194,205,77,166,83,210,171,173,119,30,94,184,81,79,246,122,201,111,191,37,189,222,29,182,210,163,71,15,234,123,178,196,194,30,33,67,27,93,45,116,68,97,175,233,198,102,35,122,225,8,61,163,186,142,211,4,234,48,113,12,113,81,144,244,120,173,198,49,219,215,121,253,140,136,141,17,0,180,77,113,75,75,207,137,192,134,20,69,221,154,64,139,130,251,25,77,21,216,73,99,238,199,233,11,103,51,209,195,64,80,246,29,157,226,45,248,33,140,67,191,126,132,152,252,101,172,198,232,146,163,231,49,139,190,170,150,217,175,172,23,110,51,164,204,91,217,94,39,145,239,122,226,87,113,111,130,159,113,8,247,101,96,176,192,105,35,167,47,201,24,205,44,209,98,2,183,222,241,192,63,45,76,82,66,25,78,119,188,191,21,164,99,173,69,250,232,81,46,74,97,62,235,214,29,183,100,86,86,236,224,142,171,53,242,23,117,147,64,37,220,128,126,36,65,162,216,138,215,36,140,111,60,52,13,107,147,138,7,151,103,8,88,67,174,95,176,158,164,81,226,27,231,43,237,166,44,169,188,223,121,244,168,161,153,6,55,39,120,50,77,227,134,67,206,8,232,43,230,132,97,116,93,45,124,113,16,199,242,6,140,2,250,109,40,204,149,127,129,127,171,184,32,17,72,127,116,125,174,215,229,76,118,186,198,4,123,85,50,241,211,158,83,60,215,67,156,163,189,156,27,244,23,206,158,73,201,231,237,161,189,212,133,113,44,108,223,134,33,196,13,144,40,155,97,8,180,225,11,46,215,64,27,26,128,120,189,201,193,185,67,9,140,121,201,108,170,182,81,51,112,25,10,10,172,26,48,49,20,88,75,48,66,182,109,39,177,36,179,231,36,86,48,246,155,60,223,190,128,209,41,195,37,199,99,134,170,113,139,59,3,186,169,75,67,11,191,101,59,172,235,3,61,43,239,107,243,192,96,246,96,11,168,142,100,207,92,82,164,70,178,138,12,118,189,90,132,147,213,128,162,7,132,218,139,225,85,184,113,193,114,87,170,112,97,111,213,232,166,53,25,248,101,61,7,106,226,160,192,230,192,221,133,201,235,208,66,83,109,187,204,26,117,1,182,85,93,214,6,227,238,232,180,182,237,186,110,57,144,181,94,183,58,158,182,118,183,186,237,82,183,89,8,235,222,78,243,96,215,58,93,230,237,150,58,228,96,206,189,189,233,48,215,58,93,233,22,151,108,212,144,85,94,80,39,45,38,120,98,132,1,111,246,209,117,244,104,0,239,87,30,213,11,49,15,204,46,244,248,203,54,86,67,179,57,250,49,46,77,120,5,243,152,68,91,53,4,130,150,26,206,90,189,57,224,144,59,172,58,50,136,12,6,181,22,227,185,209,145,128,6,184,50,15,182,48,32,193,206,39,184,93,30,43,195,67,212,150,13,135,206,12,113,120,115,16,145,216,86,25,182,73,119,177,129,239,206,228,220,66,49,55,15,127,2,187,132,119,80,237,25,85,159,188,151,239,27,126,243,5,248,48,160,191,168,2,204,246,98,183,110,184,8,130,38,201,32,191,220,185,49,251,254,209,29,135,216,113,193,69,63,175,88,67,181,48,164,150,19,147,150,157,22,114,13,31,64,21,99,70,38,189,115,52,2,59,123,209,190,81,166,123,17,216,141,201,121,116,209,59,151,240,215,245,241,79,8,127,46,246,114,155,63,179,17,50,171,159,198,241,30,223,164,206,39,184,45,241,108,55,161,141,97,75,119,142,206,73,213,212,1,14,46,113,35,122,130,63,30,46,246,20,145,197,141,7,94,9,128,148,224,48,44,63,160,33,65,198,55,156,234,52,97,78,229,91,178,77,160,194,173,198,212,4,237,248,91,128,134,217,43,224,223,184,205,149,23,233,31,27,180,3,86,148,107,130,183,118,20,37,86,227,197,72,149,120,139,103,100,109,240,196,176,58,24,68,128,46,178,143,21,147,115,101,22,202,131,75,109,40,128,132,89,186,183,85,92,203,6,138,198,152,119,178,215,133,200,108,93,140,98,68,107,111,179,77,177,48,9,238,208,220,205,226,120,99,212,201,238,179,18,5,171,177,80,238,239,253,62,179,166,89,29,89,6,132,161,58,66,81,81,152,14,234,133,197,108,45,127,220,34,247,198,169,147,47,53,245,236,211,159,140,126,81,205,102,183,190,201,58,41,109,66,33,24,75,209,72,220,21,208,210,145,192,180,119,79,128,84,200,13,13,78,19,53,5,67,224,154,200,140,131,110,64,4,180,73,193,96,54,231,44,1,121,141,181,76,134,52,86,204,67,91,5,65,137,30,147,61,60,171,35,94,170,249,194,84,77,166,160,37,145,52,124,91,32,70,22,38,154,250,136,31,10,15,138,216,254,153,229,117,175,198,180,6,136,48,254,210,110,252,101,188,217,252,139,103,255,52,90,13,239,231,102,243,69,219,40,144,219,153,252,28,197,93,156,120,238,204,15,233,114,251,130,133,36,92,62,190,112,113,29,24,123,130,187,29,44,0,61,213,61,167,250,84,147,234,96,209,5,204,36,208,70,152,65,86,133,45,159,226,54,240,104,250,250,197,39,105,166,217,164,71,125,245,125,254,93,186,28,109,58,68,159,235,117,20,115,192,169,134,35,80,215,1,23,208,143,44,201,46,124,248,160,135,142,41,12,247,136,249,166,165,53,95,198,164,158,126,176,233,136,6,58,86,208,172,47,195,116,211,105,106,77,250,160,151,114,11,1,66,144,53,32,60,228,117,60,120,3,154,82,5,243,200,192,150,158,23,185,131,234,162,31,84,124,140,235,245,96,165,200,140,65,240,1,110,105,207,121,68,207,167,165,129,8,112,210,160,145,207,94,1,77,47,237,229,134,158,91,173,101,230,42,179,35,27,73,123,178,87,28,212,6,248,148,242,114,128,70,131,155,145,170,249,194,129,58,38,35,89,200,175,96,81,227,170,253,3,167,235,252,55,103,172,234,100,201,25,120,169,180,170,26,166,166,186,199,179,207,0,145,44,102,51,25,223,24,80,37,3,226,247,82,15,230,3,38,231,173,142,194,167,30,130,5,195,48,86,215,31,208,182,214,67,215,124,208,107,109,161,145,98,249,245,216,229,196,143,103,87,50,86,45,60,78,209,209,100,118,240,88,69,7,204,19,80,173,195,56,186,2,83,121,128,66,0,213,222,96,17,7,168,49,252,18,107,131,29,144,68,1,88,222,209,165,14,70,102,183,96,126,239,213,112,155,123,207,8,89,33,30,115,252,145,168,143,245,144,144,215,193,158,230,93,148,188,123,208,89,188,184,153,201,67,121,230,96,239,84,226,190,128,46,6,20,36,42,15,244,156,22,177,34,203,210,188,153,157,225,50,247,205,105,40,120,204,6,110,185,198,115,52,102,67,138,154,180,85,50,199,77,154,131,153,79,91,120,6,80,15,234,4,148,174,217,102,16,235,122,199,232,119,79,85,122,55,210,193,138,69,151,87,47,21,81,152,230,126,193,242,214,116,199,0,16,143,191,137,239,185,206,135,247,184,218,193,239,53,243,83,99,206,40,124,218,0,135,1,110,35,164,111,185,8,253,116,90,156,54,50,57,59,245,67,63,247,98,83,195,187,204,174,134,184,232,146,157,138,113,119,205,189,50,22,249,178,60,98,194,231,176,96,236,142,87,145,28,119,171,211,249,25,252,69,189,96,164,78,80,172,171,171,67,108,14,143,247,249,57,245,166,84,39,245,18,253,27,104,7,53,95,204,55,110,8,150,23,10,90,214,73,11,94,118,208,66,179,221,40,60,55,71,46,172,168,93,70,201,74,108,90,129,147,175,33,204,211,21,24,194,176,103,63,251,25,207,216,107,151,32,201,143,123,168,244,203,249,14,245,61,134,56,251,171,20,172,44,35,164,180,154,5,253,194,144,194,165,236,237,116,216,116,168,28,164,135,6,7,232,121,235,184,68,106,177,235,76,129,91,104,37,203,165,197,243,63,185,180,160,245,167,38,45,102,224,193,86,167,38,151,160,214,57,166,21,121,80,34,118,198,1,58,198,85,167,184,120,92,86,193,190,43,158,181,197,242,35,153,203,144,26,97,161,114,74,155,58,13,44,14,193,6,79,190,21,184,206,189,192,105,98,213,1,199,112,215,2,23,205,45,216,72,252,21,78,226,204,180,29,46,222,133,200,109,234,149,54,250,94,99,12,108,197,250,208,170,213,21,127,108,204,204,85,11,45,224,128,75,207,100,201,191,200,35,200,122,31,137,89,205,20,118,4,60,173,9,164,74,179,4,147,158,203,139,117,214,72,64,67,216,43,33,27,155,33,66,105,1,108,226,230,116,83,179,30,146,89,209,18,43,180,251,2,23,69,172,69,143,166,155,175,119,56,168,243,138,171,8,133,243,114,29,19,108,175,177,155,49,241,176,81,145,197,15,58,230,80,23,133,156,156,214,218,189,120,82,200,127,181,76,67,0,53,76,92,104,10,79,93,3,201,154,182,27,91,63,155,66,58,193,174,209,68,251,147,206,12,162,153,71,130,80,234,133,250,162,136,244,155,246,194,108,227,22,230,49,233,157,61,51,169,237,163,62,209,41,136,112,39,48,176,81,97,200,20,175,250,227,238,34,130,3,56,106,169,235,46,230,141,162,138,197,88,24,23,129,34,94,89,134,83,165,216,197,163,71,229,14,72,240,176,44,115,50,161,198,79,99,121,213,98,188,112,72,40,27,180,106,181,222,98,146,132,189,216,97,136,88,88,241,48,15,125,152,154,93,51,110,96,24,194,104,119,137,131,50,219,149,3,88,234,188,216,12,109,155,108,94,128,105,97,19,90,25,248,179,243,144,211,90,238,41,12,142,174,219,45,90,213,250,169,67,231,3,49,77,104,137,121,85,147,214,98,245,218,77,26,201,86,110,13,221,105,226,108,202,188,160,148,107,116,200,181,100,122,97,139,115,112,4,11,18,180,97,154,46,8,174,22,121,169,181,96,23,218,170,21,121,198,149,196,66,60,132,79,159,206,68,77,129,186,121,136,135,35,235,8,49,210,198,189,7,141,59,3,203,171,130,194,142,131,103,75,25,220,180,191,190,46,225,208,111,247,209,109,176,143,139,130,217,92,239,58,103,245,114,221,145,21,85,66,12,222,16,207,152,162,250,119,144,200,171,95,158,117,107,228,89,22,78,72,227,44,208,129,45,103,75,238,178,215,217,147,251,207,247,228,230,102,51,139,46,228,161,7,67,65,16,240,126,127,95,234,83,223,31,106,13,67,149,11,162,185,143,63,32,135,37,184,4,240,134,67,81,142,58,20,10,253,0,241,133,116,234,34,135,133,153,133,239,148,230,64,14,129,195,171,149,149,209,66,105,165,157,56,187,253,106,38,24,72,5,76,109,169,75,169,227,148,18,120,107,185,44,219,107,58,176,232,92,184,89,96,116,35,19,102,126,61,127,249,213,120,89,47,143,76,233,240,88,182,204,13,165,32,1,98,220,58,209,5,1,78,251,72,143,102,243,244,134,121,6,159,69,48,70,31,194,215,209,104,145,224,45,87,254,16,190,4,7,5,239,129,63,241,180,228,91,134,181,91,23,24,1,172,246,209,124,201,220,190,210,73,155,142,61,248,253,187,51,0,96,194,27,159,144,25,1,27,6,247,176,78,45,217,49,226,218,161,171,153,146,250,133,74,16,147,21,79,133,243,43,231,130,58,205,149,140,194,75,35,182,125,85,121,185,207,70,198,70,190,8,98,230,136,67,20,236,83,250,168,186,166,148,83,126,0,205,154,58,18,174,193,136,143,66,30,188,131,241,184,52,28,105,37,2,141,198,124,229,161,89,180,254,142,164,67,247,142,84,198,101,125,156,118,89,12,245,231,177,102,139,109,113,65,160,158,99,147,40,78,201,158,237,234,120,113,45,15,255,255,199,180,199,227,63,4,199,234,197,19,94,226,217,211,191,189,44,177,87,218,75,19,44,206,245,82,130,132,183,221,66,126,194,49,128,219,208,45,212,44,91,48,139,188,166,8,92,183,118,52,116,236,249,255,53,58,215,231,178,53,57,104,189,238,180,158,95,220,110,185,79,150,191,157,235,203,157,101,243,167,118,243,69,35,131,16,186,234,236,99,12,83,237,247,118,119,118,30,239,188,104,20,214,37,48,247,1,93,128,110,233,49,39,132,14,21,238,130,167,227,1,84,156,109,153,236,92,119,240,128,127,220,218,212,185,126,13,255,28,28,174,230,143,104,99,105,167,105,244,178,171,194,12,221,227,64,196,198,254,34,243,201,66,249,21,188,172,175,173,84,14,19,118,189,104,255,236,25,222,130,103,8,53,180,89,178,167,15,159,172,93,103,15,201,215,203,102,18,167,166,89,211,108,3,89,73,94,86,124,80,232,21,109,3,229,153,77,40,182,145,128,157,163,11,167,27,183,154,171,44,178,215,180,250,233,213,73,214,212,6,182,1,45,47,192,39,228,230,172,137,129,141,233,53,211,2,198,2,119,141,220,139,116,169,33,152,38,120,178,22,40,118,157,158,167,157,89,141,61,183,239,228,231,98,214,166,145,249,19,10,104,15,49,206,7,239,153,185,84,232,44,255,254,9,102,74,74,68,143,156,92,87,230,110,55,212,161,164,179,172,115,184,180,9,173,189,100,112,254,112,135,15,102,87,149,48,202,79,204,215,20,198,203,22,202,4,110,23,111,105,160,181,36,113,101,238,109,187,117,169,62,61,16,8,232,154,189,224,174,152,49,139,114,154,125,86,97,103,246,137,149,169,67,194,68,45,128,176,229,61,68,181,99,70,0,178,238,232,83,32,107,191,109,238,150,6,124,238,26,164,157,181,163,234,239,2,220,30,130,124,95,208,15,128,15,239,75,187,23,51,64,107,67,71,188,80,2,3,110,86,229,49,133,210,100,22,220,46,155,221,98,106,42,157,105,91,159,244,91,198,12,243,126,237,209,135,201,33,151,60,121,179,248,75,179,118,238,213,78,153,202,12,231,57,92,245,234,146,22,29,231,47,172,83,253,45,143,70,175,169,218,80,20,174,201,59,54,121,20,249,254,160,176,89,46,73,22,195,153,159,22,115,139,48,202,91,235,119,86,115,128,188,169,76,74,94,94,179,154,81,132,1,8,115,64,118,218,43,67,160,98,31,220,131,191,41,206,222,4,175,4,70,250,142,180,5,117,206,43,79,23,217,218,101,41,255,83,54,95,72,111,190,72,166,153,106,237,102,239,244,206,65,11,243,211,11,235,169,126,68,153,12,133,51,64,186,231,23,75,90,230,246,128,99,144,130,183,213,201,138,117,80,181,255,35,18,88,82,12,118,88,251,25,76,211,204,28,20,250,130,223,134,131,65,6,71,91,72,53,17,2,100,241,250,193,214,109,215,141,89,157,9,182,146,79,87,130,98,125,157,100,229,74,45,84,219,247,201,22,173,143,57,91,118,43,157,222,72,235,109,254,37,250,235,105,20,161,160,108,220,210,65,218,152,133,219,117,48,64,237,82,46,110,201,123,206,164,188,195,123,24,164,15,162,164,203,254,250,18,93,119,29,185,96,75,208,68,195,30,234,61,241,133,208,85,237,250,120,179,144,217,221,168,9,120,232,40,199,239,251,96,241,2,24,37,47,189,10,79,37,21,38,223,208,6,48,174,202,76,58,135,249,155,62,122,244,160,176,163,234,209,163,194,94,195,26,63,240,247,123,6,122,126,170,60,90,158,185,17,58,68,203,148,115,193,245,129,180,96,86,216,238,139,225,195,226,99,253,10,127,89,13,138,244,246,5,86,95,124,227,148,234,234,61,183,69,147,113,20,5,129,156,39,217,99,25,95,226,151,43,77,122,122,86,220,47,122,150,113,11,79,85,115,250,188,196,138,8,248,151,36,171,180,199,36,10,213,105,102,66,115,78,255,135,20,103,27,40,4,88,198,69,28,57,157,94,155,203,109,167,242,205,77,169,77,182,34,133,25,71,81,197,185,226,46,96,57,252,208,71,31,45,187,60,147,232,69,226,178,228,43,82,182,44,162,251,70,28,100,70,123,169,105,10,19,179,131,66,151,247,56,238,180,50,71,178,146,169,216,47,174,9,102,36,204,59,51,173,101,81,78,171,119,58,58,222,41,215,168,103,165,236,125,1,134,146,197,133,152,246,209,2,59,96,30,205,23,243,30,159,45,193,15,213,53,192,50,86,128,26,31,48,209,231,179,240,11,163,63,130,153,151,90,67,47,237,81,41,124,232,175,60,40,4,123,139,63,8,180,214,200,20,94,48,195,115,192,183,5,130,49,126,137,2,169,32,241,156,225,12,28,255,171,207,211,177,58,142,180,8,163,151,119,180,195,105,235,141,53,192,51,213,53,96,164,91,232,208,21,173,93,86,48,80,214,229,186,61,180,178,76,28,221,19,183,47,94,235,199,43,250,193,53,243,202,166,182,218,110,184,102,171,60,17,94,210,227,188,117,244,90,237,43,222,187,197,127,97,14,174,144,146,68,217,204,173,181,235,224,158,85,42,205,51,30,181,246,193,143,142,150,165,110,208,74,102,173,199,200,83,185,75,89,84,117,245,109,57,125,142,209,136,247,96,57,26,87,211,248,77,249,27,89,35,164,135,241,180,42,5,29,28,82,68,135,207,67,244,60,143,216,158,157,158,2,250,197,75,27,191,181,145,56,30,151,225,231,220,75,3,253,241,184,56,13,173,252,209,124,46,174,66,14,27,47,224,117,68,187,132,167,139,33,238,17,174,32,85,5,249,73,17,17,59,197,147,186,41,236,103,102,238,34,220,70,163,212,141,47,135,87,248,103,128,55,147,69,218,121,246,156,126,158,111,21,233,160,67,194,78,159,204,135,12,153,213,185,4,230,133,21,217,4,247,44,249,155,13,145,253,213,123,32,205,218,201,125,27,32,183,64,224,240,146,110,47,91,147,239,139,173,12,133,111,202,61,248,46,8,182,161,191,237,127,98,127,192,194,226,241,63,177,63,224,63,241,100,205,254,68,129,59,115,94,100,214,251,17,208,236,0,52,59,191,27,104,118,1,154,221,223,13,52,79,1,154,167,191,27,104,158,1,52,207,254,137,124,218,129,254,14,130,32,235,177,160,35,239,144,173,150,24,164,40,95,255,163,154,69,160,120,48,244,80,39,9,45,211,112,197,62,105,125,6,103,197,66,44,58,36,245,246,162,134,167,143,126,112,174,84,238,176,1,181,249,191,210,16,36,113,205,136,161,185,102,83,12,181,67,113,29,12,15,130,43,88,119,118,117,208,33,197,218,135,135,103,43,43,107,165,147,153,131,153,196,47,246,182,121,79,27,149,23,86,86,206,24,215,170,207,39,215,137,182,120,249,236,206,247,72,29,150,222,123,190,133,239,109,223,249,222,118,167,250,222,118,167,108,181,221,197,135,171,236,148,173,237,59,180,126,62,91,11,250,29,126,109,29,79,192,173,219,213,116,167,255,102,1,12,7,191,223,10,234,110,201,31,206,115,206,86,22,232,100,180,122,23,183,154,136,154,51,187,254,169,80,116,5,200,171,169,102,25,68,223,70,165,211,44,157,249,187,137,197,146,206,68,15,40,233,77,88,233,110,162,146,55,205,160,115,77,252,92,38,202,186,226,51,121,13,150,80,167,244,212,88,72,157,142,243,125,116,210,76,85,107,60,126,3,201,40,79,81,156,229,73,211,223,77,57,235,105,229,211,235,107,19,214,206,222,254,199,80,246,219,102,57,208,34,207,249,254,46,210,80,51,235,33,207,41,228,63,26,237,111,98,6,62,120,243,251,17,205,21,93,150,121,153,187,29,126,209,144,200,82,67,69,97,75,69,214,143,69,159,226,226,177,214,216,85,215,130,5,123,253,220,17,252,249,229,149,2,186,44,156,177,165,187,98,149,85,163,205,206,53,229,94,244,19,90,177,31,208,164,112,250,191,226,79,110,25,176,42,34,26,175,219,35,250,182,119,117,22,210,206,24,182,42,222,227,117,169,183,181,49,227,93,52,119,117,53,151,126,124,127,156,111,145,232,56,223,9,84,255,78,96,244,249,54,119,192,178,8,215,129,198,62,229,70,124,162,87,202,131,129,118,193,28,126,231,119,179,243,183,176,89,145,173,172,55,145,56,56,201,225,47,59,240,107,82,67,91,177,120,173,207,105,190,107,148,244,161,188,154,39,10,10,189,142,70,4,79,93,16,219,126,145,150,165,28,109,10,107,75,225,239,7,93,208,49,41,248,73,143,50,83,171,235,116,93,248,13,199,149,140,112,91,100,153,94,74,186,33,159,140,127,143,16,169,14,243,63,216,45,193,65,184,59,24,125,143,35,34,138,217,195,69,227,248,251,136,82,23,222,250,95,226,127,62,158,27,191,74,49,208,232,253,200,25,112,138,13,255,192,41,112,7,244,68,159,127,6,247,87,124,35,124,155,66,139,181,75,230,22,83,200,128,50,69,204,210,202,216,151,65,116,89,145,150,50,104,173,46,169,141,137,115,209,90,171,136,188,199,129,169,8,94,202,204,207,222,119,250,143,232,100,228,189,156,44,211,237,98,15,122,80,205,87,102,121,231,1,26,68,219,245,147,192,100,180,220,107,34,9,219,76,194,52,134,26,122,114,122,67,127,63,165,111,109,228,25,56,8,209,126,138,200,247,241,136,65,184,204,162,223,175,246,219,112,135,79,240,83,132,39,81,156,102,15,78,226,8,99,50,129,208,123,176,179,130,124,49,178,172,234,13,56,184,149,131,88,17,97,44,237,235,88,91,221,179,171,141,253,225,105,113,120,197,8,32,189,234,144,55,187,63,176,42,214,193,95,196,29,126,145,46,247,185,150,249,138,195,163,112,152,204,247,74,156,91,90,196,249,227,177,108,190,60,187,22,175,86,169,180,142,125,79,140,203,11,143,180,155,196,166,108,41,78,200,57,58,171,172,202,108,103,224,41,87,251,214,65,206,66,87,120,86,168,160,191,173,43,25,211,142,119,190,163,3,77,117,174,90,97,39,211,90,67,81,121,77,111,157,170,25,142,39,253,51,174,40,244,121,214,162,65,31,101,193,252,95,243,9,198,38,140,202,19,139,25,75,7,95,175,66,50,95,124,45,175,117,254,241,248,183,178,120,251,77,34,183,134,15,28,50,227,135,253,131,0,83,252,68,138,159,175,144,34,11,9,182,173,92,8,125,80,67,206,203,30,101,178,245,121,1,24,79,212,191,137,22,152,57,154,103,64,136,161,154,224,103,112,22,243,203,152,78,54,242,4,160,216,63,141,102,138,79,223,231,2,252,116,12,127,148,141,15,224,231,237,165,137,171,191,102,130,95,93,25,69,113,188,152,243,215,249,40,205,135,246,23,122,226,120,194,95,205,152,82,26,23,188,66,122,155,219,113,129,179,192,59,70,160,245,123,76,53,87,115,93,146,242,39,122,148,88,129,130,71,254,205,58,211,137,200,248,74,83,40,235,72,12,253,80,226,145,77,230,115,161,42,167,172,57,203,193,58,199,97,189,19,28,64,239,250,41,30,77,105,30,8,220,155,139,212,246,68,158,69,32,69,104,157,26,50,4,199,27,19,217,232,211,11,124,90,136,83,201,56,112,4,51,31,99,77,92,241,14,217,33,89,24,42,33,54,55,230,59,121,76,88,250,236,135,36,255,119,78,223,195,192,6,137,136,244,173,237,7,56,220,25,187,242,23,76,252,208,250,166,73,118,194,232,136,62,85,163,83,16,5,157,250,80,51,186,64,58,226,16,2,135,62,230,77,77,126,58,125,105,65,125,66,116,17,244,61,61,49,186,25,5,202,170,207,31,149,161,86,244,103,70,160,97,196,80,210,103,231,161,174,62,69,48,10,9,120,139,5,10,114,189,109,6,217,17,156,246,137,7,205,37,32,202,85,56,226,233,207,73,221,50,78,73,104,183,112,222,23,167,163,157,124,86,90,95,178,138,180,61,97,91,180,70,77,204,99,159,78,33,233,191,164,195,84,68,65,181,80,94,156,142,26,241,117,146,222,176,236,163,160,116,55,140,66,85,93,254,95,51,113,26,83,241,48,105,12,84,161,190,250,62,20,140,166,3,201,70,83,39,99,149,90,53,185,22,6,90,63,216,154,178,34,27,39,192,95,247,10,236,146,43,178,66,116,31,162,88,183,194,35,119,168,99,59,77,232,143,173,161,214,85,76,72,145,242,153,58,230,189,105,76,118,131,222,194,151,27,232,182,141,80,62,104,170,76,237,226,129,60,14,53,248,150,158,85,219,51,145,103,142,176,222,221,146,201,157,97,150,203,154,170,174,181,102,231,86,49,85,225,10,157,21,62,228,41,235,131,95,251,200,242,92,188,34,250,173,215,84,246,85,219,129,76,87,52,247,62,162,108,50,221,222,60,86,119,181,167,135,134,190,38,194,137,73,114,69,117,60,68,162,199,7,58,89,239,227,70,5,60,201,201,233,255,217,7,253,3,140,198,202,170,152,171,89,219,42,18,185,120,106,84,174,80,139,201,106,247,27,188,255,219,147,188,62,39,238,143,55,217,57,201,79,60,18,31,181,49,245,173,254,213,254,244,113,54,239,248,99,214,165,166,173,6,31,247,191,197,44,93,173,43,203,13,23,57,115,37,64,53,40,62,46,219,4,57,88,255,178,9,126,156,77,240,67,77,129,223,157,172,168,203,83,254,227,73,10,206,5,207,210,175,239,151,14,121,16,158,73,83,90,35,189,243,3,15,196,68,51,97,157,65,83,10,23,163,239,7,147,144,39,181,140,239,88,252,251,206,14,200,11,45,156,212,14,108,129,142,41,126,201,80,240,166,171,186,37,181,127,113,231,191,184,243,95,220,121,47,119,182,117,232,27,205,205,254,255,0,96,61,96,105,204,167,0,0}; \ No newline at end of file diff --git a/docs/gh-pages/index.html b/docs/gh-pages/index.html new file mode 100644 index 00000000..9d83ddfc --- /dev/null +++ b/docs/gh-pages/index.html @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + +

MiLight Hub REST API Documentation

+ +
+ +
+ Loading... + + \ No newline at end of file diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 00000000..f4143399 --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,1041 @@ +openapi: 3.0.1 +info: + title: ESP8266 MiLight Hub + description: Official documention for MiLight Hub's REST API. + contact: + email: chris@sidoh.org + license: + name: MIT + version: 1.0.0 +servers: + - url: http://milight-hub +tags: + - name: System + description: > + Routes that return system information and allow you to control the device. + - name: Settings + description: Read and write settings + - name: Device Control + description: Control lighting devices + - name: Device Control by Alias + description: Control lighting devices using aliases rather than raw IDs + - name: Raw Packet Handling + description: Read and write raw Milight packets + - name: Transitions + description: Control transitions +x-tagGroups: + - name: Admin + tags: + - System + - Settings + - name: Devices + tags: + - Device Control + - Device Control by Alias + - Raw Packet Handling + - name: Transitions + tags: + - Transitions + +paths: + /about: + get: + tags: + - System + summary: Get system information + responses: + 200: + description: success + content: + application/json: + schema: + $ref: '#/components/schemas/About' + /remote_configs: + get: + tags: + - System + summary: List supported remote types + responses: + 200: + description: success + content: + applicaiton/json: + schema: + type: array + items: + type: string + example: + $ref: '#/components/schemas/RemoteType/enum' + /system: + post: + tags: + - System + summary: Send a system command + description: > + Send commands to the system. Supported commands: + + 1. `restart`. Restart the ESP8266. + + 1. `clear_wifi_config`. Clears on-board wifi information. ESP8266 will reboot and enter wifi config mode. + + requestBody: + content: + application/json: + schema: + type: object + required: + - command + properties: + command: + type: string + enum: + - restart + - clear_wifi_config + responses: + 200: + description: command handled successfully + content: + application/json: + schema: + $ref: '#/components/schemas/BooleanResponse' + 400: + description: error + content: + application/json: + schema: + $ref: '#/components/schemas/BooleanResponse' + /settings: + get: + tags: + - Settings + summary: Get existing settings + responses: + 200: + description: success + content: + application/json: + schema: + $ref: '#/components/schemas/Settings' + put: + tags: + - Settings + summary: Patch existing settings + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Settings' + responses: + 200: + description: success + content: + application/json: + schema: + $ref: '#/components/schemas/BooleanResponse' + post: + tags: + - Settings + summary: Overwrite existing settings with a file + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Settings' + responses: + 200: + description: success + content: + application/json: + schema: + $ref: '#/components/schemas/BooleanResponse' + + /gateway_traffic/{remote-type}: + get: + tags: + - Raw Packet Handling + summary: Read a packet from a specific remote + description: + Read a packet from the given remote type. Does not return a response until a packet is read. + If `remote-type` is unspecified, will read from all remote types simultaneously. + parameters: + - $ref: '#/components/parameters/RemoteType' + responses: + 200: + description: success + content: + application/json: + schema: + $ref: '#/components/schemas/ReadPacket' + /gateway_traffic: + get: + tags: + - Raw Packet Handling + summary: Read a packet from any remote + description: + Read a packet from any remote type. Does not return a response until a packet is read. + responses: + 200: + description: success + content: + application/json: + schema: + $ref: '#/components/schemas/ReadPacket' + + /gateways/{device-id}/{remote-type}/{group-id}: + parameters: + - $ref: '#/components/parameters/DeviceId' + - $ref: '#/components/parameters/RemoteType' + - $ref: '#/components/parameters/GroupId' + get: + tags: + - Device Control + summary: + Get device state + description: + If `blockOnQueue` is provided, a response will not be returned until any unprocessed + packets in the command queue are finished sending. + parameters: + - $ref: '#/components/parameters/BlockOnQueue' + responses: + 200: + description: success + content: + application/json: + schema: + $ref: '#/components/schemas/GroupState' + put: + tags: + - Device Control + summary: + Patch device state + description: + Update state of the bulbs with the provided parameters. Existing parameters will be + unchanged. + + if `blockOnQueue` is set to true, the response will not return until packets corresponding + to the commands sent are processed, and the updated `GroupState` will be returned. If + `blockOnQueue` is false or not provided, a simple response indicating success will be + returned. + parameters: + - $ref: '#/components/parameters/BlockOnQueue' + requestBody: + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/GroupState' + - $ref: '#/components/schemas/GroupStateCommands' + responses: + 400: + description: error with request + content: + application/json: + schema: + $ref: '#/components/schemas/BooleanResponse' + 200: + description: > + Success. + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/BooleanResponse' + - $ref: '#/components/schemas/GroupState' + delete: + tags: + - Device Control + summary: + Delete kept state + description: + Usets all known values for state fields for the corresponding device. If MQTT is + configured, the retained state message corresponding to this device will also be + deleted. + responses: + 200: + description: success + content: + application/json: + schema: + $ref: '#/components/schemas/BooleanResponse' + /gateways/{device-alias}: + parameters: + - $ref: '#/components/parameters/DeviceAlias' + get: + tags: + - Device Control by Alias + summary: Get device state by alias + responses: + 404: + description: provided device alias does not exist + 200: + description: success + content: + application/json: + schema: + $ref: '#/components/schemas/GroupState' + put: + tags: + - Device Control by Alias + summary: Patch device state by alias + parameters: + - $ref: '#/components/parameters/BlockOnQueue' + requestBody: + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/GroupState' + - $ref: '#/components/schemas/GroupStateCommands' + responses: + 400: + description: error with request + content: + application/json: + schema: + $ref: '#/components/schemas/BooleanResponse' + 200: + description: success + content: + application/json: + schema: + $ref: '#/components/schemas/GroupState' + delete: + tags: + - Device Control by Alias + summary: Delete kept state for alias + description: + Usets all known values for state fields for the corresponding device. If MQTT is + configured, the retained state message corresponding to this device will also be + deleted. + responses: + 200: + description: success + content: + application/json: + schema: + $ref: '#/components/schemas/BooleanResponse' + + /raw_commands/{remote-type}: + parameters: + - $ref: '#/components/parameters/RemoteType' + post: + tags: + - Raw Packet Handling + summary: Send a raw packet + requestBody: + content: + application/json: + schema: + type: object + properties: + packet: + type: string + pattern: "([A-Fa-f0-9]{2}[ ])+" + description: Raw packet to send + example: '01 02 03 04 05 06 07 08 09' + num_repeats: + type: integer + minimum: 1 + description: Number of repeated packets to send + example: 50 + responses: + 200: + description: success + content: + applicaiton/json: + schema: + $ref: '#/components/schemas/BooleanResponse' + + + /transitions: + get: + tags: + - Transitions + summary: List all active transitions + responses: + 200: + description: success + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TransitionData' + post: + tags: + - Transitions + summary: Create a new transition + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TransitionData' + responses: + 400: + description: error + content: + application/json: + schema: + $ref: '#/components/schemas/BooleanResponse' + 200: + description: success + content: + application/json: + schema: + $ref: '#/components/schemas/BooleanResponse' + /transitions/{id}: + parameters: + - name: id + in: path + description: ID of transition. This will be an auto-incrementing number reset after a restart. + schema: + type: integer + required: true + get: + tags: + - Transitions + summary: Get properties for a transition + responses: + 404: + description: Provided transition ID not found + 200: + description: success + content: + application/json: + schema: + $ref: '#/components/schemas/TransitionData' + delete: + tags: + - Transitions + summary: Delete a transition + responses: + 404: + description: Provided transition ID not found + content: + application/json: + schema: + $ref: '#/components/schemas/BooleanResponse' + 200: + description: success + content: + application/json: + schema: + $ref: '#/components/schemas/BooleanResponse' + /firmware: + post: + tags: + - System + summary: + Update firmware + requestBody: + description: Firmware file + content: + multipart/form-data: + schema: + type: object + properties: + fileName: + type: string + format: binary + responses: + 200: + description: success + 500: + description: server error +components: + parameters: + DeviceAlias: + name: device-alias + in: path + description: Device alias saved in settings + schema: + type: string + required: true + BlockOnQueue: + name: blockOnQueue + in: query + description: If true, response will block on update packets being sent before returning + schema: + type: boolean + required: false + GroupId: + name: group-id + in: path + description: > + Group ID. Should be 0-8, depending on remote type. Group 0 is a "wildcard" group. All bulbs paired with the same device ID will respond to commands sent to Group 0. + schema: + type: integer + minimum: 0 + maximum: 8 + required: true + DeviceId: + name: device-id + in: path + description: 2-byte device ID. Can be decimal or hexadecimal. + schema: + oneOf: + - type: integer + minimum: 0 + maximum: 65535 + - type: string + pattern: '0x[a-fA-F0-9]+' + example: '0x1234' + required: true + RemoteType: + name: remote-type + in: path + description: Type of remote to read a packet from. If unspecified, will read packets from all remote types. + schema: + $ref: '#/components/schemas/RemoteType' + required: true + schemas: + State: + description: "On/Off state" + type: string + enum: + - On + - Off + example: On + GroupStateCommand: + type: string + enum: + - unpair + - pair + - set_white + - night_mode + - level_up + - level_down + - temperature_up + - temperature_down + - next_mode + - previous_mode + - mode_speed_down + - mode_speed_up + - toggle + example: pair + description: > + Commands that affect a given group. Descriptiosn follow: + + * `pair`. Emulates the pairing process. Send this command right as you connect an unpaired bulb and it will pair with the device ID being used. + + * `unpair`. Emulates the unpairing process. Send as you connect a paired bulb to have it disassociate with the device ID being used. + + * `set_white`. Turns off RGB and enters WW/CW mode. + + * `night_mode`. Most devices support a "night mode," which has LEDs turned to a very dim setting -- lower than brightness 0. + + * `level_up`. Turns down the brightness. Not all dimmable bulbs support this command. + + * `level_down`. Turns down the brightness. Not all dimmable bulbs support this command. + + * `temperature_up`. Turns up the white temperature. Not all bulbs with adjustable white temperature support this command. + + * `temperature_down`. Turns down the white temperature. Not all bulbs with adjustable white temperature support this command. + + * `next_mode`. Cycles to the next "disco mode". + + * `previous_mode`. Cycles to the previous disco mode. + + * `mode_speed_up`. Turn transition speed for current mode up. + + * `mode_speed_down`. Turn transition speed for current mode down. + + * `toggle`. Toggle on/off state. + + TransitionField: + type: string + enum: + - hue + - saturation + - brightness + - level + - kelvin + - color_temp + - color + - status + example: brightness + description: > + If transitioning `status`: + + * If transitioning to `OFF`, will fade to 0 brightness and then turn off. + + * If transitioning to `ON`, will turn on, set brightness to 0, and fade to brightness 100. + TransitionValue: + oneOf: + - type: integer + - type: string + pattern: '[0-9]{1,3},[0-9]{1,3},[0-9]{1,3}' + description: Either an int value or a color + TransitionArgs: + type: object + properties: + field: + $ref: '#/components/schemas/TransitionField' + start_value: + $ref: '#/components/schemas/TransitionValue' + end_value: + $ref: '#/components/schemas/TransitionValue' + duration: + type: number + format: float + description: Duration of transition, measured in seconds + period: + type: integer + description: Length of time between updates in a transition, measured in milliseconds + num_periods: + type: integer + description: Number of packets sent over the course of the transition + TransitionData: + allOf: + - $ref: '#/components/schemas/TransitionArgs' + - type: object + properties: + id: + type: integer + last_sent: + type: integer + description: Timestamp since last update was sent. + bulb: + $ref: '#/components/schemas/BulbId' + type: + readOnly: true + description: > + Specifies whether this is a simple field transition, or a color transition. + type: string + enum: + - field + - color + current_value: + $ref: '#/components/schemas/TransitionValue' + end_value: + $ref: '#/components/schemas/TransitionValue' + + BulbId: + type: object + properties: + device_id: + type: integer + minimum: 0 + maximum: 65536 + example: 1234 + group_id: + type: integer + minimum: 0 + maximum: 8 + example: 1 + device_type: + $ref: '#/components/schemas/RemoteType' + GroupStateCommands: + type: object + properties: + command: + oneOf: + - $ref: '#/components/schemas/GroupStateCommand' + - type: object + properties: + command: + type: string + enum: + - transition + args: + $ref: '#/components/schemas/TransitionArgs' + commands: + type: array + items: + $ref: '#/components/schemas/GroupStateCommand' + example: + - level_up + - temperature_up + GroupState: + type: object + description: Group state + properties: + state: + $ref: '#/components/schemas/State' + status: + $ref: '#/components/schemas/State' + hue: + type: integer + minimum: 0 + maximum: 359 + description: Color hue. Will change bulb to color mode. + saturation: + type: integer + minimum: 0 + maximum: 100 + description: Color saturation. Will normally change bulb to color mode. + kelvin: + type: integer + minimum: 0 + maximum: 100 + description: White temperature. 0 is coolest, 100 is warmest. + temperature: + type: integer + minimum: 0 + maximum: 100 + description: Alias for `kelvin`. + color_temp: + type: integer + minimum: 153 + maximum: 370 + description: White temperature measured in mireds. Lower values are cooler. + mode: + type: integer + description: Party mode ID. Actual effect depends on the bulb. + color: + oneOf: + - type: string + pattern: '[0-9]{1,3},[0-9]{1,3},[0-9]{1,3}' + example: '255,255,0' + - type: object + properties: + r: + type: integer + g: + type: integer + b: + type: integer + example: + r: 255 + g: 255 + b: 0 + example: + '255,0,255' + level: + type: integer + minimum: 0 + maximum: 100 + description: Brightness on a 0-100 scale. + example: 50 + brightness: + type: integer + minimum: 0 + maximum: 255 + description: Brightness on a 0-255 scale. + example: 170 + effect: + type: string + enum: + - night_mode + - white_mode + transition: + type: number + description: > + Enables a transition from current state to the provided state. + example: 2.0 + + RemoteType: + type: string + enum: + - "rgbw" + - "cct" + - "rgb_cct" + - "rgb" + - "fut089" + - "fut091" + - "fut020" + example: rgb_cct + RF24Channel: + type: string + enum: + - LOW + - MID + - HIGH + LedMode: + type: string + enum: + - Off + - Slow toggle + - Fast toggle + - Slow blip + - Fast blip + - Flicker + - On + GroupStateField: + type: string + enum: + - state + - status + - brightness + - level + - hue + - saturation + - color + - mode + - kelvin + - color_temp + - bulb_mode + - computed_color + - effect + - device_id + - group_id + - device_type + - oh_color + - hex_color + description: > + Defines a field which is a part of state for a particular light device. Most fields are self-explanatory, but documentation for each follows: + + * `state` / `status` - same value with different keys (useful if your platform expects one or the other). + + * `brightness` / `level` - [0, 255] and [0, 100] scales of the same value. + + * `kelvin / color_temp` - [0, 100] and [153, 370] scales for the same value. The later's unit is mireds. + + * `bulb_mode` - what mode the bulb is in: white, rgb, etc. + + * `color` / `computed_color` - behaves the same when bulb is in rgb mode. `computed_color` will send RGB = 255,255,255 when in white mode. This is useful for HomeAssistant where it always expects the color to be set. + + * `oh_color` - same as `color` with a format compatible with [OpenHAB's colorRGB channel type](https://www.openhab.org/addons/bindings/mqtt.generic/#channel-type-colorrgb-colorhsb). + + * `hex_color` - same as `color` except in hex color (e.g., `#FF0000` for red). + + * `device_id` / `device_type` / `group_id` - this information is in the MQTT topic or REST route, but can be included in the payload in the case that processing the topic or route is more difficult. + DeviceId: + type: array + items: {} + example: + - 1234 + - "rgb_cct" + - 1 + Settings: + type: object + properties: + admin_username: + type: string + description: If spcified along with `admin_password`, HTTP basic auth will be enabled to access all REST endpoints. + default: "" + admin_password: + type: string + description: If spcified along with `admin_username`, HTTP basic auth will be enabled to access all REST endpoints. + default: "" + ce_pin: + type: integer + description: CE pin to use for SPI radio (nRF24, LT8900) + default: 4 + csn_pin: + type: integer + description: CSN pin to use with nRF24 + default: 15 + reset_pin: + type: integer + description: Reset pin to use with LT8900 + default: 0 + led_pin: + type: integer + description: Pin to control for status LED. Set to a negative value to invert on/off status. + default: -2 + packet_repeats: + type: integer + description: Number of times to resend the same 2.4 GHz milight packet when a command is sent. + default: 50 + http_repeat_factor: + type: integer + description: Packet repeats resulting from REST commands will be multiplied by this number. + default: 1 + auto_restart_period: + type: integer + description: Automatically restart the device after the number of specified minutes. Use 0 to disable. + default: 0 + mqtt_server: + type: string + description: MQTT server to connect to. + format: hostname + mqtt_username: + type: string + description: If specified, use this username to authenticate with the MQTT server. + mqtt_password: + type: string + description: If specified, use this password to authenticate with the MQTT server. + mqtt_topic_pattern: + type: string + description: Topic pattern to listen on for commands. More detail on the format in README. + example: milight/commands/:device_id/:device_type/:group_id + mqtt_update_topic_pattern: + type: string + description: Topic pattern individual intercepted commands will be sent to. More detail on the format in README. + example: milight/updates/:device_id/:device_type/:group_id + mqtt_update_state_pattern: + type: string + description: Topic pattern device state will be sent to. More detail on the format in README. + example: milight/state/:device_id/:device_type/:group_id + mqtt_client_status_topic: + type: string + description: Topic client status will be sent to. + example: milight/status + simple_mqtt_client_status: + type: boolean + description: If true, will use a simple enum flag (`connected` or `disconnected`) to indicate status. If false, will send a rich JSON message including IP address, version, etc. + default: true + discovery_port: + type: integer + description: UDP discovery port + default: 48899 + listen_repeats: + type: integer + description: Controls how many cycles are spent listening for packets. Set to 0 to disable passive listening. + default: 3 + state_flush_interval: + type: integer + description: Controls how many miliseconds must pass between states being flushed to persistent storage. Set to 0 to disable throttling. + default: 10000 + mqtt_state_rate_limit: + type: integer + description: Controls how many miliseconds must pass between MQTT state updates. Set to 0 to disable throttling. + default: 500 + packet_repeat_throttle_threshold: + type: integer + description: + Controls how packet repeats are throttled. Packets sent with less time (measured in milliseconds) between them than this value (in milliseconds) will cause packet repeats to be throttled down. More than this value will unthrottle up. + default: 200 + packet_repeat_throttle_sensitivity: + type: integer + description: + Controls how packet repeats are throttled. Higher values cause packets to be throttled up and down faster. Set to 0 to disable throttling. + default: 0 + minimum: 0 + maximum: 1000 + packet_repeat_minimum: + type: integer + description: + Controls how far throttling can decrease the number of repeated packets + default: 3 + enable_automatic_mode_switching: + type: boolean + description: + When making updates to hue or white temperature in a different bulb mode, switch back to the original bulb mode after applying the setting change. + default: false + led_mode_wifi_config: + $ref: '#/components/schemas/LedMode' + led_mode_wifi_failed: + $ref: '#/components/schemas/LedMode' + led_mode_operating: + $ref: '#/components/schemas/LedMode' + led_mode_packet: + $ref: '#/components/schemas/LedMode' + led_mode_packet_count: + type: integer + description: Number of times the LED will flash when packets are changing + default: 3 + hostname: + type: string + description: Hostname that will be advertized on a DHCP request + pattern: "[a-zA-Z0-9-]+" + default: milight-hub + rf24_power_level: + type: string + enum: + - MIN + - LOW + - HIGH + - MAX + description: Power level used when packets are sent. See nRF24 documentation for further detail. + default: MAX + rf24_listen_channel: + $ref: '#/components/schemas/RF24Channel' + wifi_static_ip: + type: string + format: ipv4 + description: If specified, the static IP address to use + wifi_static_ip_gateway: + type: string + format: ipv4 + description: If specified along with static IP, the gateway address to use + wifi_static_ip_netmask: + type: string + format: ipv4 + description: If specified along with static IP, the netmask to use + packet_repeats_per_loop: + type: integer + default: 10 + description: Packets are sent asynchronously. This number controls the number of repeats sent during each iteration. Increase this number to improve packet throughput. Decrease to improve system multi-tasking. + home_assistant_discovery_prefix: + type: string + description: If specified along with MQTT settings, will enable HomeAssistant MQTT discovery using the specified discovery prefix. HomeAssistant's default is `homeassistant/`. + wifi_mode: + type: string + enum: + - B + - G + - N + description: Forces WiFi into the spcified mode. Try using B or G mode if you are having stability issues. + default: N + rf24_channels: + type: array + items: + $ref: '#/components/schemas/RF24Channel' + description: Defines which channels we send on. Each remote type has three channels. We can send on any subset of these. + device_ids: + type: array + items: + $ref: '#/components/schemas/DeviceId' + description: + "List of saved device IDs, stored as 3-long arrays. Elements are: 1) remote ID, 2) remote type, 3) group ID" + example: + - [1234, 'rgb_cct', 1] + - [5678, 'fut089', 5] + gateway_configs: + type: array + items: + type: integer + description: "List of UDP servers, stored as 3-long arrays. Elements are 1) remote ID to bind to, 2) UDP port to listen on, 3) protocol version (5 or 6)" + example: + - [1234, 5555, 6] + group_state_fields: + type: array + items: + $ref: '#/components/schemas/GroupStateField' + group_id_aliases: + type: object + description: Keys are aliases, values are 3-long arrays with same schema as items in `device_ids`. + example: + alias1: [1234, 'rgb_cct', 1] + alias2: [1234, 'rgb_cct', 2] + + BooleanResponse: + type: object + required: + - success + properties: + success: + type: boolean + error: + type: string + description: If an error occurred, message specifying what went wrong + About: + type: object + properties: + firmware: + type: string + description: Always set to "milight-hub" + version: + type: string + description: Semver version string + ip_address: + type: string + reset_reason: + type: string + description: Reason the system was last rebooted + variant: + type: string + description: Firmware variant (e.g., d1_mini, nodemcuv2) + free_heap: + type: integer + format: int64 + description: Amount of free heap remaining (measured in bytes) + arduino_version: + type: string + description: Version of Arduino SDK firmware was built with + queue_stats: + type: object + properties: + length: + type: integer + description: Number of enqueued packets to be sent + dropped_packets: + type: integer + description: Number of packets that have been dropped since last reboot + ReadPacket: + type: object + properties: + packet_info: + type: string \ No newline at end of file diff --git a/lib/DataStructures/LinkedList.h b/lib/DataStructures/LinkedList.h index af11a95d..aebbaed4 100644 --- a/lib/DataStructures/LinkedList.h +++ b/lib/DataStructures/LinkedList.h @@ -28,7 +28,7 @@ template class LinkedList { protected: - int _size; + size_t _size; ListNode *root; ListNode *last; @@ -39,7 +39,7 @@ class LinkedList { /* Returns current size of LinkedList */ - virtual int size() const; + virtual size_t size() const; /* Adds a T object in the specified index; Unlink and link the LinkedList correcly; @@ -67,6 +67,7 @@ class LinkedList { else, decrement _size */ virtual T remove(int index); + virtual void remove(ListNode* node); /* Remove last object; */ @@ -159,7 +160,7 @@ ListNode* LinkedList::getNode(int index){ } template -int LinkedList::size() const{ +size_t LinkedList::size() const{ return _size; } @@ -193,6 +194,7 @@ bool LinkedList::add(T _t){ if(root){ // Already have elements inserted last->next = tmp; + tmp->prev = last; last = tmp; }else{ // First element being inserted @@ -276,6 +278,24 @@ T LinkedList::shift(){ } +template +void LinkedList::remove(ListNode* node){ + if (node == root) { + shift(); + } else if (node == last) { + pop(); + } else { + ListNode* prev = node->prev; + ListNode* next = node->next; + + prev->next = next; + next->prev = prev; + + delete node; + --_size; + } +} + template T LinkedList::remove(int index){ if (index < 0 || index >= _size) diff --git a/lib/Helpers/IntParsing.h b/lib/Helpers/IntParsing.h index ab9778ca..bcc10ec2 100644 --- a/lib/Helpers/IntParsing.h +++ b/lib/Helpers/IntParsing.h @@ -60,7 +60,7 @@ class IntParsing { static void bytesToHexStr(const uint8_t* bytes, const size_t len, char* buffer, size_t maxLen) { char* p = buffer; - for (size_t i = 0; i < len && (p - buffer) < (maxLen - 3); i++) { + for (size_t i = 0; i < len && static_cast(p - buffer) < (maxLen - 3); i++) { p += sprintf(p, "%02X", bytes[i]); if (i < (len - 1)) { diff --git a/lib/Helpers/JsonHelpers.h b/lib/Helpers/JsonHelpers.h new file mode 100644 index 00000000..47ee6a7b --- /dev/null +++ b/lib/Helpers/JsonHelpers.h @@ -0,0 +1,51 @@ +#include +#include +#include +#include + +#ifndef _JSON_HELPERS_H +#define _JSON_HELPERS_H + +class JsonHelpers { +public: + template + static void copyFrom(JsonArray arr, std::vector vec) { + for (typename std::vector::const_iterator it = vec.begin(); it != vec.end(); ++it) { + arr.add(*it); + } + } + + template + static void copyTo(JsonArray arr, std::vector vec) { + for (size_t i = 0; i < arr.size(); ++i) { + JsonVariant val = arr[i]; + vec.push_back(val.as()); + } + } + + template + static std::vector jsonArrToVector(JsonArray& arr, std::function converter, const bool unique = true) { + std::vector vec; + + for (size_t i = 0; i < arr.size(); ++i) { + StrType strVal = arr[i]; + T convertedVal = converter(strVal); + + // inefficient, but everything using this is tiny, so doesn't matter + if (!unique || std::find(vec.begin(), vec.end(), convertedVal) == vec.end()) { + vec.push_back(convertedVal); + } + } + + return vec; + } + + template + static void vectorToJsonArr(JsonArray& arr, const std::vector& vec, std::function converter) { + for (typename std::vector::const_iterator it = vec.begin(); it != vec.end(); ++it) { + arr.add(converter(*it)); + } + } +}; + +#endif \ No newline at end of file diff --git a/lib/Helpers/TokenIterator.cpp b/lib/Helpers/TokenIterator.cpp deleted file mode 100644 index 5bb59896..00000000 --- a/lib/Helpers/TokenIterator.cpp +++ /dev/null @@ -1,51 +0,0 @@ -#include - -TokenIterator::TokenIterator(char* data, size_t length, const char sep) - : data(data), - current(data), - length(length), - sep(sep), - i(0) -{ - for (size_t i = 0; i < length; i++) { - if (data[i] == sep) { - data[i] = 0; - } - } -} - -const char* TokenIterator::nextToken() { - if (i >= length) { - return NULL; - } - - char* token = current; - char* nextToken = current; - - for (; i < length && *nextToken != 0; i++, nextToken++); - - if (i == length) { - nextToken = NULL; - } else { - i = (nextToken - data); - - if (i < length) { - nextToken++; - } else { - nextToken = NULL; - } - } - - current = nextToken; - - return token; -} - -void TokenIterator::reset() { - current = data; - i = 0; -} - -bool TokenIterator::hasNext() { - return i < length; -} diff --git a/lib/Helpers/TokenIterator.h b/lib/Helpers/TokenIterator.h deleted file mode 100644 index ed824db5..00000000 --- a/lib/Helpers/TokenIterator.h +++ /dev/null @@ -1,21 +0,0 @@ -#include - -#ifndef _TOKEN_ITERATOR_H -#define _TOKEN_ITERATOR_H - -class TokenIterator { -public: - TokenIterator(char* data, size_t length, char sep = ','); - - bool hasNext(); - const char* nextToken(); - void reset(); - -private: - char* data; - char* current; - size_t length; - char sep; - int i; -}; -#endif diff --git a/lib/Helpers/UrlTokenBindings.cpp b/lib/Helpers/UrlTokenBindings.cpp deleted file mode 100644 index 69b1b6d8..00000000 --- a/lib/Helpers/UrlTokenBindings.cpp +++ /dev/null @@ -1,35 +0,0 @@ -#include - -UrlTokenBindings::UrlTokenBindings(TokenIterator& patternTokens, TokenIterator& requestTokens) - : patternTokens(patternTokens), - requestTokens(requestTokens) -{ } - -bool UrlTokenBindings::hasBinding(const char* searchToken) const { - patternTokens.reset(); - while (patternTokens.hasNext()) { - const char* token = patternTokens.nextToken(); - - if (token[0] == ':' && strcmp(token+1, searchToken) == 0) { - return true; - } - } - - return false; -} - -const char* UrlTokenBindings::get(const char* searchToken) const { - patternTokens.reset(); - requestTokens.reset(); - - while (patternTokens.hasNext() && requestTokens.hasNext()) { - const char* token = patternTokens.nextToken(); - const char* binding = requestTokens.nextToken(); - - if (token[0] == ':' && strcmp(token+1, searchToken) == 0) { - return binding; - } - } - - return NULL; -} diff --git a/lib/Helpers/UrlTokenBindings.h b/lib/Helpers/UrlTokenBindings.h deleted file mode 100644 index 9d23f6d5..00000000 --- a/lib/Helpers/UrlTokenBindings.h +++ /dev/null @@ -1,18 +0,0 @@ -#include - -#ifndef _URL_TOKEN_BINDINGS_H -#define _URL_TOKEN_BINDINGS_H - -class UrlTokenBindings { -public: - UrlTokenBindings(TokenIterator& patternTokens, TokenIterator& requestTokens); - - bool hasBinding(const char* key) const; - const char* get(const char* key) const; - -private: - TokenIterator& patternTokens; - TokenIterator& requestTokens; -}; - -#endif diff --git a/lib/LEDStatus/LEDStatus.cpp b/lib/LEDStatus/LEDStatus.cpp index 8a389092..3cd09bb9 100644 --- a/lib/LEDStatus/LEDStatus.cpp +++ b/lib/LEDStatus/LEDStatus.cpp @@ -1,4 +1,4 @@ -#include "LEDStatus.h" +#include // constructor defines which pin the LED is attached to LEDStatus::LEDStatus(int8_t ledPin) { @@ -80,7 +80,7 @@ void LEDStatus::oneshot(uint16_t ledOffMs, uint16_t ledOnMs, uint8_t count) { _timer = millis(); } -// call this function in your loop - it will return quickly after calculating if any changes need to +// call this function in your loop - it will return quickly after calculating if any changes need to // be made to the pin to flash the LED void LEDStatus::LEDStatus::handle() { // is a pin defined? @@ -109,7 +109,7 @@ void LEDStatus::LEDStatus::handle() { } _oneshotCurrentlyOn = true; _timer += _oneshotOffMs; - } + } } } else { // operate using continuous diff --git a/lib/MQTT/BulbStateUpdater.cpp b/lib/MQTT/BulbStateUpdater.cpp index dc52141f..96c7bab3 100644 --- a/lib/MQTT/BulbStateUpdater.cpp +++ b/lib/MQTT/BulbStateUpdater.cpp @@ -39,10 +39,11 @@ void BulbStateUpdater::loop() { inline void BulbStateUpdater::flushGroup(BulbId bulbId, GroupState& state) { char buffer[200]; - StaticJsonBuffer<200> jsonBuffer; - JsonObject& message = jsonBuffer.createObject(); - state.applyState(message, bulbId, settings.groupStateFields, settings.numGroupStateFields); - message.printTo(buffer); + StaticJsonDocument<200> json; + JsonObject message = json.to(); + + state.applyState(message, bulbId, settings.groupStateFields); + serializeJson(json, buffer); mqttClient.sendState( *MiLightRemoteConfig::fromType(bulbId.deviceType), diff --git a/lib/MQTT/HomeAssistantDiscoveryClient.cpp b/lib/MQTT/HomeAssistantDiscoveryClient.cpp new file mode 100644 index 00000000..b7445523 --- /dev/null +++ b/lib/MQTT/HomeAssistantDiscoveryClient.cpp @@ -0,0 +1,173 @@ +#include +#include + +HomeAssistantDiscoveryClient::HomeAssistantDiscoveryClient(Settings& settings, MqttClient* mqttClient) + : settings(settings) + , mqttClient(mqttClient) +{ } + +void HomeAssistantDiscoveryClient::sendDiscoverableDevices(const std::map& aliases) { +#ifdef MQTT_DEBUG + Serial.println(F("HomeAssistantDiscoveryClient: Sending discoverable devices...")); +#endif + + for (auto itr = aliases.begin(); itr != aliases.end(); ++itr) { + addConfig(itr->first.c_str(), itr->second); + } +} + +void HomeAssistantDiscoveryClient::removeOldDevices(const std::map& aliases) { +#ifdef MQTT_DEBUG + Serial.println(F("HomeAssistantDiscoveryClient: Removing discoverable devices...")); +#endif + + for (auto itr = aliases.begin(); itr != aliases.end(); ++itr) { + removeConfig(itr->second); + } +} + +void HomeAssistantDiscoveryClient::removeConfig(const BulbId& bulbId) { + // Remove by publishing an empty message + String topic = buildTopic(bulbId); + mqttClient->send(topic.c_str(), "", true); +} + +void HomeAssistantDiscoveryClient::addConfig(const char* alias, const BulbId& bulbId) { + String topic = buildTopic(bulbId); + DynamicJsonDocument config(1024); + + config[F("schema")] = F("json"); + config[F("name")] = alias; + config[F("command_topic")] = mqttClient->bindTopicString(settings.mqttTopicPattern, bulbId); + config[F("state_topic")] = mqttClient->bindTopicString(settings.mqttStateTopicPattern, bulbId); + JsonObject deviceMetadata = config.createNestedObject(F("device")); + + deviceMetadata[F("manufacturer")] = F("esp8266_milight_hub"); + deviceMetadata[F("sw_version")] = QUOTE(MILIGHT_HUB_VERSION); + + JsonArray identifiers = deviceMetadata.createNestedArray(F("identifiers")); + identifiers.add(ESP.getChipId()); + bulbId.serialize(identifiers); + + // HomeAssistant only supports simple client availability + if (settings.mqttClientStatusTopic.length() > 0 && settings.simpleMqttClientStatus) { + config[F("availability_topic")] = settings.mqttClientStatusTopic; + config[F("payload_available")] = F("connected"); + config[F("payload_not_available")] = F("disconnected"); + } + + // Configure supported commands based on the bulb type + + // All supported bulbs support brightness and night mode + config[GroupStateFieldNames::BRIGHTNESS] = true; + config[GroupStateFieldNames::EFFECT] = true; + + JsonArray effects = config.createNestedArray(F("effect_list")); + effects.add(MiLightCommandNames::NIGHT_MODE); + + // These bulbs support switching between rgb/white, and have a "white_mode" command + switch (bulbId.deviceType) { + case REMOTE_TYPE_FUT089: + case REMOTE_TYPE_RGB_CCT: + case REMOTE_TYPE_RGBW: + effects.add("white_mode"); + break; + default: + break; //nothing + } + + // All bulbs except CCT have 9 modes. FUT029 and RGB/FUT096 has 9 modes, but they + // are not selectable directly. There are only "next mode" commands. + switch (bulbId.deviceType) { + case REMOTE_TYPE_CCT: + case REMOTE_TYPE_RGB: + case REMOTE_TYPE_FUT020: + break; + default: + addNumberedEffects(effects, 0, 8); + break; + } + + // These bulbs support RGB color + switch (bulbId.deviceType) { + case REMOTE_TYPE_FUT089: + case REMOTE_TYPE_RGB: + case REMOTE_TYPE_RGB_CCT: + case REMOTE_TYPE_RGBW: + config[F("rgb")] = true; + break; + default: + break; //nothing + } + + // These bulbs support adjustable white values + switch (bulbId.deviceType) { + case REMOTE_TYPE_CCT: + case REMOTE_TYPE_FUT089: + case REMOTE_TYPE_FUT091: + case REMOTE_TYPE_RGB_CCT: + config[GroupStateFieldNames::COLOR_TEMP] = true; + break; + default: + break; //nothing + } + + String message; + serializeJson(config, message); + +#ifdef MQTT_DEBUG + Serial.printf_P(PSTR("HomeAssistantDiscoveryClient: adding discoverable device: %s...\n"), alias); + Serial.printf_P(PSTR(" topic: %s\nconfig: %s\n"), topic.c_str(), message.c_str()); +#endif + + + mqttClient->send(topic.c_str(), message.c_str(), true); +} + +// Topic syntax: +// //[/]/config +// +// source: https://www.home-assistant.io/docs/mqtt/discovery/ +String HomeAssistantDiscoveryClient::buildTopic(const BulbId& bulbId) { + String topic = settings.homeAssistantDiscoveryPrefix; + + // Don't require the user to entier a "/" (or break things if they do) + if (! topic.endsWith("/")) { + topic += "/"; + } + + topic += "light/"; + // Use a static ID that doesn't depend on configuration. + topic += "milight_hub_" + String(ESP.getChipId()); + + // make the object ID based on the actual parameters rather than the alias. + topic += "/"; + topic += MiLightRemoteTypeHelpers::remoteTypeToString(bulbId.deviceType); + topic += "_"; + topic += bulbId.getHexDeviceId(); + topic += "_"; + topic += bulbId.groupId; + topic += "/config"; + + return topic; +} + +String HomeAssistantDiscoveryClient::bindTopicVariables(const String& topic, const char* alias, const BulbId& bulbId) { + String boundTopic = topic; + String hexDeviceId = bulbId.getHexDeviceId(); + + boundTopic.replace(":device_alias", alias); + boundTopic.replace(":device_id", hexDeviceId); + boundTopic.replace(":hex_device_id", hexDeviceId); + boundTopic.replace(":dec_device_id", String(bulbId.deviceId)); + boundTopic.replace(":device_type", MiLightRemoteTypeHelpers::remoteTypeToString(bulbId.deviceType)); + boundTopic.replace(":group_id", String(bulbId.groupId)); + + return boundTopic; +} + +void HomeAssistantDiscoveryClient::addNumberedEffects(JsonArray& effectList, uint8_t start, uint8_t end) { + for (uint8_t i = start; i <= end; ++i) { + effectList.add(String(i)); + } +} \ No newline at end of file diff --git a/lib/MQTT/HomeAssistantDiscoveryClient.h b/lib/MQTT/HomeAssistantDiscoveryClient.h new file mode 100644 index 00000000..ee6fc798 --- /dev/null +++ b/lib/MQTT/HomeAssistantDiscoveryClient.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include + +class HomeAssistantDiscoveryClient { +public: + HomeAssistantDiscoveryClient(Settings& settings, MqttClient* mqttClient); + + void addConfig(const char* alias, const BulbId& bulbId); + void removeConfig(const BulbId& bulbId); + + void sendDiscoverableDevices(const std::map& aliases); + void removeOldDevices(const std::map& aliases); + +private: + Settings& settings; + MqttClient* mqttClient; + + String buildTopic(const BulbId& bulbId); + String bindTopicVariables(const String& topic, const char* alias, const BulbId& bulbId); + void addNumberedEffects(JsonArray& effectList, uint8_t start, uint8_t end); +}; \ No newline at end of file diff --git a/lib/MQTT/MqttClient.cpp b/lib/MQTT/MqttClient.cpp index b2020628..2c2e686a 100644 --- a/lib/MQTT/MqttClient.cpp +++ b/lib/MQTT/MqttClient.cpp @@ -6,24 +6,35 @@ #include #include #include +#include + +static const char* STATUS_CONNECTED = "connected"; +static const char* STATUS_DISCONNECTED = "disconnected_clean"; +static const char* STATUS_LWT_DISCONNECTED = "disconnected_unclean"; MqttClient::MqttClient(Settings& settings, MiLightClient*& milightClient) - : milightClient(milightClient), + : mqttClient(tcpClient), + milightClient(milightClient), settings(settings), - lastConnectAttempt(0) + lastConnectAttempt(0), + connected(false) { String strDomain = settings.mqttServer(); this->domain = new char[strDomain.length() + 1]; strcpy(this->domain, strDomain.c_str()); - - this->mqttClient = new PubSubClient(tcpClient); } MqttClient::~MqttClient() { - mqttClient->disconnect(); + String aboutStr = generateConnectionStatusMessage(STATUS_DISCONNECTED); + mqttClient.publish(settings.mqttClientStatusTopic.c_str(), aboutStr.c_str(), true); + mqttClient.disconnect(); delete this->domain; } +void MqttClient::onConnect(OnConnectFn fn) { + this->onConnectFn = fn; +} + void MqttClient::begin() { #ifdef MQTT_DEBUG printf_P( @@ -34,8 +45,8 @@ void MqttClient::begin() { ); #endif - mqttClient->setServer(this->domain, settings.mqttPort()); - mqttClient->setCallback( + mqttClient.setServer(this->domain, settings.mqttPort()); + mqttClient.setCallback( [this](char* topic, byte* payload, int length) { this->publishCallback(topic, payload, length); } @@ -51,14 +62,39 @@ bool MqttClient::connect() { Serial.println(F("MqttClient - connecting")); #endif - if (settings.mqttUsername.length() > 0) { - return mqttClient->connect( + if (settings.mqttUsername.length() > 0 && settings.mqttClientStatusTopic.length() > 0) { + return mqttClient.connect( + nameBuffer, + settings.mqttUsername.c_str(), + settings.mqttPassword.c_str(), + settings.mqttClientStatusTopic.c_str(), + 2, + true, + generateConnectionStatusMessage(STATUS_LWT_DISCONNECTED).c_str() + ); + } else if (settings.mqttUsername.length() > 0) { + return mqttClient.connect( nameBuffer, settings.mqttUsername.c_str(), settings.mqttPassword.c_str() ); + } else if (settings.mqttClientStatusTopic.length() > 0) { + return mqttClient.connect( + nameBuffer, + settings.mqttClientStatusTopic.c_str(), + 2, + true, + generateConnectionStatusMessage(STATUS_LWT_DISCONNECTED).c_str() + ); } else { - return mqttClient->connect(nameBuffer); + return mqttClient.connect(nameBuffer); + } +} + +void MqttClient::sendBirthMessage() { + if (settings.mqttClientStatusTopic.length() > 0) { + String aboutStr = generateConnectionStatusMessage(STATUS_CONNECTED); + mqttClient.publish(settings.mqttClientStatusTopic.c_str(), aboutStr.c_str(), true); } } @@ -67,9 +103,10 @@ void MqttClient::reconnect() { return; } - if (! mqttClient->connected()) { + if (! mqttClient.connected()) { if (connect()) { subscribe(); + sendBirthMessage(); #ifdef MQTT_DEBUG Serial.println(F("MqttClient - Successfully connected to MQTT server")); @@ -84,7 +121,14 @@ void MqttClient::reconnect() { void MqttClient::handleClient() { reconnect(); - mqttClient->loop(); + mqttClient.loop(); + + if (!connected && mqttClient.connected()) { + this->connected = true; + this->onConnectFn(); + } else if (!mqttClient.connected()) { + this->connected = false; + } } void MqttClient::sendUpdate(const MiLightRemoteConfig& remoteConfig, uint16_t deviceId, uint16_t groupId, const char* update) { @@ -99,14 +143,43 @@ void MqttClient::subscribe() { String topic = settings.mqttTopicPattern; topic.replace(":device_id", "+"); + topic.replace(":hex_device_id", "+"); + topic.replace(":dec_device_id", "+"); topic.replace(":group_id", "+"); topic.replace(":device_type", "+"); + topic.replace(":device_alias", "+"); #ifdef MQTT_DEBUG printf_P(PSTR("MqttClient - subscribing to topic: %s\n"), topic.c_str()); #endif - mqttClient->subscribe(topic.c_str()); + mqttClient.subscribe(topic.c_str()); +} + +void MqttClient::send(const char* topic, const char* message, const bool retain) { + size_t len = strlen(message); + size_t topicLen = strlen(topic); + + if ((topicLen + len + 10) < MQTT_MAX_PACKET_SIZE ) { + mqttClient.publish(topic, message, retain); + } else { + const uint8_t* messageBuffer = reinterpret_cast(message); + mqttClient.beginPublish(topic, len, retain); + +#ifdef MQTT_DEBUG + Serial.printf_P(PSTR("Printing message in parts because it's too large for the packet buffer (%d bytes)"), len); +#endif + + for (size_t i = 0; i < len; i += MQTT_PACKET_CHUNK_SIZE) { + size_t toWrite = std::min(static_cast(MQTT_PACKET_CHUNK_SIZE), len - i); + mqttClient.write(messageBuffer+i, toWrite); +#ifdef MQTT_DEBUG + Serial.printf_P(PSTR(" Wrote %d bytes\n"), toWrite); +#endif + } + + mqttClient.endPublish(); + } } void MqttClient::publish( @@ -121,14 +194,14 @@ void MqttClient::publish( return; } - String topic = _topic; - MqttClient::bindTopicString(topic, remoteConfig, deviceId, groupId); + BulbId bulbId(deviceId, groupId, remoteConfig.type); + String topic = bindTopicString(_topic, bulbId); #ifdef MQTT_DEBUG printf("MqttClient - publishing update to %s\n", topic.c_str()); #endif - mqttClient->publish(topic.c_str(), message, retain); + send(topic.c_str(), message, retain); } void MqttClient::publishCallback(char* topic, byte* payload, int length) { @@ -150,27 +223,48 @@ void MqttClient::publishCallback(char* topic, byte* payload, int length) { TokenIterator topicIterator(topic, strlen(topic), '/'); UrlTokenBindings tokenBindings(patternIterator, topicIterator); - if (tokenBindings.hasBinding("device_id")) { - deviceId = parseInt(tokenBindings.get("device_id")); - } - - if (tokenBindings.hasBinding("group_id")) { - groupId = parseInt(tokenBindings.get("group_id")); - } - - if (tokenBindings.hasBinding("device_type")) { - config = MiLightRemoteConfig::fromType(tokenBindings.get("device_type")); + if (tokenBindings.hasBinding("device_alias")) { + String alias = tokenBindings.get("device_alias"); + auto itr = settings.groupIdAliases.find(alias); - if (config == NULL) { - Serial.println(F("MqttClient - ERROR: could not extract device_type from topic")); + if (itr == settings.groupIdAliases.end()) { + Serial.printf_P(PSTR("MqttClient - WARNING: could not find device alias: `%s'. Ignoring packet.\n"), alias.c_str()); return; + } else { + BulbId bulbId = itr->second; + + deviceId = bulbId.deviceId; + config = MiLightRemoteConfig::fromType(bulbId.deviceType); + groupId = bulbId.groupId; } } else { - Serial.println(F("MqttClient - WARNING: could not find device_type token. Defaulting to FUT092.\n")); + if (tokenBindings.hasBinding(GroupStateFieldNames::DEVICE_ID)) { + deviceId = parseInt(tokenBindings.get(GroupStateFieldNames::DEVICE_ID)); + } else if (tokenBindings.hasBinding("hex_device_id")) { + deviceId = parseInt(tokenBindings.get("hex_device_id")); + } else if (tokenBindings.hasBinding("dec_device_id")) { + deviceId = parseInt(tokenBindings.get("dec_device_id")); + } + + if (tokenBindings.hasBinding(GroupStateFieldNames::GROUP_ID)) { + groupId = parseInt(tokenBindings.get(GroupStateFieldNames::GROUP_ID)); + } + + if (tokenBindings.hasBinding(GroupStateFieldNames::DEVICE_TYPE)) { + config = MiLightRemoteConfig::fromType(tokenBindings.get(GroupStateFieldNames::DEVICE_TYPE)); + } else { + Serial.println(F("MqttClient - WARNING: could not find device_type token. Defaulting to FUT092.\n")); + } + } + + if (config == NULL) { + Serial.println(F("MqttClient - ERROR: unknown device_type specified")); + return; } - StaticJsonBuffer<400> buffer; - JsonObject& obj = buffer.parseObject(cstrPayload); + StaticJsonDocument<400> buffer; + deserializeJson(buffer, cstrPayload); + JsonObject obj = buffer.as(); #ifdef MQTT_DEBUG printf("MqttClient - device %04X, group %u\n", deviceId, groupId); @@ -180,19 +274,44 @@ void MqttClient::publishCallback(char* topic, byte* payload, int length) { milightClient->update(obj); } -inline void MqttClient::bindTopicString( - String& topicPattern, - const MiLightRemoteConfig& remoteConfig, - const uint16_t deviceId, - const uint16_t groupId -) { - String deviceIdHex = String(deviceId, 16); - deviceIdHex.toUpperCase(); - deviceIdHex = String("0x") + deviceIdHex; - - topicPattern.replace(":device_id", deviceIdHex); - topicPattern.replace(":hex_device_id", deviceIdHex); - topicPattern.replace(":dec_device_id", String(deviceId)); - topicPattern.replace(":group_id", String(groupId)); - topicPattern.replace(":device_type", remoteConfig.name); +String MqttClient::bindTopicString(const String& topicPattern, const BulbId& bulbId) { + String boundTopic = topicPattern; + String deviceIdHex = bulbId.getHexDeviceId(); + + boundTopic.replace(":device_id", deviceIdHex); + boundTopic.replace(":hex_device_id", deviceIdHex); + boundTopic.replace(":dec_device_id", String(bulbId.deviceId)); + boundTopic.replace(":group_id", String(bulbId.groupId)); + boundTopic.replace(":device_type", MiLightRemoteTypeHelpers::remoteTypeToString(bulbId.deviceType)); + + auto it = settings.findAlias(bulbId.deviceType, bulbId.deviceId, bulbId.groupId); + if (it != settings.groupIdAliases.end()) { + boundTopic.replace(":device_alias", it->first); + } else { + boundTopic.replace(":device_alias", "__unnamed_group"); + } + + return boundTopic; } + +String MqttClient::generateConnectionStatusMessage(const char* connectionStatus) { + if (settings.simpleMqttClientStatus) { + // Don't expand disconnect type for simple status + if (0 == strcmp(connectionStatus, STATUS_CONNECTED)) { + return connectionStatus; + } else { + return "disconnected"; + } + } else { + StaticJsonDocument<1024> json; + json[GroupStateFieldNames::STATUS] = connectionStatus; + + // Fill other fields + AboutHelper::generateAboutObject(json, true); + + String response; + serializeJson(json, response); + + return response; + } +} \ No newline at end of file diff --git a/lib/MQTT/MqttClient.h b/lib/MQTT/MqttClient.h index cac9e393..dc601c4b 100644 --- a/lib/MQTT/MqttClient.h +++ b/lib/MQTT/MqttClient.h @@ -8,11 +8,17 @@ #define MQTT_CONNECTION_ATTEMPT_FREQUENCY 5000 #endif +#ifndef MQTT_PACKET_CHUNK_SIZE +#define MQTT_PACKET_CHUNK_SIZE 128 +#endif + #ifndef _MQTT_CLIENT_H #define _MQTT_CLIENT_H class MqttClient { public: + using OnConnectFn = std::function; + MqttClient(Settings& settings, MiLightClient*& milightClient); ~MqttClient(); @@ -21,15 +27,22 @@ class MqttClient { void reconnect(); void sendUpdate(const MiLightRemoteConfig& remoteConfig, uint16_t deviceId, uint16_t groupId, const char* update); void sendState(const MiLightRemoteConfig& remoteConfig, uint16_t deviceId, uint16_t groupId, const char* update); + void send(const char* topic, const char* message, const bool retain = false); + void onConnect(OnConnectFn fn); + + String bindTopicString(const String& topicPattern, const BulbId& bulbId); private: WiFiClient tcpClient; - PubSubClient* mqttClient; + PubSubClient mqttClient; MiLightClient*& milightClient; Settings& settings; char* domain; unsigned long lastConnectAttempt; + OnConnectFn onConnectFn; + bool connected; + void sendBirthMessage(); bool connect(); void subscribe(); void publishCallback(char* topic, byte* payload, int length); @@ -42,12 +55,7 @@ class MqttClient { const bool retain = false ); - inline static void bindTopicString( - String& topicPattern, - const MiLightRemoteConfig& remoteConfig, - const uint16_t deviceId, - const uint16_t groupId - ); + String generateConnectionStatusMessage(const char* status); }; #endif diff --git a/lib/MiLight/CctPacketFormatter.cpp b/lib/MiLight/CctPacketFormatter.cpp index beb64254..7e412119 100644 --- a/lib/MiLight/CctPacketFormatter.cpp +++ b/lib/MiLight/CctPacketFormatter.cpp @@ -1,4 +1,5 @@ #include +#include static const uint8_t CCT_PROTOCOL_ID = 0x5A; @@ -184,11 +185,12 @@ MiLightStatus CctPacketFormatter::cctCommandToStatus(uint8_t command) { case CCT_GROUP_3_OFF: case CCT_GROUP_4_OFF: case CCT_ALL_OFF: + default: return OFF; } } -BulbId CctPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& result) { +BulbId CctPacketFormatter::parsePacket(const uint8_t* packet, JsonObject result) { uint8_t command = packet[CCT_COMMAND_INDEX] & 0x7F; uint8_t onOffGroupId = cctCommandIdToGroup(command); @@ -198,16 +200,19 @@ BulbId CctPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& result REMOTE_TYPE_CCT ); - if (onOffGroupId < 255) { - result["state"] = cctCommandToStatus(command) == ON ? "ON" : "OFF"; + // Night mode + if (command & 0x10) { + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::NIGHT_MODE; + } else if (onOffGroupId < 255) { + result[GroupStateFieldNames::STATE] = cctCommandToStatus(command) == ON ? "ON" : "OFF"; } else if (command == CCT_BRIGHTNESS_DOWN) { - result["command"] = "brightness_down"; + result[GroupStateFieldNames::COMMAND] = "brightness_down"; } else if (command == CCT_BRIGHTNESS_UP) { - result["command"] = "brightness_up"; + result[GroupStateFieldNames::COMMAND] = "brightness_up"; } else if (command == CCT_TEMPERATURE_DOWN) { - result["command"] = "temperature_down"; + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::TEMPERATURE_DOWN; } else if (command == CCT_TEMPERATURE_UP) { - result["command"] = "temperature_up"; + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::TEMPERATURE_UP; } else { result["button_id"] = command; } diff --git a/lib/MiLight/CctPacketFormatter.h b/lib/MiLight/CctPacketFormatter.h index e89c915c..c0ac96a9 100644 --- a/lib/MiLight/CctPacketFormatter.h +++ b/lib/MiLight/CctPacketFormatter.h @@ -26,7 +26,7 @@ enum MiLightCctButton { class CctPacketFormatter : public PacketFormatter { public: CctPacketFormatter() - : PacketFormatter(7, 20) + : PacketFormatter(REMOTE_TYPE_CCT, 7, 20) { } virtual bool canHandle(const uint8_t* packet, const size_t len); @@ -46,7 +46,7 @@ class CctPacketFormatter : public PacketFormatter { virtual void format(uint8_t const* packet, char* buffer); virtual void initializePacket(uint8_t* packet); virtual void finalizePacket(uint8_t* packet); - virtual BulbId parsePacket(const uint8_t* packet, JsonObject& result); + virtual BulbId parsePacket(const uint8_t* packet, JsonObject result); static uint8_t getCctStatusButton(uint8_t groupId, MiLightStatus status); static uint8_t cctCommandIdToGroup(uint8_t command); diff --git a/lib/MiLight/FUT020PacketFormatter.cpp b/lib/MiLight/FUT020PacketFormatter.cpp new file mode 100644 index 00000000..d77a537d --- /dev/null +++ b/lib/MiLight/FUT020PacketFormatter.cpp @@ -0,0 +1,86 @@ +#include +#include + +void FUT020PacketFormatter::updateColorRaw(uint8_t color) { + command(static_cast(FUT020Command::COLOR), color); +} + +void FUT020PacketFormatter::updateHue(uint16_t hue) { + uint16_t remapped = Units::rescale(hue, 255.0, 360.0); + remapped = (remapped + 0xB0) % 0x100; + + updateColorRaw(remapped); +} + +void FUT020PacketFormatter::updateColorWhite() { + command(static_cast(FUT020Command::COLOR_WHITE_TOGGLE), 0); +} + +void FUT020PacketFormatter::nextMode() { + command(static_cast(FUT020Command::MODE_SWITCH), 0); +} + +void FUT020PacketFormatter::updateBrightness(uint8_t value) { + const GroupState* state = this->stateStore->get(deviceId, groupId, MiLightRemoteType::REMOTE_TYPE_FUT020); + int8_t knownValue = (state != NULL && state->isSetBrightness()) ? state->getBrightness() : -1; + + valueByStepFunction( + &PacketFormatter::increaseBrightness, + &PacketFormatter::decreaseBrightness, + FUT02xPacketFormatter::NUM_BRIGHTNESS_INTERVALS, + value / FUT02xPacketFormatter::NUM_BRIGHTNESS_INTERVALS, + knownValue / FUT02xPacketFormatter::NUM_BRIGHTNESS_INTERVALS + ); +} + +void FUT020PacketFormatter::increaseBrightness() { + command(static_cast(FUT020Command::BRIGHTNESS_UP), 0); +} + +void FUT020PacketFormatter::decreaseBrightness() { + command(static_cast(FUT020Command::BRIGHTNESS_DOWN), 0); +} + +void FUT020PacketFormatter::updateStatus(MiLightStatus status, uint8_t groupId) { + command(static_cast(FUT020Command::ON_OFF), 0); +} + +BulbId FUT020PacketFormatter::parsePacket(const uint8_t* packet, JsonObject result) { + FUT020Command command = static_cast(packet[FUT02xPacketFormatter::FUT02X_COMMAND_INDEX] & 0x0F); + + BulbId bulbId( + (packet[1] << 8) | packet[2], + 0, + REMOTE_TYPE_FUT020 + ); + + switch (command) { + case FUT020Command::ON_OFF: + result[F("state")] = F("ON"); + break; + + case FUT020Command::BRIGHTNESS_DOWN: + result[F("command")] = F("brightness_down"); + break; + + case FUT020Command::BRIGHTNESS_UP: + result[F("command")] = F("brightness_up"); + break; + + case FUT020Command::MODE_SWITCH: + result[F("command")] = F("next_mode"); + break; + + case FUT020Command::COLOR_WHITE_TOGGLE: + result[F("command")] = F("color_white_toggle"); + break; + + case FUT020Command::COLOR: + uint16_t remappedColor = Units::rescale(packet[FUT02xPacketFormatter::FUT02X_ARGUMENT_INDEX], 360.0, 255.0); + remappedColor = (remappedColor + 113) % 360; + result[GroupStateFieldNames::HUE] = remappedColor; + break; + } + + return bulbId; +} \ No newline at end of file diff --git a/lib/MiLight/FUT020PacketFormatter.h b/lib/MiLight/FUT020PacketFormatter.h new file mode 100644 index 00000000..11f8eea9 --- /dev/null +++ b/lib/MiLight/FUT020PacketFormatter.h @@ -0,0 +1,30 @@ +#include + +#pragma once + +enum class FUT020Command { + ON_OFF = 0x04, + MODE_SWITCH = 0x02, + COLOR_WHITE_TOGGLE = 0x05, + BRIGHTNESS_DOWN = 0x01, + BRIGHTNESS_UP = 0x03, + COLOR = 0x00 +}; + +class FUT020PacketFormatter : public FUT02xPacketFormatter { +public: + FUT020PacketFormatter() + : FUT02xPacketFormatter(REMOTE_TYPE_FUT020) + { } + + virtual void updateStatus(MiLightStatus status, uint8_t groupId); + virtual void updateHue(uint16_t value); + virtual void updateColorRaw(uint8_t value); + virtual void updateColorWhite(); + virtual void nextMode(); + virtual void updateBrightness(uint8_t value); + virtual void increaseBrightness(); + virtual void decreaseBrightness(); + + virtual BulbId parsePacket(const uint8_t* packet, JsonObject result) override; +}; \ No newline at end of file diff --git a/lib/MiLight/FUT02xPacketFormatter.cpp b/lib/MiLight/FUT02xPacketFormatter.cpp new file mode 100644 index 00000000..a810630a --- /dev/null +++ b/lib/MiLight/FUT02xPacketFormatter.cpp @@ -0,0 +1,50 @@ +#include + +static const uint8_t FUT02X_PACKET_HEADER = 0xA5; + +static const uint8_t FUT02X_PAIR_COMMAND = 0x03; +static const uint8_t FUT02X_UNPAIR_COMMAND = 0x03; + +void FUT02xPacketFormatter::initializePacket(uint8_t *packet) { + size_t packetPtr = 0; + + packet[packetPtr++] = 0xA5; + packet[packetPtr++] = deviceId >> 8; + packet[packetPtr++] = deviceId & 0xFF; + packet[packetPtr++] = 0; // arg + packet[packetPtr++] = 0; // command + packet[packetPtr++] = sequenceNum++; +} + +bool FUT02xPacketFormatter::canHandle(const uint8_t* packet, const size_t len) { + return len == packetLength && packet[0] == FUT02X_PACKET_HEADER; +} + +void FUT02xPacketFormatter::command(uint8_t command, uint8_t arg) { + pushPacket(); + if (held) { + command |= 0x10; + } + currentPacket[FUT02X_COMMAND_INDEX] = command; + currentPacket[FUT02X_ARGUMENT_INDEX] = arg; +} + +void FUT02xPacketFormatter::pair() { + for (size_t i = 0; i < 5; i++) { + command(FUT02X_PAIR_COMMAND, 0); + } +} + +void FUT02xPacketFormatter::unpair() { + for (size_t i = 0; i < 5; i++) { + command(FUT02X_PAIR_COMMAND, 0); + } +} + +void FUT02xPacketFormatter::format(uint8_t const* packet, char* buffer) { + buffer += sprintf_P(buffer, PSTR("b0 : %02X\n"), packet[0]); + buffer += sprintf_P(buffer, PSTR("ID : %02X%02X\n"), packet[1], packet[2]); + buffer += sprintf_P(buffer, PSTR("Arg : %02X\n"), packet[3]); + buffer += sprintf_P(buffer, PSTR("Command : %02X\n"), packet[4]); + buffer += sprintf_P(buffer, PSTR("Sequence : %02X\n"), packet[5]); +} \ No newline at end of file diff --git a/lib/MiLight/FUT02xPacketFormatter.h b/lib/MiLight/FUT02xPacketFormatter.h new file mode 100644 index 00000000..7a0d6912 --- /dev/null +++ b/lib/MiLight/FUT02xPacketFormatter.h @@ -0,0 +1,24 @@ +#include + +#pragma once + +class FUT02xPacketFormatter : public PacketFormatter { +public: + static const uint8_t FUT02X_COMMAND_INDEX = 4; + static const uint8_t FUT02X_ARGUMENT_INDEX = 3; + static const uint8_t NUM_BRIGHTNESS_INTERVALS = 8; + + FUT02xPacketFormatter(MiLightRemoteType type) + : PacketFormatter(type, 6, 10) + { } + + virtual bool canHandle(const uint8_t* packet, const size_t len) override; + + virtual void command(uint8_t command, uint8_t arg) override; + + virtual void pair() override; + virtual void unpair() override; + + virtual void initializePacket(uint8_t* packet) override; + virtual void format(uint8_t const* packet, char* buffer) override; +}; \ No newline at end of file diff --git a/lib/MiLight/FUT089PacketFormatter.cpp b/lib/MiLight/FUT089PacketFormatter.cpp index 90070418..c9fecc6a 100644 --- a/lib/MiLight/FUT089PacketFormatter.cpp +++ b/lib/MiLight/FUT089PacketFormatter.cpp @@ -1,6 +1,7 @@ #include #include #include +#include void FUT089PacketFormatter::modeSpeedDown() { command(FUT089_ON, FUT089_MODE_SPEED_DOWN); @@ -28,15 +29,15 @@ void FUT089PacketFormatter::updateColorRaw(uint8_t value) { command(FUT089_COLOR, FUT089_COLOR_OFFSET + value); } -// change the temperature (kelvin). Note that temperature and saturation share the same command +// change the temperature (kelvin). Note that temperature and saturation share the same command // number (7), and they change which they do based on the mode of the lamp (white vs. color mode). // To make this command work, we need to switch to white mode, make the change, and then flip // back to the original mode. void FUT089PacketFormatter::updateTemperature(uint8_t value) { - // look up our current mode + // look up our current mode const GroupState* ourState = this->stateStore->get(this->deviceId, this->groupId, REMOTE_TYPE_FUT089); BulbMode originalBulbMode; - + if (ourState != NULL) { originalBulbMode = ourState->getBulbMode(); @@ -55,15 +56,15 @@ void FUT089PacketFormatter::updateTemperature(uint8_t value) { } } -// change the saturation. Note that temperature and saturation share the same command +// change the saturation. Note that temperature and saturation share the same command // number (7), and they change which they do based on the mode of the lamp (white vs. color mode). // Therefore, if we are not in color mode, we need to switch to color mode, make the change, // and switch back to the original mode. void FUT089PacketFormatter::updateSaturation(uint8_t value) { - // look up our current mode + // look up our current mode const GroupState* ourState = this->stateStore->get(this->deviceId, this->groupId, REMOTE_TYPE_FUT089); - BulbMode originalBulbMode; - + BulbMode originalBulbMode = BulbMode::BULB_MODE_WHITE; + if (ourState != NULL) { originalBulbMode = ourState->getBulbMode(); } @@ -91,7 +92,7 @@ void FUT089PacketFormatter::enableNightMode() { command(FUT089_ON | 0x80, arg); } -BulbId FUT089PacketFormatter::parsePacket(const uint8_t *packet, JsonObject& result) { +BulbId FUT089PacketFormatter::parsePacket(const uint8_t *packet, JsonObject result) { if (stateStore == NULL) { Serial.println(F("ERROR: stateStore not set. Prepare was not called! **THIS IS A BUG**")); BulbId fakeId(0, 0, REMOTE_TYPE_FUT089); @@ -113,39 +114,39 @@ BulbId FUT089PacketFormatter::parsePacket(const uint8_t *packet, JsonObject& res if (command == FUT089_ON) { if ((packetCopy[V2_COMMAND_INDEX] & 0x80) == 0x80) { - result["command"] = "night_mode"; + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::NIGHT_MODE; } else if (arg == FUT089_MODE_SPEED_DOWN) { - result["command"] = "mode_speed_down"; + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::MODE_SPEED_DOWN; } else if (arg == FUT089_MODE_SPEED_UP) { - result["command"] = "mode_speed_up"; + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::MODE_SPEED_UP; } else if (arg == FUT089_WHITE_MODE) { - result["command"] = "set_white"; + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::SET_WHITE; } else if (arg <= 8) { // Group is not reliably encoded in group byte. Extract from arg byte - result["state"] = "ON"; + result[GroupStateFieldNames::STATE] = "ON"; bulbId.groupId = arg; } else if (arg >= 9 && arg <= 17) { - result["state"] = "OFF"; + result[GroupStateFieldNames::STATE] = "OFF"; bulbId.groupId = arg-9; } } else if (command == FUT089_COLOR) { uint8_t rescaledColor = (arg - FUT089_COLOR_OFFSET) % 0x100; uint16_t hue = Units::rescale(rescaledColor, 360, 255.0); - result["hue"] = hue; + result[GroupStateFieldNames::HUE] = hue; } else if (command == FUT089_BRIGHTNESS) { uint8_t level = constrain(arg, 0, 100); - result["brightness"] = Units::rescale(level, 255, 100); + result[GroupStateFieldNames::BRIGHTNESS] = Units::rescale(level, 255, 100); // saturation == kelvin. arg ranges are the same, so can't distinguish // without using state } else if (command == FUT089_SATURATION) { const GroupState* state = stateStore->get(bulbId); if (state != NULL && state->getBulbMode() == BULB_MODE_COLOR) { - result["saturation"] = 100 - constrain(arg, 0, 100); + result[GroupStateFieldNames::SATURATION] = 100 - constrain(arg, 0, 100); } else { - result["color_temp"] = Units::whiteValToMireds(100 - arg, 100); + result[GroupStateFieldNames::COLOR_TEMP] = Units::whiteValToMireds(100 - arg, 100); } } else if (command == FUT089_MODE) { - result["mode"] = arg; + result[GroupStateFieldNames::MODE] = arg; } else { result["button_id"] = command; result["argument"] = arg; diff --git a/lib/MiLight/FUT089PacketFormatter.h b/lib/MiLight/FUT089PacketFormatter.h index 77347bc1..71d75839 100644 --- a/lib/MiLight/FUT089PacketFormatter.h +++ b/lib/MiLight/FUT089PacketFormatter.h @@ -24,7 +24,7 @@ enum MiLightFUT089Arguments { class FUT089PacketFormatter : public V2PacketFormatter { public: FUT089PacketFormatter() - : V2PacketFormatter(0x25, 8) // protocol is 0x25, and there are 8 groups + : V2PacketFormatter(REMOTE_TYPE_FUT089, 0x25, 8) // protocol is 0x25, and there are 8 groups { } virtual void updateBrightness(uint8_t value); @@ -39,7 +39,7 @@ class FUT089PacketFormatter : public V2PacketFormatter { virtual void modeSpeedUp(); virtual void updateMode(uint8_t mode); - virtual BulbId parsePacket(const uint8_t* packet, JsonObject& result); + virtual BulbId parsePacket(const uint8_t* packet, JsonObject result); }; #endif diff --git a/lib/MiLight/FUT091PacketFormatter.cpp b/lib/MiLight/FUT091PacketFormatter.cpp index 17c92947..816e3776 100644 --- a/lib/MiLight/FUT091PacketFormatter.cpp +++ b/lib/MiLight/FUT091PacketFormatter.cpp @@ -1,6 +1,7 @@ #include #include #include +#include static const uint8_t BRIGHTNESS_SCALE_MAX = 0x97; static const uint8_t KELVIN_SCALE_MAX = 0xC5; @@ -18,7 +19,7 @@ void FUT091PacketFormatter::enableNightMode() { command(static_cast(FUT091Command::ON_OFF) | 0x80, arg); } -BulbId FUT091PacketFormatter::parsePacket(const uint8_t *packet, JsonObject& result) { +BulbId FUT091PacketFormatter::parsePacket(const uint8_t *packet, JsonObject result) { uint8_t packetCopy[V2_PACKET_LEN]; memcpy(packetCopy, packet, V2_PACKET_LEN); V2RFEncoding::decodeV2Packet(packetCopy); @@ -34,20 +35,20 @@ BulbId FUT091PacketFormatter::parsePacket(const uint8_t *packet, JsonObject& res if (command == (uint8_t)FUT091Command::ON_OFF) { if ((packetCopy[V2_COMMAND_INDEX] & 0x80) == 0x80) { - result["command"] = "night_mode"; + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::NIGHT_MODE; } else if (arg < 5) { // Group is not reliably encoded in group byte. Extract from arg byte - result["state"] = "ON"; + result[GroupStateFieldNames::STATE] = "ON"; bulbId.groupId = arg; } else { - result["state"] = "OFF"; + result[GroupStateFieldNames::STATE] = "OFF"; bulbId.groupId = arg-5; } } else if (command == (uint8_t)FUT091Command::BRIGHTNESS) { uint8_t level = V2PacketFormatter::fromv2scale(arg, BRIGHTNESS_SCALE_MAX, 2, true); - result["brightness"] = Units::rescale(level, 255, 100); + result[GroupStateFieldNames::BRIGHTNESS] = Units::rescale(level, 255, 100); } else if (command == (uint8_t)FUT091Command::KELVIN) { uint8_t kelvin = V2PacketFormatter::fromv2scale(arg, KELVIN_SCALE_MAX, 2, false); - result["color_temp"] = Units::whiteValToMireds(kelvin, 100); + result[GroupStateFieldNames::COLOR_TEMP] = Units::whiteValToMireds(kelvin, 100); } else { result["button_id"] = command; result["argument"] = arg; diff --git a/lib/MiLight/FUT091PacketFormatter.h b/lib/MiLight/FUT091PacketFormatter.h index d71a9a7e..0a472dd3 100644 --- a/lib/MiLight/FUT091PacketFormatter.h +++ b/lib/MiLight/FUT091PacketFormatter.h @@ -12,14 +12,14 @@ enum class FUT091Command { class FUT091PacketFormatter : public V2PacketFormatter { public: FUT091PacketFormatter() - : V2PacketFormatter(0x21, 4) // protocol is 0x21, and there are 4 groups + : V2PacketFormatter(REMOTE_TYPE_FUT091, 0x21, 4) // protocol is 0x21, and there are 4 groups { } virtual void updateBrightness(uint8_t value); virtual void updateTemperature(uint8_t value); virtual void enableNightMode(); - virtual BulbId parsePacket(const uint8_t* packet, JsonObject& result); + virtual BulbId parsePacket(const uint8_t* packet, JsonObject result); }; #endif diff --git a/lib/MiLight/MiLightClient.cpp b/lib/MiLight/MiLightClient.cpp index b7e484af..6e939252 100644 --- a/lib/MiLight/MiLightClient.cpp +++ b/lib/MiLight/MiLightClient.cpp @@ -3,86 +3,91 @@ #include #include #include +#include +#include +#include +#include + +using namespace std::placeholders; + +static const uint8_t STATUS_UNDEFINED = 255; + +const char* MiLightClient::FIELD_ORDERINGS[] = { + // These are handled manually + // GroupStateFieldNames::STATE, + // GroupStateFieldNames::STATUS, + GroupStateFieldNames::HUE, + GroupStateFieldNames::SATURATION, + GroupStateFieldNames::KELVIN, + GroupStateFieldNames::TEMPERATURE, + GroupStateFieldNames::COLOR_TEMP, + GroupStateFieldNames::MODE, + GroupStateFieldNames::EFFECT, + GroupStateFieldNames::COLOR, + // Level/Brightness must be processed last because they're specific to a particular bulb mode. + // So make sure bulb mode is set before applying level/brightness. + GroupStateFieldNames::LEVEL, + GroupStateFieldNames::BRIGHTNESS, + GroupStateFieldNames::COMMAND, + GroupStateFieldNames::COMMANDS +}; + +const std::map, MiLightClient::cmp_str> MiLightClient::FIELD_SETTERS = { + { + GroupStateFieldNames::STATUS, + [](MiLightClient* client, JsonVariant val) { + client->updateStatus(parseMilightStatus(val)); + } + }, + {GroupStateFieldNames::LEVEL, &MiLightClient::updateBrightness}, + { + GroupStateFieldNames::BRIGHTNESS, + [](MiLightClient* client, uint16_t arg) { + client->updateBrightness(Units::rescale(arg, 100, 255)); + } + }, + {GroupStateFieldNames::HUE, &MiLightClient::updateHue}, + {GroupStateFieldNames::SATURATION, &MiLightClient::updateSaturation}, + {GroupStateFieldNames::KELVIN, &MiLightClient::updateTemperature}, + {GroupStateFieldNames::TEMPERATURE, &MiLightClient::updateTemperature}, + { + GroupStateFieldNames::COLOR_TEMP, + [](MiLightClient* client, uint16_t arg) { + client->updateTemperature(Units::miredsToWhiteVal(arg, 100)); + } + }, + {GroupStateFieldNames::MODE, &MiLightClient::updateMode}, + {GroupStateFieldNames::COLOR, &MiLightClient::updateColor}, + {GroupStateFieldNames::EFFECT, &MiLightClient::handleEffect}, + {GroupStateFieldNames::COMMAND, &MiLightClient::handleCommand}, + {GroupStateFieldNames::COMMANDS, &MiLightClient::handleCommands} +}; MiLightClient::MiLightClient( - MiLightRadioFactory* radioFactory, + RadioSwitchboard& radioSwitchboard, + PacketSender& packetSender, GroupStateStore* stateStore, - Settings* settings -) - : baseResendCount(MILIGHT_DEFAULT_RESEND_COUNT), - currentRadio(NULL), - currentRemote(NULL), - numRadios(MiLightRadioConfig::NUM_CONFIGS), - packetSentHandler(NULL), - updateBeginHandler(NULL), - updateEndHandler(NULL), - stateStore(stateStore), - settings(settings), - lastSend(0) -{ - radios = new MiLightRadio*[numRadios]; - - for (size_t i = 0; i < numRadios; i++) { - radios[i] = radioFactory->create(MiLightRadioConfig::ALL_CONFIGS[i]); - } -} - -void MiLightClient::begin() { - for (size_t i = 0; i < numRadios; i++) { - radios[i]->begin(); - } - - switchRadio(static_cast(0)); - - // Little gross to do this here as it's relying on global state. A better alternative - // would be to statically construct remote config factories which take in a stateStore - // and settings pointer. The objects could then be initialized by calling the factory - // in main. - for (size_t i = 0; i < MiLightRemoteConfig::NUM_REMOTES; i++) { - MiLightRemoteConfig::ALL_REMOTES[i]->packetFormatter->initialize(stateStore, settings); - } -} + Settings& settings, + TransitionController& transitions +) : radioSwitchboard(radioSwitchboard) + , updateBeginHandler(NULL) + , updateEndHandler(NULL) + , stateStore(stateStore) + , settings(settings) + , packetSender(packetSender) + , transitions(transitions) + , repeatsOverride(0) +{ } void MiLightClient::setHeld(bool held) { currentRemote->packetFormatter->setHeld(held); } -size_t MiLightClient::getNumRadios() const { - return numRadios; -} - -MiLightRadio* MiLightClient::switchRadio(size_t radioIx) { - if (radioIx >= getNumRadios()) { - return NULL; - } - - if (this->currentRadio != radios[radioIx]) { - this->currentRadio = radios[radioIx]; - this->currentRadio->configure(); - } - - return this->currentRadio; -} - -MiLightRadio* MiLightClient::switchRadio(const MiLightRemoteConfig* remoteConfig) { - MiLightRadio* radio; - - for (int i = 0; i < numRadios; i++) { - if (&this->radios[i]->config() == &remoteConfig->radioConfig) { - radio = switchRadio(i); - break; - } - } - - return radio; -} - -void MiLightClient::prepare(const MiLightRemoteConfig* config, +void MiLightClient::prepare( + const MiLightRemoteConfig* config, const uint16_t deviceId, const uint8_t groupId ) { - switchRadio(config); - this->currentRemote = config; if (deviceId >= 0 && groupId >= 0) { @@ -90,70 +95,14 @@ void MiLightClient::prepare(const MiLightRemoteConfig* config, } } -void MiLightClient::prepare(const MiLightRemoteType type, +void MiLightClient::prepare( + const MiLightRemoteType type, const uint16_t deviceId, const uint8_t groupId ) { prepare(MiLightRemoteConfig::fromType(type), deviceId, groupId); } -void MiLightClient::setResendCount(const unsigned int resendCount) { - this->baseResendCount = resendCount; - this->currentResendCount = resendCount; - this->throttleMultiplier = ceil((settings->packetRepeatThrottleSensitivity / 1000.0) * this->baseResendCount); -} - -bool MiLightClient::available() { - if (currentRadio == NULL) { - return false; - } - - return currentRadio->available(); -} - -size_t MiLightClient::read(uint8_t packet[]) { - if (currentRadio == NULL) { - return 0; - } - - size_t length; - currentRadio->read(packet, length); - - return length; -} - -void MiLightClient::write(uint8_t packet[]) { - if (currentRadio == NULL) { - return; - } - -#ifdef DEBUG_PRINTF - Serial.printf_P(PSTR("Sending packet (%d repeats): \n"), this->currentResendCount); - for (int i = 0; i < currentRemote->packetFormatter->getPacketLength(); i++) { - Serial.printf_P(PSTR("%02X "), packet[i]); - } - Serial.println(); - int iStart = millis(); -#endif - - // send the packet out (multiple times for "reliability") - for (int i = 0; i < this->currentResendCount; i++) { - currentRadio->write(packet, currentRemote->packetFormatter->getPacketLength()); - } - - // if we have a packetSendHandler defined (see MiLightClient::onPacketSent), call it now that - // the packet has been dispatched - if (this->packetSentHandler) { - this->packetSentHandler(packet, *currentRemote); - } - -#ifdef DEBUG_PRINTF - int iElapsed = millis() - iStart; - Serial.print("Elapsed: "); - Serial.println(iElapsed); -#endif -} - void MiLightClient::updateColorRaw(const uint8_t color) { #ifdef DEBUG_CLIENT_COMMANDS Serial.printf_P(PSTR("MiLightClient::updateColorRaw: Change color to %d\n"), color); @@ -321,92 +270,99 @@ void MiLightClient::command(uint8_t command, uint8_t arg) { flushPacket(); } -void MiLightClient::update(const JsonObject& request) { - if (this->updateBeginHandler) { - this->updateBeginHandler(); - } - - const uint8_t parsedStatus = this->parseStatus(request); - - // Always turn on first - if (parsedStatus == ON) { - this->updateStatus(ON); - } - - if (request.containsKey("command")) { - this->handleCommand(request["command"]); - } +void MiLightClient::toggleStatus() { +#ifdef DEBUG_CLIENT_COMMANDS + Serial.printf_P(PSTR("MiLightClient::toggleStatus")); +#endif + currentRemote->packetFormatter->toggleStatus(); + flushPacket(); +} - if (request.containsKey("commands")) { - JsonArray& commands = request["commands"]; +void MiLightClient::updateColor(JsonVariant json) { + ParsedColor color = ParsedColor::fromJson(json); - if (commands.success()) { - for (size_t i = 0; i < commands.size(); i++) { - this->handleCommand(commands.get(i)); - } - } + if (!color.success) { + Serial.println(F("Error parsing color field, unrecognized format")); + return; } - //Homeassistant - Handle effect - if (request.containsKey("effect")) { - this->handleEffect(request["effect"]); + // We consider an RGB color "white" if all color intensities are roughly the + // same value. An unscientific value of 10 (~4%) is chosen. + if ( abs(color.r - color.g) < RGB_WHITE_THRESHOLD + && abs(color.g - color.b) < RGB_WHITE_THRESHOLD + && abs(color.r - color.b) < RGB_WHITE_THRESHOLD) { + this->updateColorWhite(); + } else { + this->updateHue(color.hue); + this->updateSaturation(color.saturation); } +} - if (request.containsKey("hue")) { - this->updateHue(request["hue"]); - } - if (request.containsKey("saturation")) { - this->updateSaturation(request["saturation"]); +void MiLightClient::update(JsonObject request) { + if (this->updateBeginHandler) { + this->updateBeginHandler(); } - // Convert RGB to HSV - if (request.containsKey("color")) { - JsonObject& color = request["color"]; - - int16_t r = color["r"]; - int16_t g = color["g"]; - int16_t b = color["b"]; + const JsonVariant status = this->extractStatus(request); + const uint8_t parsedStatus = this->parseStatus(status); + const JsonVariant jsonTransition = request[RequestKeys::TRANSITION]; + float transition = 0; - // We consider an RGB color "white" if all color intensities are roughly the - // same value. An unscientific value of 10 (~4%) is chosen. - if ( abs(r - g) < RGB_WHITE_THRESHOLD - && abs(g - b) < RGB_WHITE_THRESHOLD - && abs(r - b) < RGB_WHITE_THRESHOLD) { - this->updateColorWhite(); + if (!jsonTransition.isNull()) { + if (jsonTransition.is()) { + transition = jsonTransition.as(); + } else if (jsonTransition.is()) { + transition = jsonTransition.as(); } else { - double hsv[3]; - RGBConverter converter; - converter.rgbToHsv(r, g, b, hsv); - - uint16_t hue = round(hsv[0]*360); - uint8_t saturation = round(hsv[1]*100); - - this->updateHue(hue); - this->updateSaturation(saturation); + Serial.println(F("MiLightClient - WARN: unsupported transition type. Must be float or int.")); } } - if (request.containsKey("level")) { - this->updateBrightness(request["level"]); - } - // HomeAssistant - if (request.containsKey("brightness")) { - uint8_t scaledBrightness = Units::rescale(request.get("brightness"), 100, 255); - this->updateBrightness(scaledBrightness); - } - - if (request.containsKey("temperature")) { - this->updateTemperature(request["temperature"]); - } - // HomeAssistant - if (request.containsKey("color_temp")) { - this->updateTemperature( - Units::miredsToWhiteVal(request["color_temp"], 100) - ); + // Always turn on first + if (parsedStatus == ON) { + if (transition == 0) { + this->updateStatus(ON); + } else { + JsonVariant brightness = request[GroupStateFieldNames::BRIGHTNESS]; + JsonVariant level = request[GroupStateFieldNames::LEVEL]; + + // The behavior for status transitions is to ramp up to max or down to min brightness. If a + // brightness is specified, we shold ramp up or down to that value instead. + if (!brightness.isUndefined()) { + this->updateStatus(ON); + handleTransition(GroupStateField::BRIGHTNESS, brightness, transition, 0); + } else if (!level.isUndefined()) { + this->updateStatus(ON); + handleTransition(GroupStateField::LEVEL, level, transition, 0); + } else { + handleTransition(GroupStateField::STATUS, status, transition, 0); + } + } } - if (request.containsKey("mode")) { - this->updateMode(request["mode"]); + for (const char* fieldName : FIELD_ORDERINGS) { + if (request.containsKey(fieldName)) { + auto handler = FIELD_SETTERS.find(fieldName); + JsonVariant value = request[fieldName]; + + if (handler != FIELD_SETTERS.end()) { + // No transition -- set field directly + if (transition == 0) { + handler->second(this, value); + } else { + // Do not generate a brightness transition if a status field was specified. Status will + // generate its own brightness transition, and generating another one will cause conflicts. + GroupStateField field = GroupStateFieldHelpers::getFieldByName(fieldName); + + if ( !GroupStateFieldHelpers::isBrightnessField(field) // If field isn't brightness + || parsedStatus == STATUS_UNDEFINED // or if there was not a status field + // in the command + ) { + handleTransition(field, value, transition); + } + } + } + } } // Raw packet command/args @@ -416,7 +372,11 @@ void MiLightClient::update(const JsonObject& request) { // Always turn off last if (parsedStatus == OFF) { - this->updateStatus(OFF); + if (transition == 0) { + this->updateStatus(OFF); + } else { + handleTransition(GroupStateField::STATUS, status, transition); + } } if (this->updateEndHandler) { @@ -424,36 +384,228 @@ void MiLightClient::update(const JsonObject& request) { } } -void MiLightClient::handleCommand(const String& command) { - if (command == "unpair") { +void MiLightClient::handleCommands(JsonArray commands) { + if (! commands.isNull()) { + for (size_t i = 0; i < commands.size(); i++) { + this->handleCommand(commands[i]); + } + } +} + +void MiLightClient::handleCommand(JsonVariant command) { + String cmdName; + JsonObject args; + + if (command.is()) { + JsonObject cmdObj = command.as(); + cmdName = cmdObj[GroupStateFieldNames::COMMAND].as(); + args = cmdObj["args"]; + } else if (command.is()) { + cmdName = command.as(); + } + + if (cmdName == MiLightCommandNames::UNPAIR) { this->unpair(); - } else if (command == "pair") { + } else if (cmdName == MiLightCommandNames::PAIR) { this->pair(); - } else if (command == "set_white") { + } else if (cmdName == MiLightCommandNames::SET_WHITE) { this->updateColorWhite(); - } else if (command == "night_mode") { + } else if (cmdName == MiLightCommandNames::NIGHT_MODE) { this->enableNightMode(); - } else if (command == "level_up") { + } else if (cmdName == MiLightCommandNames::LEVEL_UP) { this->increaseBrightness(); - } else if (command == "level_down") { + } else if (cmdName == MiLightCommandNames::LEVEL_DOWN) { this->decreaseBrightness(); - } else if (command == "temperature_up") { + } else if (cmdName == MiLightCommandNames::TEMPERATURE_UP) { this->increaseTemperature(); - } else if (command == "temperature_down") { + } else if (cmdName == MiLightCommandNames::TEMPERATURE_DOWN) { this->decreaseTemperature(); - } else if (command == "next_mode") { + } else if (cmdName == MiLightCommandNames::NEXT_MODE) { this->nextMode(); - } else if (command == "previous_mode") { + } else if (cmdName == MiLightCommandNames::PREVIOUS_MODE) { this->previousMode(); - } else if (command == "mode_speed_down") { + } else if (cmdName == MiLightCommandNames::MODE_SPEED_DOWN) { this->modeSpeedDown(); - } else if (command == "mode_speed_up") { + } else if (cmdName == MiLightCommandNames::MODE_SPEED_UP) { this->modeSpeedUp(); + } else if (cmdName == MiLightCommandNames::TOGGLE) { + this->toggleStatus(); + } else if (cmdName == MiLightCommandNames::TRANSITION) { + StaticJsonDocument<100> fakedoc; + this->handleTransition(args, fakedoc); + } +} + +void MiLightClient::handleTransition(GroupStateField field, JsonVariant value, float duration, int16_t startValue) { + BulbId bulbId = currentRemote->packetFormatter->currentBulbId(); + GroupState* currentState = stateStore->get(bulbId); + std::shared_ptr transitionBuilder = nullptr; + + if (currentState == nullptr) { + Serial.println(F("Error planning transition: could not find current bulb state.")); + return; + } + + if (!currentState->isSetField(field)) { + Serial.println(F("Error planning transition: current state for field could not be determined")); + return; + } + + if (field == GroupStateField::COLOR) { + ParsedColor currentColor = currentState->getColor(); + ParsedColor endColor = ParsedColor::fromJson(value); + + transitionBuilder = transitions.buildColorTransition( + bulbId, + currentColor, + endColor + ); + } else if (field == GroupStateField::STATUS || field == GroupStateField::STATE) { + uint8_t startLevel; + MiLightStatus status = parseMilightStatus(value); + + if (startValue == FETCH_VALUE_FROM_STATE) { + startLevel = currentState->getBrightness(); + } else if (status == ON) { + startLevel = 0; + } else { + startLevel = 100; + } + + transitionBuilder = transitions.buildStatusTransition(bulbId, status, startLevel); + } else { + uint16_t currentValue; + uint16_t endValue = value; + + if (startValue == FETCH_VALUE_FROM_STATE) { + currentValue = currentState->getParsedFieldValue(field); + } else { + currentValue = startValue; + } + + transitionBuilder = transitions.buildFieldTransition( + bulbId, + field, + currentValue, + endValue + ); + } + + if (transitionBuilder == nullptr) { + Serial.printf_P(PSTR("Unsupported transition field: %s\n"), GroupStateFieldHelpers::getFieldName(field)); + return; + } + + transitionBuilder->setDuration(duration); + transitions.addTransition(transitionBuilder->build()); +} + +bool MiLightClient::handleTransition(JsonObject args, JsonDocument& responseObj) { + if (! args.containsKey(FS(TransitionParams::FIELD)) + || ! args.containsKey(FS(TransitionParams::END_VALUE))) { + responseObj[F("error")] = F("Ignoring transition missing required arguments"); + return false; } + + const BulbId& bulbId = currentRemote->packetFormatter->currentBulbId(); + const char* fieldName = args[FS(TransitionParams::FIELD)]; + const GroupState* groupState = stateStore->get(bulbId); + JsonVariant startValue = args[FS(TransitionParams::START_VALUE)]; + JsonVariant endValue = args[FS(TransitionParams::END_VALUE)]; + GroupStateField field = GroupStateFieldHelpers::getFieldByName(fieldName); + std::shared_ptr transitionBuilder = nullptr; + + if (field == GroupStateField::UNKNOWN) { + char errorMsg[30]; + sprintf_P(errorMsg, PSTR("Unknown transition field: %s\n"), fieldName); + responseObj[F("error")] = errorMsg; + return false; + } + + // These fields can be transitioned directly. + switch (field) { + case GroupStateField::HUE: + case GroupStateField::SATURATION: + case GroupStateField::BRIGHTNESS: + case GroupStateField::LEVEL: + case GroupStateField::KELVIN: + case GroupStateField::COLOR_TEMP: + + transitionBuilder = transitions.buildFieldTransition( + bulbId, + field, + startValue.isUndefined() + ? groupState->getParsedFieldValue(field) + : startValue.as(), + endValue + ); + break; + + default: + break; + } + + // Color can be decomposed into hue/saturation and these can be transitioned separately + if (field == GroupStateField::COLOR) { + ParsedColor _startValue = startValue.isUndefined() + ? groupState->getColor() + : ParsedColor::fromJson(startValue); + ParsedColor endColor = ParsedColor::fromJson(endValue); + + if (! _startValue.success) { + responseObj[F("error")] = F("Transition - error parsing start color"); + return false; + } + if (! endColor.success) { + responseObj[F("error")] = F("Transition - error parsing end color"); + return false; + } + + transitionBuilder = transitions.buildColorTransition( + bulbId, + _startValue, + endColor + ); + } + + // Status is handled a little differently + if (field == GroupStateField::STATUS || field == GroupStateField::STATE) { + MiLightStatus toStatus = parseMilightStatus(endValue); + uint8_t startLevel; + if (groupState->isSetBrightness()) { + startLevel = groupState->getBrightness(); + } else if (toStatus == ON) { + startLevel = 0; + } else { + startLevel = 100; + } + + transitionBuilder = transitions.buildStatusTransition(bulbId, toStatus, startLevel); + } + + if (transitionBuilder == nullptr) { + char errorMsg[30]; + sprintf_P(errorMsg, PSTR("Recognized, but unsupported transition field: %s\n"), fieldName); + responseObj[F("error")] = errorMsg; + return false; + } + + if (args.containsKey(FS(TransitionParams::DURATION))) { + transitionBuilder->setDuration(args[FS(TransitionParams::DURATION)]); + } + if (args.containsKey(FS(TransitionParams::PERIOD))) { + transitionBuilder->setPeriod(args[FS(TransitionParams::PERIOD)]); + } + if (args.containsKey(FS(TransitionParams::NUM_PERIODS))) { + transitionBuilder->setNumPeriods(args[FS(TransitionParams::NUM_PERIODS)]); + } + + transitions.addTransition(transitionBuilder->build()); + return true; } void MiLightClient::handleEffect(const String& effect) { - if (effect == "night_mode") { + if (effect == MiLightCommandNames::NIGHT_MODE) { this->enableNightMode(); } else if (effect == "white" || effect == "white_mode") { this->updateColorWhite(); @@ -462,52 +614,42 @@ void MiLightClient::handleEffect(const String& effect) { } } -uint8_t MiLightClient::parseStatus(const JsonObject& object) { - String strStatus; +JsonVariant MiLightClient::extractStatus(JsonObject object) { + JsonVariant status; - if (object.containsKey("status")) { - strStatus = object.get("status"); - } else if (object.containsKey("state")) { - strStatus = object.get("state"); + if (object.containsKey(FS(GroupStateFieldNames::STATUS))) { + return object[FS(GroupStateFieldNames::STATUS)]; } else { - return 255; + return object[FS(GroupStateFieldNames::STATE)]; + } +} + +uint8_t MiLightClient::parseStatus(JsonVariant val) { + if (val.isUndefined()) { + return STATUS_UNDEFINED; } - return (strStatus.equalsIgnoreCase("on") || strStatus.equalsIgnoreCase("true")) ? ON : OFF; + return parseMilightStatus(val); } -void MiLightClient::updateResendCount() { - unsigned long now = millis(); - long millisSinceLastSend = now - lastSend; - long x = (millisSinceLastSend - settings->packetRepeatThrottleThreshold); - long delta = x * throttleMultiplier; +void MiLightClient::setRepeatsOverride(size_t repeats) { + this->repeatsOverride = repeats; +} - this->currentResendCount = constrain(this->currentResendCount + delta, settings->packetRepeatMinimum, this->baseResendCount); - this->lastSend = now; +void MiLightClient::clearRepeatsOverride() { + this->repeatsOverride = PacketSender::DEFAULT_PACKET_SENDS_VALUE; } void MiLightClient::flushPacket() { PacketStream& stream = currentRemote->packetFormatter->buildPackets(); - updateResendCount(); while (stream.hasNext()) { - write(stream.next()); - - if (stream.hasNext()) { - delay(10); - } + packetSender.enqueue(stream.next(), currentRemote, repeatsOverride); } currentRemote->packetFormatter->reset(); } -/* - Register a callback for when packets are sent -*/ -void MiLightClient::onPacketSent(PacketSentHandler handler) { - this->packetSentHandler = handler; -} - void MiLightClient::onUpdateBegin(EventHandler handler) { this->updateBeginHandler = handler; } diff --git a/lib/MiLight/MiLightClient.h b/lib/MiLight/MiLightClient.h index 984cb6e3..8146335b 100644 --- a/lib/MiLight/MiLightClient.h +++ b/lib/MiLight/MiLightClient.h @@ -5,6 +5,11 @@ #include #include #include +#include +#include +#include +#include +#include #ifndef _MILIGHTCLIENT_H #define _MILIGHTCLIENT_H @@ -12,27 +17,41 @@ //#define DEBUG_PRINTF //#define DEBUG_CLIENT_COMMANDS // enable to show each individual change command (like hue, brightness, etc) -#define MILIGHT_DEFAULT_RESEND_COUNT 10 +#define FS(str) (reinterpret_cast(str)) + +namespace RequestKeys { + static const char TRANSITION[] = "transition"; +}; + +namespace TransitionParams { + static const char FIELD[] PROGMEM = "field"; + static const char START_VALUE[] PROGMEM = "start_value"; + static const char END_VALUE[] PROGMEM = "end_value"; + static const char DURATION[] PROGMEM = "duration"; + static const char PERIOD[] PROGMEM = "period"; + static const char NUM_PERIODS[] PROGMEM = "num_periods"; +} // Used to determine RGB colros that are approximately white #define RGB_WHITE_THRESHOLD 10 class MiLightClient { public: + // Used to indicate that the start value for a transition should be fetched from current state + static const int16_t FETCH_VALUE_FROM_STATE = -1; + MiLightClient( - MiLightRadioFactory* radioFactory, + RadioSwitchboard& radioSwitchboard, + PacketSender& packetSender, GroupStateStore* stateStore, - Settings* settings + Settings& settings, + TransitionController& transitions ); - ~MiLightClient() { - delete[] radios; - } + ~MiLightClient() { } - typedef std::function PacketSentHandler; typedef std::function EventHandler; - void begin(); void prepare(const MiLightRemoteConfig* remoteConfig, const uint16_t deviceId = -1, const uint8_t groupId = -1); void prepare(const MiLightRemoteType type, const uint16_t deviceId = -1, const uint8_t groupId = -1); @@ -54,6 +73,7 @@ class MiLightClient { void previousMode(); void modeSpeedDown(); void modeSpeedUp(); + void toggleStatus(); // RGBW methods void updateHue(const uint16_t hue); @@ -61,6 +81,7 @@ class MiLightClient { void updateColorWhite(); void updateColorRaw(const uint8_t color); void enableNightMode(); + void updateColor(JsonVariant json); // CCT methods void updateTemperature(const uint8_t colorTemperature); @@ -71,57 +92,54 @@ class MiLightClient { void updateSaturation(const uint8_t saturation); - void update(const JsonObject& object); - void handleCommand(const String& command); + void update(JsonObject object); + void handleCommand(JsonVariant command); + void handleCommands(JsonArray commands); + bool handleTransition(JsonObject args, JsonDocument& responseObj); + void handleTransition(GroupStateField field, JsonVariant value, float duration, int16_t startValue = FETCH_VALUE_FROM_STATE); void handleEffect(const String& effect); - void onPacketSent(PacketSentHandler handler); void onUpdateBegin(EventHandler handler); void onUpdateEnd(EventHandler handler); size_t getNumRadios() const; - MiLightRadio* switchRadio(size_t radioIx); + std::shared_ptr switchRadio(size_t radioIx); + std::shared_ptr switchRadio(const MiLightRemoteConfig* remoteConfig); MiLightRemoteConfig& currentRemoteConfig() const; -protected: + // Call to override the number of packet repeats that are sent. Clear with clearRepeatsOverride + void setRepeatsOverride(size_t repeatsOverride); + + // Clear the repeats override so that the default is used + void clearRepeatsOverride(); - MiLightRadio** radios; - MiLightRadio* currentRadio; + uint8_t parseStatus(JsonVariant object); + JsonVariant extractStatus(JsonObject object); + +protected: + struct cmp_str { + bool operator()(char const *a, char const *b) const { + return std::strcmp(a, b) < 0; + } + }; + static const std::map, cmp_str> FIELD_SETTERS; + static const char* FIELD_ORDERINGS[]; + + RadioSwitchboard& radioSwitchboard; + std::vector> radios; + std::shared_ptr currentRadio; const MiLightRemoteConfig* currentRemote; - const size_t numRadios; - GroupStateStore* stateStore; - const Settings* settings; - PacketSentHandler packetSentHandler; EventHandler updateBeginHandler; EventHandler updateEndHandler; - // Used to track auto repeat limiting - unsigned long lastSend; - int currentResendCount; - unsigned int baseResendCount; - - // This will be pre-computed, but is simply: - // - // (sensitivity / 1000.0) * R - // - // Where R is the base number of repeats. - size_t throttleMultiplier; - - /* - * Calculates the number of resend packets based on when the last packet - * was sent using this function: - * - * lastRepeatsValue + (millisSinceLastSend - THRESHOLD) * throttleMultiplier - * - * When the last send was more recent than THRESHOLD, the number of repeats - * will be decreased to a minimum of zero. When less recent, it will be - * increased up to a maximum of the default resend count. - */ - void updateResendCount(); - - MiLightRadio* switchRadio(const MiLightRemoteConfig* remoteConfig); - uint8_t parseStatus(const JsonObject& object); + GroupStateStore* stateStore; + Settings& settings; + PacketSender& packetSender; + TransitionController& transitions; + + // If set, override the number of packet repeats used. + size_t repeatsOverride; void flushPacket(); }; diff --git a/lib/MiLight/MiLightRemoteConfig.cpp b/lib/MiLight/MiLightRemoteConfig.cpp index 85b9a516..7db763b3 100644 --- a/lib/MiLight/MiLightRemoteConfig.cpp +++ b/lib/MiLight/MiLightRemoteConfig.cpp @@ -1,4 +1,5 @@ #include +#include /** * IMPORTANT NOTE: These should be in the same order as MiLightRemoteType. @@ -9,38 +10,14 @@ const MiLightRemoteConfig* MiLightRemoteConfig::ALL_REMOTES[] = { &FUT092Config, // rgb+cct &FUT098Config, // rgb &FUT089Config, // 8-group rgb+cct (b8, fut089) - &FUT091Config + &FUT091Config, + &FUT020Config }; -const MiLightRemoteConfig* MiLightRemoteConfig::fromType(const String& type) { - if (type.equalsIgnoreCase("rgbw") || type.equalsIgnoreCase("fut096")) { - return &FUT096Config; - } +const size_t MiLightRemoteConfig::NUM_REMOTES = size(ALL_REMOTES); - if (type.equalsIgnoreCase("cct") || type.equalsIgnoreCase("fut007")) { - return &FUT007Config; - } - - if (type.equalsIgnoreCase("rgb_cct") || type.equalsIgnoreCase("fut092")) { - return &FUT092Config; - } - - if (type.equalsIgnoreCase("fut089")) { - return &FUT089Config; - } - - if (type.equalsIgnoreCase("rgb") || type.equalsIgnoreCase("fut098")) { - return &FUT098Config; - } - - if (type.equalsIgnoreCase("v2_cct") || type.equalsIgnoreCase("fut091")) { - return &FUT091Config; - } - - Serial.print(F("MiLightRemoteConfig::fromType: ERROR - tried to fetch remote config for type: ")); - Serial.println(type); - - return NULL; +const MiLightRemoteConfig* MiLightRemoteConfig::fromType(const String& type) { + return fromType(MiLightRemoteTypeHelpers::remoteTypeFromString(type)); } const MiLightRemoteConfig* MiLightRemoteConfig::fromType(MiLightRemoteType type) { @@ -121,3 +98,11 @@ const MiLightRemoteConfig FUT098Config( //rgb "rgb", 0 ); + +const MiLightRemoteConfig FUT020Config( + new FUT020PacketFormatter(), + MiLightRadioConfig::ALL_CONFIGS[4], + REMOTE_TYPE_FUT020, + "fut020", + 0 +); \ No newline at end of file diff --git a/lib/MiLight/MiLightRemoteConfig.h b/lib/MiLight/MiLightRemoteConfig.h index 2fa6b51d..966bf16a 100644 --- a/lib/MiLight/MiLightRemoteConfig.h +++ b/lib/MiLight/MiLightRemoteConfig.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #ifndef _MILIGHT_REMOTE_CONFIG_H @@ -37,8 +38,8 @@ class MiLightRemoteConfig { static const MiLightRemoteConfig* fromType(const String& type); static const MiLightRemoteConfig* fromReceivedPacket(const MiLightRadioConfig& radioConfig, const uint8_t* packet, const size_t len); - static const size_t NUM_REMOTES = 6; - static const MiLightRemoteConfig* ALL_REMOTES[NUM_REMOTES]; + static const size_t NUM_REMOTES; + static const MiLightRemoteConfig* ALL_REMOTES[]; }; extern const MiLightRemoteConfig FUT096Config; //rgbw @@ -47,5 +48,6 @@ extern const MiLightRemoteConfig FUT092Config; //rgb+cct extern const MiLightRemoteConfig FUT089Config; //rgb+cct B8 / FUT089 extern const MiLightRemoteConfig FUT098Config; //rgb extern const MiLightRemoteConfig FUT091Config; //v2 cct +extern const MiLightRemoteConfig FUT020Config; #endif diff --git a/lib/MiLight/PacketFormatter.cpp b/lib/MiLight/PacketFormatter.cpp index 70458d4a..0b370d2f 100644 --- a/lib/MiLight/PacketFormatter.cpp +++ b/lib/MiLight/PacketFormatter.cpp @@ -19,8 +19,9 @@ uint8_t* PacketStream::next() { return packet; } -PacketFormatter::PacketFormatter(const size_t packetLength, const size_t maxPackets) - : packetLength(packetLength), +PacketFormatter::PacketFormatter(const MiLightRemoteType deviceType, const size_t packetLength, const size_t maxPackets) + : deviceType(deviceType), + packetLength(packetLength), numPackets(0), currentPacket(NULL), held(false) @@ -43,6 +44,16 @@ void PacketFormatter::updateStatus(MiLightStatus status) { updateStatus(status, groupId); } +void PacketFormatter::toggleStatus() { + const GroupState* state = stateStore->get(deviceId, groupId, deviceType); + + if (state && state->isSetState() && state->getState() == MiLightStatus::ON) { + updateStatus(MiLightStatus::OFF); + } else { + updateStatus(MiLightStatus::ON); + } +} + void PacketFormatter::setHeld(bool held) { this->held = held; } @@ -69,7 +80,7 @@ void PacketFormatter::enableNightMode() { } void PacketFormatter::updateTemperature(uint8_t value) { } void PacketFormatter::updateSaturation(uint8_t value) { } -BulbId PacketFormatter::parsePacket(const uint8_t *packet, JsonObject &result) { +BulbId PacketFormatter::parsePacket(const uint8_t *packet, JsonObject result) { return DEFAULT_BULB_ID; } @@ -113,6 +124,8 @@ void PacketFormatter::valueByStepFunction(StepFunction increase, StepFunction de } else if (targetValue > knownValue) { fn = increase; numCommands = (targetValue - knownValue); + } else { + return; } // Get to the desired value @@ -150,7 +163,7 @@ void PacketFormatter::pushPacket() { } void PacketFormatter::format(uint8_t const* packet, char* buffer) { - for (int i = 0; i < packetLength; i++) { + for (size_t i = 0; i < packetLength; i++) { sprintf_P(buffer, "%02X ", packet[i]); buffer += 3; } @@ -158,14 +171,18 @@ void PacketFormatter::format(uint8_t const* packet, char* buffer) { } void PacketFormatter::formatV1Packet(uint8_t const* packet, char* buffer) { - buffer += sprintf_P(buffer, "Request type : %02X\n", packet[0]) ; - buffer += sprintf_P(buffer, "Device ID : %02X%02X\n", packet[1], packet[2]); - buffer += sprintf_P(buffer, "b1 : %02X\n", packet[3]); - buffer += sprintf_P(buffer, "b2 : %02X\n", packet[4]); - buffer += sprintf_P(buffer, "b3 : %02X\n", packet[5]); - buffer += sprintf_P(buffer, "Sequence Num. : %02X", packet[6]); + buffer += sprintf_P(buffer, PSTR("Request type : %02X\n"), packet[0]) ; + buffer += sprintf_P(buffer, PSTR("Device ID : %02X%02X\n"), packet[1], packet[2]); + buffer += sprintf_P(buffer, PSTR("b1 : %02X\n"), packet[3]); + buffer += sprintf_P(buffer, PSTR("b2 : %02X\n"), packet[4]); + buffer += sprintf_P(buffer, PSTR("b3 : %02X\n"), packet[5]); + buffer += sprintf_P(buffer, PSTR("Sequence Num. : %02X"), packet[6]); } size_t PacketFormatter::getPacketLength() const { return packetLength; } + +BulbId PacketFormatter::currentBulbId() const { + return BulbId(deviceId, groupId, deviceType); +} \ No newline at end of file diff --git a/lib/MiLight/PacketFormatter.h b/lib/MiLight/PacketFormatter.h index f6e1bafe..9a4348e2 100644 --- a/lib/MiLight/PacketFormatter.h +++ b/lib/MiLight/PacketFormatter.h @@ -1,7 +1,7 @@ #include #include #include -#include +#include #include #include #include @@ -29,7 +29,7 @@ struct PacketStream { class PacketFormatter { public: - PacketFormatter(const size_t packetLength, const size_t maxPackets = 1); + PacketFormatter(const MiLightRemoteType deviceType, const size_t packetLength, const size_t maxPackets = 1); // Ideally these would be constructor parameters. We could accomplish this by // wrapping PacketFormaters in a factory, as Settings and StateStore are not @@ -43,6 +43,7 @@ class PacketFormatter { virtual bool canHandle(const uint8_t* packet, const size_t len); void updateStatus(MiLightStatus status); + void toggleStatus(); virtual void updateStatus(MiLightStatus status, uint8_t groupId); virtual void command(uint8_t command, uint8_t arg); @@ -82,20 +83,22 @@ class PacketFormatter { virtual void prepare(uint16_t deviceId, uint8_t groupId); virtual void format(uint8_t const* packet, char* buffer); - virtual BulbId parsePacket(const uint8_t* packet, JsonObject& result); + virtual BulbId parsePacket(const uint8_t* packet, JsonObject result); + virtual BulbId currentBulbId() const; static void formatV1Packet(uint8_t const* packet, char* buffer); size_t getPacketLength() const; protected: - uint8_t* currentPacket; + const MiLightRemoteType deviceType; size_t packetLength; + size_t numPackets; + uint8_t* currentPacket; + bool held; uint16_t deviceId; uint8_t groupId; uint8_t sequenceNum; - size_t numPackets; - bool held; PacketStream packetStream; GroupStateStore* stateStore = NULL; const Settings* settings = NULL; diff --git a/lib/MiLight/PacketQueue.cpp b/lib/MiLight/PacketQueue.cpp new file mode 100644 index 00000000..9b0749eb --- /dev/null +++ b/lib/MiLight/PacketQueue.cpp @@ -0,0 +1,42 @@ +#include + +PacketQueue::PacketQueue() + : droppedPackets(0) +{ } + +void PacketQueue::push(const uint8_t* packet, const MiLightRemoteConfig* remoteConfig, const size_t repeatsOverride) { + std::shared_ptr qp = checkoutPacket(); + memcpy(qp->packet, packet, remoteConfig->packetFormatter->getPacketLength()); + qp->remoteConfig = remoteConfig; + qp->repeatsOverride = repeatsOverride; +} + +bool PacketQueue::isEmpty() const { + return queue.size() == 0; +} + +size_t PacketQueue::getDroppedPacketCount() const { + return droppedPackets; +} + +std::shared_ptr PacketQueue::pop() { + return queue.shift(); +} + +std::shared_ptr PacketQueue::checkoutPacket() { + if (queue.size() == MILIGHT_MAX_QUEUED_PACKETS) { + ++droppedPackets; + return queue.getLast(); + } else { + std::shared_ptr packet = std::make_shared(); + queue.add(packet); + return packet; + } +} + +void PacketQueue::checkinPacket(std::shared_ptr packet) { +} + +size_t PacketQueue::size() const { + return queue.size(); +} \ No newline at end of file diff --git a/lib/MiLight/PacketQueue.h b/lib/MiLight/PacketQueue.h new file mode 100644 index 00000000..b802498b --- /dev/null +++ b/lib/MiLight/PacketQueue.h @@ -0,0 +1,36 @@ +#pragma once + +#include + +#include +#include +#include + +#ifndef MILIGHT_MAX_QUEUED_PACKETS +#define MILIGHT_MAX_QUEUED_PACKETS 20 +#endif + +struct QueuedPacket { + uint8_t packet[MILIGHT_MAX_PACKET_LENGTH]; + const MiLightRemoteConfig* remoteConfig; + size_t repeatsOverride; +}; + +class PacketQueue { +public: + PacketQueue(); + + void push(const uint8_t* packet, const MiLightRemoteConfig* remoteConfig, const size_t repeatsOverride); + std::shared_ptr pop(); + bool isEmpty() const; + size_t size() const; + size_t getDroppedPacketCount() const; + +private: + size_t droppedPackets; + + std::shared_ptr checkoutPacket(); + void checkinPacket(std::shared_ptr packet); + + LinkedList> queue; +}; \ No newline at end of file diff --git a/lib/MiLight/PacketSender.cpp b/lib/MiLight/PacketSender.cpp new file mode 100644 index 00000000..8b31a874 --- /dev/null +++ b/lib/MiLight/PacketSender.cpp @@ -0,0 +1,125 @@ +#include +#include + +PacketSender::PacketSender( + RadioSwitchboard& radioSwitchboard, + Settings& settings, + PacketSentHandler packetSentHandler +) : radioSwitchboard(radioSwitchboard) + , settings(settings) + , currentPacket(nullptr) + , packetRepeatsRemaining(0) + , packetSentHandler(packetSentHandler) + , lastSend(0) + , currentResendCount(settings.packetRepeats) + , throttleMultiplier( + std::ceil( + (settings.packetRepeatThrottleSensitivity / 1000.0) * settings.packetRepeats + ) + ) +{ } + +void PacketSender::enqueue(uint8_t* packet, const MiLightRemoteConfig* remoteConfig, const size_t repeatsOverride) { +#ifdef DEBUG_PRINTF + Serial.println("Enqueuing packet"); +#endif + size_t repeats = repeatsOverride == DEFAULT_PACKET_SENDS_VALUE + ? this->currentResendCount + : repeatsOverride; + + queue.push(packet, remoteConfig, repeats); +} + +void PacketSender::loop() { + // Switch to the next packet if we're done with the current one + if (packetRepeatsRemaining == 0 && !queue.isEmpty()) { + nextPacket(); + } + + // If there's a packet we're handling, deal with it + if (currentPacket != nullptr && packetRepeatsRemaining > 0) { + handleCurrentPacket(); + } +} + +bool PacketSender::isSending() { + return packetRepeatsRemaining > 0 || !queue.isEmpty(); +} + +void PacketSender::nextPacket() { +#ifdef DEBUG_PRINTF + Serial.printf("Switching to next packet, %d packets in queue\n", queue.size()); +#endif + currentPacket = queue.pop(); + + if (currentPacket->repeatsOverride > 0) { + packetRepeatsRemaining = currentPacket->repeatsOverride; + } else { + packetRepeatsRemaining = settings.packetRepeats; + } + + // Adjust resend count according to throttling rules + updateResendCount(); +} + +void PacketSender::handleCurrentPacket() { + // Always switch radio. could've been listening in another context + radioSwitchboard.switchRadio(currentPacket->remoteConfig); + + size_t numToSend = std::min(packetRepeatsRemaining, settings.packetRepeatsPerLoop); + sendRepeats(numToSend); + packetRepeatsRemaining -= numToSend; + + // If we're done sending this packet, fire the sent packet callback + if (packetRepeatsRemaining == 0 && packetSentHandler != nullptr) { + packetSentHandler(currentPacket->packet, *currentPacket->remoteConfig); + } +} + +size_t PacketSender::queueLength() const { + return queue.size(); +} + +size_t PacketSender::droppedPackets() const { + return queue.getDroppedPacketCount(); +} + +void PacketSender::sendRepeats(size_t num) { + size_t len = currentPacket->remoteConfig->packetFormatter->getPacketLength(); + +#ifdef DEBUG_PRINTF + Serial.printf_P(PSTR("Sending packet (%d repeats): \n"), num); + for (size_t i = 0; i < len; i++) { + Serial.printf_P(PSTR("%02X "), currentPacket->packet[i]); + } + Serial.println(); + int iStart = millis(); +#endif + + for (size_t i = 0; i < num; ++i) { + radioSwitchboard.write(currentPacket->packet, len); + } + +#ifdef DEBUG_PRINTF + int iElapsed = millis() - iStart; + Serial.print("Elapsed: "); + Serial.println(iElapsed); +#endif +} + +void PacketSender::updateResendCount() { + unsigned long now = millis(); + long millisSinceLastSend = now - lastSend; + long x = (millisSinceLastSend - settings.packetRepeatThrottleThreshold); + long delta = x * throttleMultiplier; + int signedResends = static_cast(this->currentResendCount) + delta; + + if (signedResends < static_cast(settings.packetRepeatMinimum)) { + signedResends = settings.packetRepeatMinimum; + } else if (signedResends > static_cast(settings.packetRepeats)) { + signedResends = settings.packetRepeats; + } + + this->currentResendCount = signedResends; + this->lastSend = now; +} \ No newline at end of file diff --git a/lib/MiLight/PacketSender.h b/lib/MiLight/PacketSender.h new file mode 100644 index 00000000..916149b9 --- /dev/null +++ b/lib/MiLight/PacketSender.h @@ -0,0 +1,74 @@ +#pragma once + +#include +#include +#include +#include + +class PacketSender { +public: + typedef std::function PacketSentHandler; + static const size_t DEFAULT_PACKET_SENDS_VALUE = 0; + + PacketSender( + RadioSwitchboard& radioSwitchboard, + Settings& settings, + PacketSentHandler packetSentHandler + ); + + void enqueue(uint8_t* packet, const MiLightRemoteConfig* remoteConfig, const size_t repeatsOverride = 0); + void loop(); + + // Return true if there are queued packets + bool isSending(); + + // Return the number of queued packets + size_t queueLength() const; + size_t droppedPackets() const; + +private: + RadioSwitchboard& radioSwitchboard; + Settings& settings; + GroupStateStore* stateStore; + PacketQueue queue; + + // The current packet we're sending and the number of repeats left + std::shared_ptr currentPacket; + size_t packetRepeatsRemaining; + + // Handler called after packets are sent. Will not be called multiple times + // per repeat. + PacketSentHandler packetSentHandler; + + // Send a batch of repeats for the current packet + void handleCurrentPacket(); + + // Switch to the next packet in the queue + void nextPacket(); + + // Send repeats of the current packet N times + void sendRepeats(size_t num); + + // Used to track auto repeat limiting + unsigned long lastSend; + uint8_t currentResendCount; + + // This will be pre-computed, but is simply: + // + // (sensitivity / 1000.0) * R + // + // Where R is the base number of repeats. + size_t throttleMultiplier; + + /* + * Calculates the number of resend packets based on when the last packet + * was sent using this function: + * + * lastRepeatsValue + (millisSinceLastSend - THRESHOLD) * throttleMultiplier + * + * When the last send was more recent than THRESHOLD, the number of repeats + * will be decreased to a minimum of zero. When less recent, it will be + * increased up to a maximum of the default resend count. + */ + void updateResendCount(); +}; \ No newline at end of file diff --git a/lib/MiLight/RadioSwitchboard.cpp b/lib/MiLight/RadioSwitchboard.cpp new file mode 100644 index 00000000..a79f8a11 --- /dev/null +++ b/lib/MiLight/RadioSwitchboard.cpp @@ -0,0 +1,74 @@ +#include + +RadioSwitchboard::RadioSwitchboard( + std::shared_ptr radioFactory, + GroupStateStore* stateStore, + Settings& settings +) { + for (size_t i = 0; i < MiLightRadioConfig::NUM_CONFIGS; i++) { + std::shared_ptr radio = radioFactory->create(MiLightRadioConfig::ALL_CONFIGS[i]); + radio->begin(); + radios.push_back(radio); + } + + for (size_t i = 0; i < MiLightRemoteConfig::NUM_REMOTES; i++) { + MiLightRemoteConfig::ALL_REMOTES[i]->packetFormatter->initialize(stateStore, &settings); + } +} + +size_t RadioSwitchboard::getNumRadios() const { + return radios.size(); +} + +std::shared_ptr RadioSwitchboard::switchRadio(size_t radioIx) { + if (radioIx >= getNumRadios()) { + return NULL; + } + + if (this->currentRadio != radios[radioIx]) { + this->currentRadio = radios[radioIx]; + this->currentRadio->configure(); + } + + return this->currentRadio; +} + +std::shared_ptr RadioSwitchboard::switchRadio(const MiLightRemoteConfig* remote) { + std::shared_ptr radio = NULL; + + for (size_t i = 0; i < radios.size(); i++) { + if (&this->radios[i]->config() == &remote->radioConfig) { + radio = switchRadio(i); + break; + } + } + + return radio; +} + +void RadioSwitchboard::write(uint8_t* packet, size_t len) { + if (this->currentRadio == nullptr) { + return; + } + + this->currentRadio->write(packet, len); +} + +size_t RadioSwitchboard::read(uint8_t* packet) { + if (currentRadio == nullptr) { + return 0; + } + + size_t length; + currentRadio->read(packet, length); + + return length; +} + +bool RadioSwitchboard::available() { + if (currentRadio == nullptr) { + return false; + } + + return currentRadio->available(); +} \ No newline at end of file diff --git a/lib/MiLight/RadioSwitchboard.h b/lib/MiLight/RadioSwitchboard.h new file mode 100644 index 00000000..00fe2d8d --- /dev/null +++ b/lib/MiLight/RadioSwitchboard.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include +#include +#include + +class RadioSwitchboard { +public: + RadioSwitchboard( + std::shared_ptr radioFactory, + GroupStateStore* stateStore, + Settings& settings + ); + + std::shared_ptr switchRadio(const MiLightRemoteConfig* remote); + std::shared_ptr switchRadio(size_t index); + size_t getNumRadios() const; + + bool available(); + void write(uint8_t* packet, size_t length); + size_t read(uint8_t* packet); + +private: + std::vector> radios; + std::shared_ptr currentRadio; +}; \ No newline at end of file diff --git a/lib/MiLight/RgbCctPacketFormatter.cpp b/lib/MiLight/RgbCctPacketFormatter.cpp index e9f49c70..5fe4c2f6 100644 --- a/lib/MiLight/RgbCctPacketFormatter.cpp +++ b/lib/MiLight/RgbCctPacketFormatter.cpp @@ -1,6 +1,7 @@ #include #include #include +#include void RgbCctPacketFormatter::modeSpeedDown() { command(RGB_CCT_ON, RGB_CCT_MODE_SPEED_DOWN); @@ -64,10 +65,10 @@ void RgbCctPacketFormatter::updateTemperature(uint8_t value) { // update saturation. This only works when in Color mode, so if not in color we switch to color, // make the change, and switch back again. void RgbCctPacketFormatter::updateSaturation(uint8_t value) { - // look up our current mode + // look up our current mode const GroupState* ourState = this->stateStore->get(this->deviceId, this->groupId, REMOTE_TYPE_RGB_CCT); - BulbMode originalBulbMode; - + BulbMode originalBulbMode = BulbMode::BULB_MODE_WHITE; + if (ourState != NULL) { originalBulbMode = ourState->getBulbMode(); @@ -90,11 +91,11 @@ void RgbCctPacketFormatter::updateSaturation(uint8_t value) { void RgbCctPacketFormatter::updateColorWhite() { // there is no direct white command, so let's look up our prior temperature and set that, which - // causes the bulb to go white + // causes the bulb to go white const GroupState* ourState = this->stateStore->get(this->deviceId, this->groupId, REMOTE_TYPE_RGB_CCT); - uint8_t value = - ourState == NULL - ? 0 + uint8_t value = + ourState == NULL + ? 0 : V2PacketFormatter::tov2scale(ourState->getKelvin(), RGB_CCT_KELVIN_REMOTE_END, 2); // issue command to set kelvin to prior value, which will drive to white @@ -106,7 +107,7 @@ void RgbCctPacketFormatter::enableNightMode() { command(RGB_CCT_ON | 0x80, arg); } -BulbId RgbCctPacketFormatter::parsePacket(const uint8_t *packet, JsonObject& result) { +BulbId RgbCctPacketFormatter::parsePacket(const uint8_t *packet, JsonObject result) { uint8_t packetCopy[V2_PACKET_LEN]; memcpy(packetCopy, packet, V2_PACKET_LEN); V2RFEncoding::decodeV2Packet(packetCopy); @@ -122,33 +123,33 @@ BulbId RgbCctPacketFormatter::parsePacket(const uint8_t *packet, JsonObject& res if (command == RGB_CCT_ON) { if ((packetCopy[V2_COMMAND_INDEX] & 0x80) == 0x80) { - result["command"] = "night_mode"; + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::NIGHT_MODE; } else if (arg == RGB_CCT_MODE_SPEED_DOWN) { - result["command"] = "mode_speed_down"; + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::MODE_SPEED_DOWN; } else if (arg == RGB_CCT_MODE_SPEED_UP) { - result["command"] = "mode_speed_up"; + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::MODE_SPEED_UP; } else if (arg < 5) { // Group is not reliably encoded in group byte. Extract from arg byte - result["state"] = "ON"; + result[GroupStateFieldNames::STATE] = "ON"; bulbId.groupId = arg; } else { - result["state"] = "OFF"; + result[GroupStateFieldNames::STATE] = "OFF"; bulbId.groupId = arg-5; } } else if (command == RGB_CCT_COLOR) { uint8_t rescaledColor = (arg - RGB_CCT_COLOR_OFFSET) % 0x100; uint16_t hue = Units::rescale(rescaledColor, 360, 255.0); - result["hue"] = hue; + result[GroupStateFieldNames::HUE] = hue; } else if (command == RGB_CCT_KELVIN) { uint8_t temperature = V2PacketFormatter::fromv2scale(arg, RGB_CCT_KELVIN_REMOTE_END, 2); - result["color_temp"] = Units::whiteValToMireds(temperature, 100); + result[GroupStateFieldNames::COLOR_TEMP] = Units::whiteValToMireds(temperature, 100); // brightness == saturation } else if (command == RGB_CCT_BRIGHTNESS && arg >= (RGB_CCT_BRIGHTNESS_OFFSET - 15)) { uint8_t level = constrain(arg - RGB_CCT_BRIGHTNESS_OFFSET, 0, 100); - result["brightness"] = Units::rescale(level, 255, 100); + result[GroupStateFieldNames::BRIGHTNESS] = Units::rescale(level, 255, 100); } else if (command == RGB_CCT_SATURATION) { - result["saturation"] = constrain(arg - RGB_CCT_SATURATION_OFFSET, 0, 100); + result[GroupStateFieldNames::SATURATION] = constrain(arg - RGB_CCT_SATURATION_OFFSET, 0, 100); } else if (command == RGB_CCT_MODE) { - result["mode"] = arg; + result[GroupStateFieldNames::MODE] = arg; } else { result["button_id"] = command; result["argument"] = arg; diff --git a/lib/MiLight/RgbCctPacketFormatter.h b/lib/MiLight/RgbCctPacketFormatter.h index dc840ed9..02f1d411 100644 --- a/lib/MiLight/RgbCctPacketFormatter.h +++ b/lib/MiLight/RgbCctPacketFormatter.h @@ -32,7 +32,7 @@ enum MiLightRgbCctArguments { class RgbCctPacketFormatter : public V2PacketFormatter { public: RgbCctPacketFormatter() - : V2PacketFormatter(0x20, 4), + : V2PacketFormatter(REMOTE_TYPE_RGB_CCT, 0x20, 4), lastMode(0) { } @@ -50,7 +50,7 @@ class RgbCctPacketFormatter : public V2PacketFormatter { virtual void nextMode(); virtual void previousMode(); - virtual BulbId parsePacket(const uint8_t* packet, JsonObject& result); + virtual BulbId parsePacket(const uint8_t* packet, JsonObject result); protected: diff --git a/lib/MiLight/RgbPacketFormatter.cpp b/lib/MiLight/RgbPacketFormatter.cpp index 2cee44bb..5f3b29b7 100644 --- a/lib/MiLight/RgbPacketFormatter.cpp +++ b/lib/MiLight/RgbPacketFormatter.cpp @@ -1,5 +1,6 @@ #include #include +#include void RgbPacketFormatter::initializePacket(uint8_t *packet) { size_t packetPtr = 0; @@ -83,7 +84,7 @@ void RgbPacketFormatter::previousMode() { command(RGB_MODE_DOWN, 0); } -BulbId RgbPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& result) { +BulbId RgbPacketFormatter::parsePacket(const uint8_t* packet, JsonObject result) { uint8_t command = packet[RGB_COMMAND_INDEX] & 0x7F; BulbId bulbId( @@ -93,25 +94,25 @@ BulbId RgbPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& result ); if (command == RGB_ON) { - result["state"] = "ON"; + result[GroupStateFieldNames::STATE] = "ON"; } else if (command == RGB_OFF) { - result["state"] = "OFF"; + result[GroupStateFieldNames::STATE] = "OFF"; } else if (command == 0) { uint16_t remappedColor = Units::rescale(packet[RGB_COLOR_INDEX], 360.0, 255.0); remappedColor = (remappedColor + 320) % 360; - result["hue"] = remappedColor; + result[GroupStateFieldNames::HUE] = remappedColor; } else if (command == RGB_MODE_DOWN) { - result["command"] = "previous_mode"; + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::PREVIOUS_MODE; } else if (command == RGB_MODE_UP) { - result["command"] = "next_mode"; + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::NEXT_MODE; } else if (command == RGB_SPEED_DOWN) { - result["command"] = "mode_speed_down"; + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::MODE_SPEED_DOWN; } else if (command == RGB_SPEED_UP) { - result["command"] = "mode_speed_up"; + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::MODE_SPEED_UP; } else if (command == RGB_BRIGHTNESS_DOWN) { - result["command"] = "brightness_down"; + result[GroupStateFieldNames::COMMAND] = "brightness_down"; } else if (command == RGB_BRIGHTNESS_UP) { - result["command"] = "brightness_up"; + result[GroupStateFieldNames::COMMAND] = "brightness_up"; } else { result["button_id"] = command; } @@ -120,9 +121,9 @@ BulbId RgbPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& result } void RgbPacketFormatter::format(uint8_t const* packet, char* buffer) { - buffer += sprintf_P(buffer, "b0 : %02X\n", packet[0]); - buffer += sprintf_P(buffer, "ID : %02X%02X\n", packet[1], packet[2]); - buffer += sprintf_P(buffer, "Color : %02X\n", packet[3]); - buffer += sprintf_P(buffer, "Command : %02X\n", packet[4]); - buffer += sprintf_P(buffer, "Sequence : %02X\n", packet[5]); + buffer += sprintf_P(buffer, PSTR("b0 : %02X\n"), packet[0]); + buffer += sprintf_P(buffer, PSTR("ID : %02X%02X\n"), packet[1], packet[2]); + buffer += sprintf_P(buffer, PSTR("Color : %02X\n"), packet[3]); + buffer += sprintf_P(buffer, PSTR("Command : %02X\n"), packet[4]); + buffer += sprintf_P(buffer, PSTR("Sequence : %02X\n"), packet[5]); } diff --git a/lib/MiLight/RgbPacketFormatter.h b/lib/MiLight/RgbPacketFormatter.h index aa55577f..e0cd9e80 100644 --- a/lib/MiLight/RgbPacketFormatter.h +++ b/lib/MiLight/RgbPacketFormatter.h @@ -22,7 +22,7 @@ enum MiLightRgbButton { class RgbPacketFormatter : public PacketFormatter { public: RgbPacketFormatter() - : PacketFormatter(6, 20) + : PacketFormatter(REMOTE_TYPE_RGB, 6, 20) { } virtual void updateStatus(MiLightStatus status, uint8_t groupId); @@ -39,7 +39,7 @@ class RgbPacketFormatter : public PacketFormatter { virtual void modeSpeedUp(); virtual void nextMode(); virtual void previousMode(); - virtual BulbId parsePacket(const uint8_t* packet, JsonObject& result); + virtual BulbId parsePacket(const uint8_t* packet, JsonObject result); virtual void initializePacket(uint8_t* packet); }; diff --git a/lib/MiLight/RgbwPacketFormatter.cpp b/lib/MiLight/RgbwPacketFormatter.cpp index 4aad4c91..4ec04cf1 100644 --- a/lib/MiLight/RgbwPacketFormatter.cpp +++ b/lib/MiLight/RgbwPacketFormatter.cpp @@ -1,5 +1,6 @@ #include #include +#include #define STATUS_COMMAND(status, groupId) ( RGBW_GROUP_1_ON + (((groupId) - 1)*2) + (status) ) #define GROUP_FOR_STATUS_COMMAND(buttonId) ( ((buttonId) - 1) / 2 ) @@ -96,11 +97,19 @@ void RgbwPacketFormatter::updateColorWhite() { void RgbwPacketFormatter::enableNightMode() { uint8_t button = STATUS_COMMAND(OFF, groupId); - //command(button, 0); + // Bulbs must be OFF for night mode to work in RGBW. + // Turn it off if it isn't already off. + const GroupState* state = stateStore->get(deviceId, groupId, REMOTE_TYPE_RGBW); + if (state == NULL || state->getState() == MiLightStatus::ON) { + //command(button, 0); + } + + // Night mode command has 0x10 bit set, but is otherwise + // a repeat of the OFF command. command(button | 0x10, 0); } -BulbId RgbwPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& result) { +BulbId RgbwPacketFormatter::parsePacket(const uint8_t* packet, JsonObject result) { uint8_t command = packet[RGBW_COMMAND_INDEX] & 0x7F; BulbId bulbId( @@ -110,18 +119,17 @@ BulbId RgbwPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& resul ); if (command >= RGBW_ALL_ON && command <= RGBW_GROUP_4_OFF) { - result["state"] = (STATUS_FOR_COMMAND(command) == ON) ? "ON" : "OFF"; + result[GroupStateFieldNames::STATE] = (STATUS_FOR_COMMAND(command) == ON) ? "ON" : "OFF"; // Determine group ID from button ID for on/off. The remote's state is from // the last packet sent, not the current one, and that can be wrong for // on/off commands. bulbId.groupId = GROUP_FOR_STATUS_COMMAND(command); - } else if (command >= RGBW_ALL_MAX_LEVEL && command <= RGBW_GROUP_4_MIN_LEVEL) { + } else if (command & 0x10) { if ((command % 2) == 0) { - result["state"] = "ON"; - result["command"] = "night_mode"; + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::NIGHT_MODE; } else { - result["command"] = "set_white"; + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::SET_WHITE; } bulbId.groupId = GROUP_FOR_STATUS_COMMAND(command & 0xF); } else if (command == RGBW_BRIGHTNESS) { @@ -129,17 +137,17 @@ BulbId RgbwPacketFormatter::parsePacket(const uint8_t* packet, JsonObject& resul brightness -= packet[RGBW_BRIGHTNESS_GROUP_INDEX] >> 3; brightness += 17; brightness %= 32; - result["brightness"] = Units::rescale(brightness, 255, 25); + result[GroupStateFieldNames::BRIGHTNESS] = Units::rescale(brightness, 255, 25); } else if (command == RGBW_COLOR) { uint16_t remappedColor = Units::rescale(packet[RGBW_COLOR_INDEX], 360.0, 255.0); remappedColor = (remappedColor + 320) % 360; - result["hue"] = remappedColor; + result[GroupStateFieldNames::HUE] = remappedColor; } else if (command == RGBW_SPEED_DOWN) { - result["command"] = "mode_speed_down"; + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::MODE_SPEED_DOWN; } else if (command == RGBW_SPEED_UP) { - result["command"] = "mode_speed_up"; + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::MODE_SPEED_UP; } else if (command == RGBW_DISCO_MODE) { - result["mode"] = packet[0] & ~RGBW_PROTOCOL_ID_BYTE; + result[GroupStateFieldNames::MODE] = packet[0] & ~RGBW_PROTOCOL_ID_BYTE; } else { result["button_id"] = command; } diff --git a/lib/MiLight/RgbwPacketFormatter.h b/lib/MiLight/RgbwPacketFormatter.h index 6fd78aff..93e7b59f 100644 --- a/lib/MiLight/RgbwPacketFormatter.h +++ b/lib/MiLight/RgbwPacketFormatter.h @@ -52,7 +52,7 @@ enum MiLightRgbwButton { class RgbwPacketFormatter : public PacketFormatter { public: RgbwPacketFormatter() - : PacketFormatter(7) + : PacketFormatter(REMOTE_TYPE_RGBW, 7) { } virtual bool canHandle(const uint8_t* packet, const size_t len); @@ -70,7 +70,7 @@ class RgbwPacketFormatter : public PacketFormatter { virtual void previousMode(); virtual void updateMode(uint8_t mode); virtual void enableNightMode(); - virtual BulbId parsePacket(const uint8_t* packet, JsonObject& result); + virtual BulbId parsePacket(const uint8_t* packet, JsonObject result); virtual void initializePacket(uint8_t* packet); diff --git a/lib/MiLight/V2PacketFormatter.cpp b/lib/MiLight/V2PacketFormatter.cpp index a9460a80..633ac241 100644 --- a/lib/MiLight/V2PacketFormatter.cpp +++ b/lib/MiLight/V2PacketFormatter.cpp @@ -3,8 +3,8 @@ #define GROUP_COMMAND_ARG(status, groupId, numGroups) ( groupId + (status == OFF ? (numGroups + 1) : 0) ) -V2PacketFormatter::V2PacketFormatter(uint8_t protocolId, uint8_t numGroups) - : PacketFormatter(9), +V2PacketFormatter::V2PacketFormatter(const MiLightRemoteType deviceType, uint8_t protocolId, uint8_t numGroups) + : PacketFormatter(deviceType, 9), protocolId(protocolId), numGroups(numGroups) { } @@ -13,6 +13,11 @@ bool V2PacketFormatter::canHandle(const uint8_t *packet, const size_t packetLen) uint8_t packetCopy[V2_PACKET_LEN]; memcpy(packetCopy, packet, V2_PACKET_LEN); V2RFEncoding::decodeV2Packet(packetCopy); + +#ifdef DEBUG_PRINTF + Serial.printf_P(PSTR("Testing whether formater for ID %d can handle packet: with protocol ID %d...\n"), protocolId, packetCopy[V2_PROTOCOL_ID_INDEX]); +#endif + return packetCopy[V2_PROTOCOL_ID_INDEX] == protocolId; } @@ -57,7 +62,7 @@ void V2PacketFormatter::finalizePacket(uint8_t* packet) { void V2PacketFormatter::format(uint8_t const* packet, char* buffer) { buffer += sprintf_P(buffer, PSTR("Raw packet: ")); - for (int i = 0; i < packetLength; i++) { + for (size_t i = 0; i < packetLength; i++) { buffer += sprintf_P(buffer, PSTR("%02X "), packet[i]); } @@ -101,7 +106,7 @@ void V2PacketFormatter::switchMode(const GroupState& currentState, BulbMode desi Serial.printf_P(PSTR("V2PacketFormatter::switchMode: Request to switch to unknown mode %d\n"), desiredMode); break; } - + } uint8_t V2PacketFormatter::tov2scale(uint8_t value, uint8_t endValue, uint8_t interval, bool reverse) { @@ -113,10 +118,19 @@ uint8_t V2PacketFormatter::tov2scale(uint8_t value, uint8_t endValue, uint8_t in } uint8_t V2PacketFormatter::fromv2scale(uint8_t value, uint8_t endValue, uint8_t interval, bool reverse, uint8_t buffer) { - value = (((value + (0x100 - endValue))%0x100) / interval); + value -= endValue; + + // Deal with underflow + if (value >= (0xFF - buffer)) { + value = 0; + } + + value /= interval; + if (reverse) { value = 100 - value; } + if (value > 100) { // overflow if (value <= (100 + buffer)) { diff --git a/lib/MiLight/V2PacketFormatter.h b/lib/MiLight/V2PacketFormatter.h index e5b2331b..284416ee 100644 --- a/lib/MiLight/V2PacketFormatter.h +++ b/lib/MiLight/V2PacketFormatter.h @@ -15,7 +15,7 @@ class V2PacketFormatter : public PacketFormatter { public: - V2PacketFormatter(uint8_t protocolId, uint8_t numGroups); + V2PacketFormatter(const MiLightRemoteType deviceType, uint8_t protocolId, uint8_t numGroups); virtual bool canHandle(const uint8_t* packet, const size_t packetLen); virtual void initializePacket(uint8_t* packet); diff --git a/lib/MiLightState/GroupState.cpp b/lib/MiLightState/GroupState.cpp index 1f26de25..cd7a65fe 100644 --- a/lib/MiLightState/GroupState.cpp +++ b/lib/MiLightState/GroupState.cpp @@ -2,73 +2,71 @@ #include #include #include +#include +#include + +static const char* BULB_MODE_NAMES[] = { + "white", + "color", + "scene", + "night" +}; const BulbId DEFAULT_BULB_ID; -static const GroupStateField ALL_PHYSICAL_FIELDS[] = { - GroupStateField::BRIGHTNESS, + +const GroupStateField GroupState::ALL_PHYSICAL_FIELDS[] = { GroupStateField::BULB_MODE, GroupStateField::HUE, GroupStateField::KELVIN, GroupStateField::MODE, GroupStateField::SATURATION, - GroupStateField::STATE + GroupStateField::STATE, + GroupStateField::BRIGHTNESS +}; + +static const GroupStateField ALL_SCRATCH_FIELDS[] = { + GroupStateField::BRIGHTNESS, + GroupStateField::KELVIN }; // Number of units each increment command counts for static const uint8_t INCREMENT_COMMAND_VALUE = 10; -const GroupState& GroupState::defaultState(MiLightRemoteType remoteType) { - static GroupState instances[MiLightRemoteConfig::NUM_REMOTES]; - GroupState& state = instances[remoteType]; +static const GroupState DEFAULT_STATE = GroupState(); +static const GroupState DEFAULT_RGB_ONLY_STATE = GroupState::initDefaultRgbState(); +static const GroupState DEFAULT_WHITE_ONLY_STATE = GroupState::initDefaultWhiteState(); + +GroupState GroupState::initDefaultRgbState() { + GroupState state; + state.setBulbMode(BULB_MODE_COLOR); + return state; +} + +GroupState GroupState::initDefaultWhiteState() { + GroupState state; + state.setBulbMode(BULB_MODE_WHITE); + return state; +} +const GroupState& GroupState::defaultState(MiLightRemoteType remoteType) { switch (remoteType) { case REMOTE_TYPE_RGB: - state.setBulbMode(BULB_MODE_COLOR); + return DEFAULT_RGB_ONLY_STATE; break; case REMOTE_TYPE_CCT: - state.setBulbMode(BULB_MODE_WHITE); + case REMOTE_TYPE_FUT091: + return DEFAULT_WHITE_ONLY_STATE; break; - } - - return state; -} - -BulbId::BulbId() - : deviceId(0), - groupId(0), - deviceType(REMOTE_TYPE_UNKNOWN) -{ } -BulbId::BulbId(const BulbId &other) - : deviceId(other.deviceId), - groupId(other.groupId), - deviceType(other.deviceType) -{ } - -BulbId::BulbId( - const uint16_t deviceId, const uint8_t groupId, const MiLightRemoteType deviceType -) - : deviceId(deviceId), - groupId(groupId), - deviceType(deviceType) -{ } - -void BulbId::operator=(const BulbId &other) { - deviceId = other.deviceId; - groupId = other.groupId; - deviceType = other.deviceType; -} + default: + // No modifications needed + break; + } -// determine if now BulbId's are the same. This compared deviceID (the controller/remote ID) and -// groupId (the group number on the controller, 1-4 or 1-8 depending), but ignores the deviceType -// (type of controller/remote) as this doesn't directly affect the identity of the bulb -bool BulbId::operator==(const BulbId &other) { - return deviceId == other.deviceId - && groupId == other.groupId - && deviceType == other.deviceType; + return DEFAULT_STATE; } -GroupState::GroupState() { +void GroupState::initFields() { state.fields._state = 0; state.fields._brightness = 0; state.fields._brightnessColor = 0; @@ -101,13 +99,34 @@ GroupState::GroupState() { GroupState& GroupState::operator=(const GroupState& other) { memcpy(state.rawData, other.state.rawData, DATA_LONGS * sizeof(uint32_t)); scratchpad.rawData = other.scratchpad.rawData; + return *this; } -GroupState::GroupState(const GroupState& other) { +GroupState::GroupState() + : previousState(NULL) +{ + initFields(); +} + +GroupState::GroupState(const GroupState& other) + : previousState(NULL) +{ memcpy(state.rawData, other.state.rawData, DATA_LONGS * sizeof(uint32_t)); scratchpad.rawData = other.scratchpad.rawData; } +GroupState::GroupState(const GroupState* previousState, JsonObject jsonState) + : previousState(previousState) +{ + initFields(); + + if (previousState != NULL) { + this->scratchpad = previousState->scratchpad; + } + + patch(jsonState); +} + bool GroupState::operator==(const GroupState& other) const { return memcmp(state.rawData, other.state.rawData, DATA_LONGS * sizeof(uint32_t)) == 0; } @@ -128,6 +147,69 @@ void GroupState::print(Stream& stream) const { stream.printf("State: %08X %08X\n", state.rawData[0], state.rawData[1]); } +bool GroupState::clearField(GroupStateField field) { + bool clearedAny = false; + + switch (field) { + // Always set and can't be cleared + case GroupStateField::COMPUTED_COLOR: + case GroupStateField::DEVICE_ID: + case GroupStateField::GROUP_ID: + case GroupStateField::DEVICE_TYPE: + break; + + case GroupStateField::STATE: + case GroupStateField::STATUS: + clearedAny = isSetState(); + state.fields._isSetState = 0; + break; + + case GroupStateField::BRIGHTNESS: + case GroupStateField::LEVEL: + clearedAny = clearBrightness(); + break; + + case GroupStateField::COLOR: + case GroupStateField::HUE: + case GroupStateField::OH_COLOR: + case GroupStateField::HEX_COLOR: + clearedAny = isSetHue(); + state.fields._isSetHue = 0; + break; + + case GroupStateField::SATURATION: + clearedAny = isSetSaturation(); + state.fields._isSetSaturation = 0; + break; + + case GroupStateField::MODE: + case GroupStateField::EFFECT: + clearedAny = isSetMode(); + state.fields._isSetMode = 0; + break; + + case GroupStateField::KELVIN: + case GroupStateField::COLOR_TEMP: + clearedAny = isSetKelvin(); + state.fields._isSetKelvin = 0; + break; + + case GroupStateField::BULB_MODE: + clearedAny = isSetBulbMode(); + state.fields._isSetBulbMode = 0; + + // Clear brightness as well + clearedAny = clearBrightness() || clearedAny; + break; + + default: + Serial.printf_P(PSTR("Attempted to clear unknown field: %d\n"), static_cast(field)); + break; + } + + return clearedAny; +} + bool GroupState::isSetField(GroupStateField field) const { switch (field) { case GroupStateField::COMPUTED_COLOR: @@ -146,6 +228,8 @@ bool GroupState::isSetField(GroupStateField field) const { return isSetBrightness(); case GroupStateField::COLOR: case GroupStateField::HUE: + case GroupStateField::OH_COLOR: + case GroupStateField::HEX_COLOR: return isSetHue(); case GroupStateField::SATURATION: return isSetSaturation(); @@ -158,11 +242,12 @@ bool GroupState::isSetField(GroupStateField field) const { return isSetKelvin(); case GroupStateField::BULB_MODE: return isSetBulbMode(); + default: + Serial.print(F("WARNING: tried to check if unknown field was set: ")); + Serial.println(static_cast(field)); + break; } - Serial.print(F("WARNING: tried to check if unknown field was set: ")); - Serial.println(static_cast(field)); - return false; } @@ -172,11 +257,12 @@ bool GroupState::isSetScratchField(GroupStateField field) const { return scratchpad.fields._isSetBrightnessScratch; case GroupStateField::KELVIN: return scratchpad.fields._isSetKelvinScratch; + default: + Serial.print(F("WARNING: tried to check if unknown scratch field was set: ")); + Serial.println(static_cast(field)); + break; } - Serial.print(F("WARNING: tried to check if unknown scratch field was set: ")); - Serial.println(static_cast(field)); - return false; } @@ -197,25 +283,40 @@ uint16_t GroupState::getFieldValue(GroupStateField field) const { return getKelvin(); case GroupStateField::BULB_MODE: return getBulbMode(); + default: + Serial.print(F("WARNING: tried to fetch value for unknown field: ")); + Serial.println(static_cast(field)); + break; } - Serial.print(F("WARNING: tried to fetch value for unknown field: ")); - Serial.println(static_cast(field)); - return 0; } +uint16_t GroupState::getParsedFieldValue(GroupStateField field) const { + switch (field) { + case GroupStateField::LEVEL: + return getBrightness(); + case GroupStateField::BRIGHTNESS: + return Units::rescale(getBrightness(), 255, 100); + case GroupStateField::COLOR_TEMP: + return getMireds(); + default: + return getFieldValue(field); + } +} + uint16_t GroupState::getScratchFieldValue(GroupStateField field) const { switch (field) { case GroupStateField::BRIGHTNESS: return scratchpad.fields._brightnessScratch; case GroupStateField::KELVIN: return scratchpad.fields._kelvinScratch; + default: + Serial.print(F("WARNING: tried to fetch value for unknown scratch field: ")); + Serial.println(static_cast(field)); + break; } - Serial.print(F("WARNING: tried to fetch value for unknown scratch field: ")); - Serial.println(static_cast(field)); - return 0; } @@ -288,7 +389,11 @@ bool GroupState::setState(const MiLightStatus status) { } bool GroupState::isSetBrightness() const { - if (! state.fields._isSetBulbMode) { + // If we don't know what mode we're in, just assume white mode. Do this for a few + // reasons: + // * Some bulbs don't have multiple modes + // * It's confusing to not have a default + if (! isSetBulbMode()) { return state.fields._isSetBrightness; } @@ -303,6 +408,33 @@ bool GroupState::isSetBrightness() const { return false; } +bool GroupState::clearBrightness() { + bool cleared = false; + + if (!state.fields._isSetBulbMode) { + cleared = state.fields._isSetBrightness; + state.fields._isSetBrightness = 0; + } else { + switch (state.fields._bulbMode) { + case BULB_MODE_COLOR: + cleared = state.fields._isSetBrightnessColor; + state.fields._isSetBrightnessColor = 0; + break; + + case BULB_MODE_SCENE: + cleared = state.fields._isSetBrightnessMode; + state.fields._isSetBrightnessMode = 0; + break; + + case BULB_MODE_WHITE: + cleared = state.fields._isSetBrightness; + state.fields._isSetBrightness = 0; + break; + } + } + + return cleared; +} uint8_t GroupState::getBrightness() const { switch (state.fields._bulbMode) { case BULB_MODE_WHITE: @@ -414,10 +546,10 @@ bool GroupState::setMireds(uint16_t mireds) { return setKelvin(Units::miredsToWhiteVal(mireds, 100)); } -bool GroupState::isSetBulbMode() const { return state.fields._isSetBulbMode; } +bool GroupState::isSetBulbMode() const { + return (isSetNightMode() && isNightMode()) || state.fields._isSetBulbMode; +} BulbMode GroupState::getBulbMode() const { - BulbMode mode; - // Night mode is a transient state. When power is toggled, the bulb returns // to the state it was last in. To handle this case, night mode state is // stored separately. @@ -463,11 +595,19 @@ bool GroupState::isDirty() const { return state.fields._dirty; } inline bool GroupState::setDirty() { state.fields._dirty = 1; state.fields._mqttDirty = 1; + + return true; +} +bool GroupState::clearDirty() { + state.fields._dirty = 0; + return true; } -bool GroupState::clearDirty() { state.fields._dirty = 0; } bool GroupState::isMqttDirty() const { return state.fields._mqttDirty; } -bool GroupState::clearMqttDirty() { state.fields._mqttDirty = 0; } +bool GroupState::clearMqttDirty() { + state.fields._mqttDirty = 0; + return true; +} void GroupState::load(Stream& stream) { for (size_t i = 0; i < DATA_LONGS; i++) { @@ -492,16 +632,17 @@ bool GroupState::applyIncrementCommand(GroupStateField field, IncrementDirection int8_t dirValue = static_cast(dir); // If there's already a known value, update it - if (isSetField(field)) { - int8_t currentValue = static_cast(getFieldValue(field)); + if (previousState != NULL && previousState->isSetField(field)) { + int8_t currentValue = static_cast(previousState->getFieldValue(field)); int8_t newValue = currentValue + (dirValue * INCREMENT_COMMAND_VALUE); #ifdef STATE_DEBUG - debugState("Updating field from increment command"); + previousState->debugState("Updating field from increment command"); #endif // For now, assume range for both brightness and kelvin is [0, 100] setFieldValue(field, constrain(newValue, 0, 100)); + return true; // Otherwise start or update scratch state } else { @@ -531,14 +672,61 @@ bool GroupState::applyIncrementCommand(GroupStateField field, IncrementDirection return false; } -bool GroupState::patch(const GroupState& other) { +bool GroupState::clearNonMatchingFields(const GroupState& other) { +#ifdef STATE_DEBUG + this->debugState("Clearing fields. Current state"); + other.debugState("Other state"); +#endif + + bool clearedAny = false; + + for (size_t i = 0; i < size(ALL_PHYSICAL_FIELDS); ++i) { + GroupStateField field = ALL_PHYSICAL_FIELDS[i]; + + if (other.isSetField(field) && isSetField(field) && getFieldValue(field) != other.getFieldValue(field)) { + if (clearField(field)) { + clearedAny = true; + } + } + } + +#ifdef STATE_DEBUG + this->debugState("Result"); +#endif + + return clearedAny; +} + +void GroupState::patch(const GroupState& other) { +#ifdef STATE_DEBUG + other.debugState("Patching existing state with: "); + Serial.println(); +#endif + for (size_t i = 0; i < size(ALL_PHYSICAL_FIELDS); ++i) { GroupStateField field = ALL_PHYSICAL_FIELDS[i]; - if (other.isSetField(field)) { + // Handle night mode separately. Should always set this field. + if (field == GroupStateField::BULB_MODE && other.isNightMode()) { + setFieldValue(field, other.getFieldValue(field)); + } + // Otherwise... + // Conditions: + // * Only set anything if field is set in other state + // * Do not patch anything other than STATE if bulb is off + else if (other.isSetField(field) && (field == GroupStateField::STATE || isOn())) { setFieldValue(field, other.getFieldValue(field)); } } + + for (size_t i = 0; i < size(ALL_SCRATCH_FIELDS); ++i) { + GroupStateField field = ALL_SCRATCH_FIELDS[i]; + + // All scratch field updates require that the bulb is on. + if (isOn() && other.isSetScratchField(field)) { + setScratchFieldValue(field, other.getScratchFieldValue(field)); + } + } } /* @@ -549,58 +737,60 @@ bool GroupState::patch(const GroupState& other) { Returns true if the packet changes affects a state change */ -bool GroupState::patch(const JsonObject& state) { +bool GroupState::patch(JsonObject state) { bool changes = false; #ifdef STATE_DEBUG Serial.print(F("Patching existing state with: ")); - state.printTo(Serial); + serializeJson(state, Serial); Serial.println(); #endif - if (state.containsKey("state")) { - bool stateChange = setState(state["state"] == "ON" ? ON : OFF); + if (state.containsKey(GroupStateFieldNames::STATE)) { + bool stateChange = setState(state[GroupStateFieldNames::STATE] == "ON" ? ON : OFF); changes |= stateChange; } // Devices do not support changing their state while off, so don't apply state // changes to devices we know are off. - if (isOn() && state.containsKey("brightness")) { - bool stateChange = setBrightness(Units::rescale(state.get("brightness"), 100, 255)); + if (isOn() && state.containsKey(GroupStateFieldNames::BRIGHTNESS)) { + bool stateChange = setBrightness(Units::rescale(state[GroupStateFieldNames::BRIGHTNESS].as(), 100, 255)); changes |= stateChange; } - if (isOn() && state.containsKey("hue")) { - changes |= setHue(state["hue"]); + if (isOn() && state.containsKey(GroupStateFieldNames::HUE)) { + changes |= setHue(state[GroupStateFieldNames::HUE]); changes |= setBulbMode(BULB_MODE_COLOR); } - if (isOn() && state.containsKey("saturation")) { - changes |= setSaturation(state["saturation"]); + if (isOn() && state.containsKey(GroupStateFieldNames::SATURATION)) { + changes |= setSaturation(state[GroupStateFieldNames::SATURATION]); } - if (isOn() && state.containsKey("mode")) { - changes |= setMode(state["mode"]); + if (isOn() && state.containsKey(GroupStateFieldNames::MODE)) { + changes |= setMode(state[GroupStateFieldNames::MODE]); changes |= setBulbMode(BULB_MODE_SCENE); } - if (isOn() && state.containsKey("color_temp")) { - changes |= setMireds(state["color_temp"]); + if (isOn() && state.containsKey(GroupStateFieldNames::COLOR_TEMP)) { + changes |= setMireds(state[GroupStateFieldNames::COLOR_TEMP]); changes |= setBulbMode(BULB_MODE_WHITE); } - if (state.containsKey("command")) { - const String& command = state["command"]; + if (state.containsKey(GroupStateFieldNames::COMMAND)) { + const String& command = state[GroupStateFieldNames::COMMAND]; - if (isOn() && command == "set_white") { + if (isOn() && command == MiLightCommandNames::SET_WHITE) { changes |= setBulbMode(BULB_MODE_WHITE); - } else if (command == "night_mode") { + } else if (command == MiLightCommandNames::NIGHT_MODE) { changes |= setBulbMode(BULB_MODE_NIGHT); } else if (isOn() && command == "brightness_up") { changes |= applyIncrementCommand(GroupStateField::BRIGHTNESS, IncrementDirection::INCREASE); } else if (isOn() && command == "brightness_down") { changes |= applyIncrementCommand(GroupStateField::BRIGHTNESS, IncrementDirection::DECREASE); - } else if (isOn() && command == "temperature_up") { + } else if (isOn() && command == MiLightCommandNames::TEMPERATURE_UP) { changes |= applyIncrementCommand(GroupStateField::KELVIN, IncrementDirection::INCREASE); - } else if (isOn() && command == "temperature_down") { + changes |= setBulbMode(BULB_MODE_WHITE); + } else if (isOn() && command == MiLightCommandNames::TEMPERATURE_DOWN) { changes |= applyIncrementCommand(GroupStateField::KELVIN, IncrementDirection::DECREASE); + changes |= setBulbMode(BULB_MODE_WHITE); } } @@ -614,28 +804,38 @@ bool GroupState::patch(const JsonObject& state) { return changes; } -void GroupState::applyColor(ArduinoJson::JsonObject& state) { - uint8_t rgb[3]; - RGBConverter converter; - converter.hsvToRgb( - getHue()/360.0, - // Default to fully saturated - (isSetSaturation() ? getSaturation() : 100)/100.0, - 1, - rgb - ); - applyColor(state, rgb[0], rgb[1], rgb[2]); +void GroupState::applyColor(JsonObject state) const { + ParsedColor color = getColor(); + applyColor(state, color.r, color.g, color.b); } -void GroupState::applyColor(ArduinoJson::JsonObject& state, uint8_t r, uint8_t g, uint8_t b) { - JsonObject& color = state.createNestedObject("color"); +void GroupState::applyColor(JsonObject state, uint8_t r, uint8_t g, uint8_t b) const { + JsonObject color = state.createNestedObject(GroupStateFieldNames::COLOR); color["r"] = r; color["g"] = g; color["b"] = b; } +void GroupState::applyOhColor(JsonObject state) const { + ParsedColor color = getColor(); + + char ohColorStr[13]; + sprintf(ohColorStr, "%d,%d,%d", color.r, color.g, color.b); + + state[GroupStateFieldNames::COLOR] = ohColorStr; +} + +void GroupState::applyHexColor(JsonObject state) const { + ParsedColor color = getColor(); + + char hexColor[8]; + sprintf(hexColor, "#%02X%02X%02X", color.r, color.g, color.b); + + state[GroupStateFieldNames::COLOR] = hexColor; +} + // gather partial state for a single field; see GroupState::applyState to gather many fields -void GroupState::applyField(JsonObject& partialState, const BulbId& bulbId, GroupStateField field) { +void GroupState::applyField(JsonObject partialState, const BulbId& bulbId, GroupStateField field) const { if (isSetField(field)) { switch (field) { case GroupStateField::STATE: @@ -644,15 +844,15 @@ void GroupState::applyField(JsonObject& partialState, const BulbId& bulbId, Grou break; case GroupStateField::BRIGHTNESS: - partialState["brightness"] = Units::rescale(getBrightness(), 255, 100); + partialState[GroupStateFieldNames::BRIGHTNESS] = Units::rescale(getBrightness(), 255, 100); break; case GroupStateField::LEVEL: - partialState["level"] = getBrightness(); + partialState[GroupStateFieldNames::LEVEL] = getBrightness(); break; case GroupStateField::BULB_MODE: - partialState["bulb_mode"] = BULB_MODE_NAMES[getBulbMode()]; + partialState[GroupStateFieldNames::BULB_MODE] = BULB_MODE_NAMES[getBulbMode()]; break; case GroupStateField::COLOR: @@ -661,6 +861,18 @@ void GroupState::applyField(JsonObject& partialState, const BulbId& bulbId, Grou } break; + case GroupStateField::OH_COLOR: + if (getBulbMode() == BULB_MODE_COLOR) { + applyOhColor(partialState); + } + break; + + case GroupStateField::HEX_COLOR: + if (getBulbMode() == BULB_MODE_COLOR) { + applyHexColor(partialState); + } + break; + case GroupStateField::COMPUTED_COLOR: if (getBulbMode() == BULB_MODE_COLOR) { applyColor(partialState); @@ -671,104 +883,142 @@ void GroupState::applyField(JsonObject& partialState, const BulbId& bulbId, Grou case GroupStateField::HUE: if (getBulbMode() == BULB_MODE_COLOR) { - partialState["hue"] = getHue(); + partialState[GroupStateFieldNames::HUE] = getHue(); } break; case GroupStateField::SATURATION: if (getBulbMode() == BULB_MODE_COLOR) { - partialState["saturation"] = getSaturation(); + partialState[GroupStateFieldNames::SATURATION] = getSaturation(); } break; case GroupStateField::MODE: if (getBulbMode() == BULB_MODE_SCENE) { - partialState["mode"] = getMode(); + partialState[GroupStateFieldNames::MODE] = getMode(); } break; case GroupStateField::EFFECT: if (getBulbMode() == BULB_MODE_SCENE) { - partialState["effect"] = String(getMode()); - } else if (getBulbMode() == BULB_MODE_WHITE) { - partialState["effect"] = "white_mode"; + partialState[GroupStateFieldNames::EFFECT] = String(getMode()); + } else if (isSetBulbMode() && getBulbMode() == BULB_MODE_WHITE) { + partialState[GroupStateFieldNames::EFFECT] = "white_mode"; } else if (getBulbMode() == BULB_MODE_NIGHT) { - partialState["effect"] = "night_mode"; + partialState[GroupStateFieldNames::EFFECT] = MiLightCommandNames::NIGHT_MODE; } break; case GroupStateField::COLOR_TEMP: - if (getBulbMode() == BULB_MODE_WHITE) { - partialState["color_temp"] = getMireds(); + if (isSetBulbMode() && getBulbMode() == BULB_MODE_WHITE) { + partialState[GroupStateFieldNames::COLOR_TEMP] = getMireds(); } break; case GroupStateField::KELVIN: - if (getBulbMode() == BULB_MODE_WHITE) { - partialState["kelvin"] = getKelvin(); + if (isSetBulbMode() && getBulbMode() == BULB_MODE_WHITE) { + partialState[GroupStateFieldNames::KELVIN] = getKelvin(); } break; case GroupStateField::DEVICE_ID: - partialState["device_id"] = bulbId.deviceId; + partialState[GroupStateFieldNames::DEVICE_ID] = bulbId.deviceId; break; case GroupStateField::GROUP_ID: - partialState["group_id"] = bulbId.groupId; + partialState[GroupStateFieldNames::GROUP_ID] = bulbId.groupId; break; case GroupStateField::DEVICE_TYPE: - const MiLightRemoteConfig* remoteConfig = MiLightRemoteConfig::fromType(bulbId.deviceType); - if (remoteConfig) { - partialState["device_type"] = remoteConfig->name; + { + const MiLightRemoteConfig* remoteConfig = MiLightRemoteConfig::fromType(bulbId.deviceType); + if (remoteConfig) { + partialState[GroupStateFieldNames::DEVICE_TYPE] = remoteConfig->name; + } } break; + + default: + Serial.printf_P(PSTR("Tried to apply unknown field: %d\n"), static_cast(field)); + break; } } } // helper function to debug the current state (in JSON) to the serial port -void GroupState::debugState(char const *debugMessage) { +void GroupState::debugState(char const *debugMessage) const { #ifdef STATE_DEBUG // using static to keep large buffers off the call stack - static StaticJsonBuffer<500> jsonBuffer; + StaticJsonDocument<500> jsonDoc; + JsonObject jsonState = jsonDoc.to(); // define fields to show (if count changes, make sure to update count to applyState below) - GroupStateField fields[] { - GroupStateField::BRIGHTNESS, + std::vector fields({ + GroupStateField::LEVEL, GroupStateField::BULB_MODE, - GroupStateField::COLOR, GroupStateField::COLOR_TEMP, - GroupStateField::COMPUTED_COLOR, GroupStateField::EFFECT, GroupStateField::HUE, GroupStateField::KELVIN, - GroupStateField::LEVEL, GroupStateField::MODE, GroupStateField::SATURATION, - GroupStateField::STATE, - GroupStateField::STATUS }; - - // since our buffer is reused, make sure to clear it every time - jsonBuffer.clear(); - JsonObject& jsonState = jsonBuffer.createObject(); + GroupStateField::STATE + }); // Fake id BulbId id; // use applyState to build JSON of all fields (from above) - applyState(jsonState, id, fields, 13); + applyState(jsonState, id, fields); // convert to string and print Serial.printf("%s: ", debugMessage); - jsonState.printTo(Serial); + serializeJson(jsonState, Serial); Serial.println(""); + Serial.printf("Raw data: %08X %08X\n", state.rawData[0], state.rawData[1]); #endif } +bool GroupState::isSetColor() const { + return isSetHue(); +} + +ParsedColor GroupState::getColor() const { + uint8_t rgb[3]; + RGBConverter converter; + uint16_t hue = getHue(); + uint8_t sat = isSetSaturation() ? getSaturation() : 100; + + converter.hsvToRgb( + hue / 360.0, + // Default to fully saturated + sat / 100.0, + 1, + rgb + ); + + return { + .success = true, + .hue = hue, + .r = rgb[0], + .g = rgb[1], + .b = rgb[2], + .saturation = sat + }; +} + // build up a partial state representation based on the specified GrouipStateField array. Used // to gather a subset of states (configurable in the UI) for sending to MQTT and web responses. -void GroupState::applyState(JsonObject& partialState, const BulbId& bulbId, GroupStateField* fields, size_t numFields) { - for (size_t i = 0; i < numFields; i++) { - applyField(partialState, bulbId, fields[i]); +void GroupState::applyState(JsonObject partialState, const BulbId& bulbId, std::vector& fields) const { + for (std::vector::const_iterator itr = fields.begin(); itr != fields.end(); ++itr) { + applyField(partialState, bulbId, *itr); } } + +bool GroupState::isPhysicalField(GroupStateField field) { + for (size_t i = 0; i < size(ALL_PHYSICAL_FIELDS); ++i) { + if (field == ALL_PHYSICAL_FIELDS[i]) { + return true; + } + } + return false; +} \ No newline at end of file diff --git a/lib/MiLightState/GroupState.h b/lib/MiLightState/GroupState.h index 6ade659f..f4dead4c 100644 --- a/lib/MiLightState/GroupState.h +++ b/lib/MiLightState/GroupState.h @@ -1,9 +1,12 @@ #include #include -#include +#include +#include #include #include #include +#include +#include #ifndef _GROUP_STATE_H #define _GROUP_STATE_H @@ -11,18 +14,6 @@ // enable to add debugging on state // #define DEBUG_STATE -struct BulbId { - uint16_t deviceId; - uint8_t groupId; - MiLightRemoteType deviceType; - - BulbId(); - BulbId(const BulbId& other); - BulbId(const uint16_t deviceId, const uint8_t groupId, const MiLightRemoteType deviceType); - bool operator==(const BulbId& other); - void operator=(const BulbId& other); -}; - enum BulbMode { BULB_MODE_WHITE, BULB_MODE_COLOR, @@ -31,32 +22,33 @@ enum BulbMode { }; enum class IncrementDirection : unsigned { - INCREASE = 1, + INCREASE = 1, DECREASE = -1U }; -static const char* BULB_MODE_NAMES[] = { - "white", - "color", - "scene", - "night" -}; - class GroupState { public: + static const GroupStateField ALL_PHYSICAL_FIELDS[]; GroupState(); GroupState(const GroupState& other); GroupState& operator=(const GroupState& other); + // Convenience constructor that patches transient state from a previous GroupState, + // and defaults with JSON state + GroupState(const GroupState* previousState, JsonObject jsonState); + + void initFields(); + bool operator==(const GroupState& other) const; bool isEqualIgnoreDirty(const GroupState& other) const; void print(Stream& stream) const; - bool isSetField(GroupStateField field) const; uint16_t getFieldValue(GroupStateField field) const; + uint16_t getParsedFieldValue(GroupStateField field) const; void setFieldValue(GroupStateField field, uint16_t value); + bool clearField(GroupStateField field); bool isSetScratchField(GroupStateField field) const; uint16_t getScratchFieldValue(GroupStateField field) const; @@ -73,6 +65,7 @@ class GroupState { bool isSetBrightness() const; uint8_t getBrightness() const; bool setBrightness(uint8_t brightness); + bool clearBrightness(); // 8 bits bool isSetHue() const; @@ -115,26 +108,29 @@ class GroupState { inline bool setMqttDirty(); bool clearMqttDirty(); - // Patches this state with ONLY the set fields in the other. Returns - // true if there were any changes. - bool patch(const GroupState& other); + // Clears all of the fields in THIS GroupState that have different values + // than the provided group state. + bool clearNonMatchingFields(const GroupState& other); - // Patches this state with the fields defined in the JSON state. Returns + // Patches this state with ONLY the set fields in the other. + void patch(const GroupState& other); + + // Patches this state with the fields defined in the JSON state. Returns // true if there were any changes. - bool patch(const JsonObject& state); + bool patch(JsonObject state); // It's a little weird to need to pass in a BulbId here. The purpose is to // support fields like DEVICE_ID, which aren't otherweise available to the // state in this class. The alternative is to have every GroupState object // keep a reference to its BulbId, which feels too heavy-weight. - void applyField(JsonObject& state, const BulbId& bulbId, GroupStateField field); - void applyState(JsonObject& state, const BulbId& bulbId, GroupStateField* fields, size_t numFields); + void applyField(JsonObject state, const BulbId& bulbId, GroupStateField field) const; + void applyState(JsonObject state, const BulbId& bulbId, std::vector& fields) const; // Attempt to keep track of increment commands in such a way that we can - // know what state it's in. When we get an increment command (like "increase + // know what state it's in. When we get an increment command (like "increase // brightness"): - // 1. If there is no value in the scratch state: assume real state is in - // the furthest value from the direction of the command. For example, + // 1. If there is no value in the scratch state: assume real state is in + // the furthest value from the direction of the command. For example, // if we get "increase," assume the value was 0. // 2. If there is a value in the scratch state, apply the command to it. // For example, if we get "decrease," subtract 1 from the scratch. @@ -142,16 +138,25 @@ class GroupState { // persistent field to that value // 4. If there is already a known value for the state, apply it rather // than messing with scratch state. - // + // // returns true if a (real, not scratch) state change was made bool applyIncrementCommand(GroupStateField field, IncrementDirection dir); + // Helpers that convert raw state values + + // Return true if hue is set. If saturation is not set, will assume 100. + bool isSetColor() const; + ParsedColor getColor() const; + void load(Stream& stream); void dump(Stream& stream) const; - void debugState(char const *debugMessage); + void debugState(char const *debugMessage) const; static const GroupState& defaultState(MiLightRemoteType remoteType); + static GroupState initDefaultRgbState(); + static GroupState initDefaultWhiteState(); + static bool isPhysicalField(GroupStateField field); private: static const size_t DATA_LONGS = 2; @@ -190,7 +195,7 @@ class GroupState { union TransientData { uint16_t rawData; struct Fields { - uint16_t + uint16_t _isSetKelvinScratch : 1, _kelvinScratch : 7, _isSetBrightnessScratch : 1, @@ -201,8 +206,18 @@ class GroupState { StateData state; TransientData scratchpad; - void applyColor(JsonObject& state, uint8_t r, uint8_t g, uint8_t b); - void applyColor(JsonObject& state); + // State is constructed from individual command packets. A command packet is parsed in + // isolation, and the result is patched onto previous state. There are a few cases where + // it's necessary to know some things from the previous state, so we keep a reference to + // it here. + const GroupState* previousState; + + void applyColor(JsonObject state, uint8_t r, uint8_t g, uint8_t b) const; + void applyColor(JsonObject state) const; + // Apply OpenHAB-style color, e.g., {"color":"0,0,0"} + void applyOhColor(JsonObject state) const; + // Apply hex color, e.g., {"color":"#FF0000"} + void applyHexColor(JsonObject state) const; }; extern const BulbId DEFAULT_BULB_ID; diff --git a/lib/MiLightState/GroupStateCache.cpp b/lib/MiLightState/GroupStateCache.cpp index 7df8b896..ea5924fa 100644 --- a/lib/MiLightState/GroupStateCache.cpp +++ b/lib/MiLightState/GroupStateCache.cpp @@ -4,6 +4,15 @@ GroupStateCache::GroupStateCache(const size_t maxSize) : maxSize(maxSize) { } +GroupStateCache::~GroupStateCache() { + ListNode* cur = cache.getHead(); + + while (cur != NULL) { + delete cur->data; + cur = cur->next; + } +} + GroupState* GroupStateCache::get(const BulbId& id) { return getInternal(id); } diff --git a/lib/MiLightState/GroupStateCache.h b/lib/MiLightState/GroupStateCache.h index 6e35fce4..6ca94af2 100644 --- a/lib/MiLightState/GroupStateCache.h +++ b/lib/MiLightState/GroupStateCache.h @@ -16,6 +16,7 @@ struct GroupCacheNode { class GroupStateCache { public: GroupStateCache(const size_t maxSize); + ~GroupStateCache(); GroupState* get(const BulbId& id); GroupState* set(const BulbId& id, const GroupState& state); diff --git a/lib/MiLightState/GroupStatePersistence.cpp b/lib/MiLightState/GroupStatePersistence.cpp index 99c3d478..14a1f718 100644 --- a/lib/MiLightState/GroupStatePersistence.cpp +++ b/lib/MiLightState/GroupStatePersistence.cpp @@ -35,6 +35,6 @@ void GroupStatePersistence::clear(const BulbId &id) { } char* GroupStatePersistence::buildFilename(const BulbId &id, char *buffer) { - uint32_t compactId = (id.deviceId << 24) | (id.deviceType << 8) | id.groupId; + uint32_t compactId = id.getCompactId(); return buffer + sprintf(buffer, "%s%x", FILE_PREFIX, compactId); } diff --git a/lib/MiLightState/GroupStateStore.cpp b/lib/MiLightState/GroupStateStore.cpp index da74f05c..28da1605 100644 --- a/lib/MiLightState/GroupStateStore.cpp +++ b/lib/MiLightState/GroupStateStore.cpp @@ -11,22 +11,25 @@ GroupState* GroupStateStore::get(const BulbId& id) { GroupState* state = cache.get(id); if (state == NULL) { +#if STATE_DEBUG + printf( + "Couldn't fetch state for 0x%04X / %d / %s in the cache, getting it from persistence\n", + id.deviceId, + id.groupId, + MiLightRemoteConfig::fromType(id.deviceType)->name.c_str() + ); +#endif trackEviction(); GroupState loadedState = GroupState::defaultState(id.deviceType); - // For device types with groups, group 0 is a "virtual" group. All devices paired with the same ID will respond - // to group 0. So it doesn't make sense to store group 0 state by itself. - // - // For devices that don't have groups, we made the unfortunate decision to represent state using the fake group - // ID 0, so we can't always ignore group 0. const MiLightRemoteConfig* remoteConfig = MiLightRemoteConfig::fromType(id.deviceType); - if (id.groupId != 0 || remoteConfig == NULL || remoteConfig->numGroups == 0) { - persistence.get(id, loadedState); - state = cache.set(id, loadedState); - } else { + if (remoteConfig == NULL) { return NULL; } + + persistence.get(id, loadedState); + state = cache.set(id, loadedState); } return state; @@ -37,24 +40,42 @@ GroupState* GroupStateStore::get(const uint16_t deviceId, const uint8_t groupId, return get(bulbId); } -// save state for a bulb. If id.groupId == 0, will iterate across all groups -// and individually save each group (recursively) +// Save state for a bulb. +// +// Notes: +// +// * For device types with groups, group 0 is a "virtual" group. All devices paired with the same ID will +// respond to group 0. When state for an individual (i.e., != 0) group is changed, the state for +// group 0 becomes out of sync and should be cleared. +// +// * If id.groupId == 0, will iterate across all groups and individually save each group (recursively) +// GroupState* GroupStateStore::set(const BulbId &id, const GroupState& state) { + BulbId otherId(id); GroupState* storedState = get(id); - *storedState = state; + storedState->patch(state); if (id.groupId == 0) { const MiLightRemoteConfig* remote = MiLightRemoteConfig::fromType(id.deviceType); - BulbId individualBulb(id); + +#ifdef STATE_DEBUG + Serial.printf_P(PSTR("Fanning out group 0 state for device ID 0x%04X (%d groups in total)\n"), id.deviceId, remote->numGroups); + state.debugState("group 0 state = "); +#endif for (size_t i = 1; i <= remote->numGroups; i++) { - individualBulb.groupId = i; + otherId.groupId = i; - GroupState* individualState = get(individualBulb); + GroupState* individualState = get(otherId); individualState->patch(state); } + } else { + otherId.groupId = 0; + GroupState* group0State = get(otherId); + + group0State->clearNonMatchingFields(state); } - + return storedState; } @@ -63,9 +84,28 @@ GroupState* GroupStateStore::set(const uint16_t deviceId, const uint8_t groupId, return set(bulbId, state); } +void GroupStateStore::clear(const BulbId& bulbId) { + GroupState* state = get(bulbId); + + if (state != NULL) { + state->initFields(); + state->patch(GroupState::defaultState(bulbId.deviceType)); + } +} + void GroupStateStore::trackEviction() { if (cache.isFull()) { evictedIds.add(cache.getLru()); + +#ifdef STATE_DEBUG + BulbId bulbId = evictedIds.getLast(); + printf( + "Evicting from cache: 0x%04X / %d / %s\n", + bulbId.deviceId, + bulbId.groupId, + MiLightRemoteConfig::fromType(bulbId.deviceType)->name.c_str() + ); +#endif } } diff --git a/lib/MiLightState/GroupStateStore.h b/lib/MiLightState/GroupStateStore.h index 08f34cc3..30c0872f 100644 --- a/lib/MiLightState/GroupStateStore.h +++ b/lib/MiLightState/GroupStateStore.h @@ -11,9 +11,9 @@ class GroupStateStore { /* * Returns the state for the given BulbId. If accessing state for a valid device - * (i.e., NOT group 0) and no state exists, its state will be initialized with a + * (i.e., NOT group 0) and no state exists, its state will be initialized with a * default. - * + * * Otherwise, we return NULL. */ GroupState* get(const BulbId& id); @@ -26,6 +26,8 @@ class GroupStateStore { GroupState* set(const BulbId& id, const GroupState& state); GroupState* set(const uint16_t deviceId, const uint8_t groupId, const MiLightRemoteType deviceType, const GroupState& state); + void clear(const BulbId& id); + /* * Flushes all states to persistent storage. Returns true iff anything was * flushed. diff --git a/lib/Radio/LT8900MiLightRadio.cpp b/lib/Radio/LT8900MiLightRadio.cpp index 7af991ce..5e6e06f7 100644 --- a/lib/Radio/LT8900MiLightRadio.cpp +++ b/lib/Radio/LT8900MiLightRadio.cpp @@ -442,6 +442,8 @@ bool LT8900MiLightRadio::sendPacket(uint8_t *data, size_t packetSize, byte byCha return true; } + + return false; } const MiLightRadioConfig& LT8900MiLightRadio::config() { diff --git a/lib/Radio/MiLightConstants.h b/lib/Radio/MiLightConstants.h deleted file mode 100644 index bd4fdc47..00000000 --- a/lib/Radio/MiLightConstants.h +++ /dev/null @@ -1,19 +0,0 @@ -#ifndef _MILIGHT_BUTTONS -#define _MILIGHT_BUTTONS - -enum MiLightRemoteType { - REMOTE_TYPE_UNKNOWN = 255, - REMOTE_TYPE_RGBW = 0, - REMOTE_TYPE_CCT = 1, - REMOTE_TYPE_RGB_CCT = 2, - REMOTE_TYPE_RGB = 3, - REMOTE_TYPE_FUT089 = 4, - REMOTE_TYPE_FUT091 = 5 -}; - -enum MiLightStatus { - ON = 0, - OFF = 1 -}; - -#endif diff --git a/lib/Radio/MiLightRadioConfig.cpp b/lib/Radio/MiLightRadioConfig.cpp index bd47b9f0..9af77447 100644 --- a/lib/Radio/MiLightRadioConfig.cpp +++ b/lib/Radio/MiLightRadioConfig.cpp @@ -1,8 +1,9 @@ #include MiLightRadioConfig MiLightRadioConfig::ALL_CONFIGS[] = { - MiLightRadioConfig(0x147A, 0x258B, 7, 9, 40, 71), // rgbw - MiLightRadioConfig(0x050A, 0x55AA, 7, 4, 39, 74), // cct - MiLightRadioConfig(0x7236, 0x1809, 9, 8, 39, 70), // rgb+cct, fut089 - MiLightRadioConfig(0x9AAB, 0xBCCD, 6, 3, 38, 73) // rgb + MiLightRadioConfig(0x147A, 0x258B, 7, 9, 40, 71, 0xAA, 0x05), // rgbw + MiLightRadioConfig(0x050A, 0x55AA, 7, 4, 39, 74, 0xAA, 0x05), // cct + MiLightRadioConfig(0x7236, 0x1809, 9, 8, 39, 70, 0xAA, 0x05), // rgb+cct, fut089 + MiLightRadioConfig(0x9AAB, 0xBCCD, 6, 3, 38, 73, 0x55, 0x0A), // rgb + MiLightRadioConfig(0x50A0, 0xAA55, 6, 6, 41, 76, 0xAA, 0x0A) // FUT020 }; diff --git a/lib/Radio/MiLightRadioConfig.h b/lib/Radio/MiLightRadioConfig.h index a47f27bc..cd512da0 100644 --- a/lib/Radio/MiLightRadioConfig.h +++ b/lib/Radio/MiLightRadioConfig.h @@ -1,6 +1,7 @@ #include -#include +#include #include +#include #ifndef _MILIGHT_RADIO_CONFIG #define _MILIGHT_RADIO_CONFIG @@ -11,29 +12,71 @@ class MiLightRadioConfig { public: static const size_t NUM_CHANNELS = 3; + // We can set this to two possible values. It only has an affect on the nRF24 radio. The + // LT8900/PL1167 radio will always use the raw syncwords. For the nRF24, this controls what + // we set the "address" to, which roughly corresponds to the LT8900 syncword. + // + // The PL1167 packet is structured as follows (lengths in bits): + // Preamble ( 8) | Syncword (32) | Trailer ( 4) | Packet Len ( 8) | Packet (...) + // + // 4 -- Use the raw syncword bits as the address. This means the Trailer will be included in + // the packet data. Since the Trailer is 4 bits, packet data will not be byte-aligned, + // and the data must be bitshifted every time it's received. + // + // 5 -- Include the Trailer in the syncword. Avoids us needing to bitshift packet data. The + // downside is that the Trailer is hardcoded and assumed based on received packets. + // + // In general, this should be set to 5 unless packets that should be showing up are + // mysteriously not present. + static const uint8_t SYNCWORD_LENGTH = 5; + MiLightRadioConfig( const uint16_t syncword0, const uint16_t syncword3, const size_t packetLength, const uint8_t channel0, const uint8_t channel1, - const uint8_t channel2 - ) - : syncword0(syncword0), - syncword3(syncword3), - packetLength(packetLength) + const uint8_t channel2, + const uint8_t preamble, + const uint8_t trailer + ) : syncword0(syncword0) + , syncword3(syncword3) + , packetLength(packetLength) { channels[0] = channel0; channels[1] = channel1; channels[2] = channel2; + + size_t ix = SYNCWORD_LENGTH; + + // precompute the syncword for the nRF24. we include the fixed preamble and trailer in the + // syncword to avoid needing to bitshift packets. trailer is 4 bits, so the actual syncword + // is no longer byte-aligned. + if (SYNCWORD_LENGTH == 5) { + syncwordBytes[ --ix ] = reverseBits( + ((syncword0 << 4) & 0xF0) | (preamble & 0x0F) + ); + syncwordBytes[ --ix ] = reverseBits((syncword0 >> 4) & 0xFF); + syncwordBytes[ --ix ] = reverseBits(((syncword0 >> 12) & 0x0F) + ((syncword3 << 4) & 0xF0)); + syncwordBytes[ --ix ] = reverseBits((syncword3 >> 4) & 0xFF); + syncwordBytes[ --ix ] = reverseBits( + ((syncword3 >> 12) & 0x0F) | ((trailer << 4) & 0xF0) + ); + } else { + syncwordBytes[ --ix ] = reverseBits(syncword0 & 0xff); + syncwordBytes[ --ix ] = reverseBits( (syncword0 >> 8) & 0xff); + syncwordBytes[ --ix ] = reverseBits(syncword3 & 0xff); + syncwordBytes[ --ix ] = reverseBits( (syncword3 >> 8) & 0xff); + } } - const uint16_t syncword0; - const uint16_t syncword3; uint8_t channels[3]; + uint8_t syncwordBytes[SYNCWORD_LENGTH]; + uint16_t syncword0, syncword3; + const size_t packetLength; - static const size_t NUM_CONFIGS = 4; + static const size_t NUM_CONFIGS = 5; static MiLightRadioConfig ALL_CONFIGS[NUM_CONFIGS]; }; diff --git a/lib/Radio/MiLightRadioFactory.cpp b/lib/Radio/MiLightRadioFactory.cpp index a5f295d4..5488e060 100644 --- a/lib/Radio/MiLightRadioFactory.cpp +++ b/lib/Radio/MiLightRadioFactory.cpp @@ -1,24 +1,40 @@ #include -MiLightRadioFactory* MiLightRadioFactory::fromSettings(const Settings& settings) { +std::shared_ptr MiLightRadioFactory::fromSettings(const Settings& settings) { switch (settings.radioInterfaceType) { case nRF24: - return new NRF24Factory(settings.csnPin, settings.cePin); + return std::make_shared( + settings.csnPin, + settings.cePin, + settings.rf24PowerLevel, + settings.rf24Channels, + settings.rf24ListenChannel + ); case LT8900: - return new LT8900Factory(settings.csnPin, settings.resetPin, settings.cePin); + return std::make_shared(settings.csnPin, settings.resetPin, settings.cePin); default: return NULL; } } -NRF24Factory::NRF24Factory(uint8_t csnPin, uint8_t cePin) - : rf24(RF24(cePin, csnPin)) -{ } +NRF24Factory::NRF24Factory( + uint8_t csnPin, + uint8_t cePin, + RF24PowerLevel rF24PowerLevel, + const std::vector& channels, + RF24Channel listenChannel +) +: rf24(RF24(cePin, csnPin)), + channels(channels), + listenChannel(listenChannel) +{ + rf24.setPALevel(RF24PowerLevelHelpers::rf24ValueFromValue(rF24PowerLevel)); +} -MiLightRadio* NRF24Factory::create(const MiLightRadioConfig &config) { - return new NRF24MiLightRadio(rf24, config); +std::shared_ptr NRF24Factory::create(const MiLightRadioConfig &config) { + return std::make_shared(rf24, config, channels, listenChannel); } LT8900Factory::LT8900Factory(uint8_t csPin, uint8_t resetPin, uint8_t pktFlag) @@ -27,6 +43,6 @@ LT8900Factory::LT8900Factory(uint8_t csPin, uint8_t resetPin, uint8_t pktFlag) _pktFlag(pktFlag) { } -MiLightRadio* LT8900Factory::create(const MiLightRadioConfig& config) { - return new LT8900MiLightRadio(_csPin, _resetPin, _pktFlag, config); +std::shared_ptr LT8900Factory::create(const MiLightRadioConfig& config) { + return std::make_shared(_csPin, _resetPin, _pktFlag, config); } diff --git a/lib/Radio/MiLightRadioFactory.h b/lib/Radio/MiLightRadioFactory.h index 9bb504fa..ee95817f 100644 --- a/lib/Radio/MiLightRadioFactory.h +++ b/lib/Radio/MiLightRadioFactory.h @@ -4,7 +4,11 @@ #include #include #include +#include +#include #include +#include +#include #ifndef _MILIGHT_RADIO_FACTORY_H #define _MILIGHT_RADIO_FACTORY_H @@ -12,22 +16,31 @@ class MiLightRadioFactory { public: - virtual MiLightRadio* create(const MiLightRadioConfig& config) = 0; + virtual ~MiLightRadioFactory() { }; + virtual std::shared_ptr create(const MiLightRadioConfig& config) = 0; - static MiLightRadioFactory* fromSettings(const Settings& settings); + static std::shared_ptr fromSettings(const Settings& settings); }; class NRF24Factory : public MiLightRadioFactory { public: - NRF24Factory(uint8_t cePin, uint8_t csnPin); + NRF24Factory( + uint8_t cePin, + uint8_t csnPin, + RF24PowerLevel rF24PowerLevel, + const std::vector& channels, + RF24Channel listenChannel + ); - virtual MiLightRadio* create(const MiLightRadioConfig& config); + virtual std::shared_ptr create(const MiLightRadioConfig& config); protected: RF24 rf24; + const std::vector& channels; + const RF24Channel listenChannel; }; @@ -36,7 +49,7 @@ class LT8900Factory : public MiLightRadioFactory { LT8900Factory(uint8_t csPin, uint8_t resetPin, uint8_t pktFlag); - virtual MiLightRadio* create(const MiLightRadioConfig& config); + virtual std::shared_ptr create(const MiLightRadioConfig& config); protected: diff --git a/lib/Radio/NRF24MiLightRadio.cpp b/lib/Radio/NRF24MiLightRadio.cpp index 0dce1b36..b2fefc9f 100644 --- a/lib/Radio/NRF24MiLightRadio.cpp +++ b/lib/Radio/NRF24MiLightRadio.cpp @@ -5,10 +5,17 @@ #define PACKET_ID(packet, packet_length) ( (packet[1] << 8) | packet[packet_length - 1] ) -NRF24MiLightRadio::NRF24MiLightRadio(RF24& rf24, const MiLightRadioConfig& config) - : _pl1167(PL1167_nRF24(rf24)), - _waiting(false), - _config(config) +NRF24MiLightRadio::NRF24MiLightRadio( + RF24& rf24, + const MiLightRadioConfig& config, + const std::vector& channels, + RF24Channel listenChannel +) + : channels(channels), + listenChannelIx(static_cast(listenChannel)), + _pl1167(PL1167_nRF24(rf24)), + _config(config), + _waiting(false) { } int NRF24MiLightRadio::begin() { @@ -28,22 +35,7 @@ int NRF24MiLightRadio::begin() { } int NRF24MiLightRadio::configure() { - int retval = _pl1167.setCRC(true); - if (retval < 0) { - return retval; - } - - retval = _pl1167.setPreambleLength(3); - if (retval < 0) { - return retval; - } - - retval = _pl1167.setTrailerLength(4); - if (retval < 0) { - return retval; - } - - retval = _pl1167.setSyncword(_config.syncword0, _config.syncword3); + int retval = _pl1167.setSyncword(_config.syncwordBytes, MiLightRadioConfig::SYNCWORD_LENGTH); if (retval < 0) { return retval; } @@ -65,7 +57,7 @@ bool NRF24MiLightRadio::available() { return true; } - if (_pl1167.receive(_config.channels[0]) > 0) { + if (_pl1167.receive(_config.channels[listenChannelIx]) > 0) { #ifdef DEBUG_PRINTF printf("NRF24MiLightRadio - received packet!\n"); #endif @@ -131,10 +123,14 @@ int NRF24MiLightRadio::write(uint8_t frame[], size_t frame_length) { } int NRF24MiLightRadio::resend() { - for (size_t i = 0; i < MiLightRadioConfig::NUM_CHANNELS; i++) { + for (std::vector::const_iterator it = channels.begin(); it != channels.end(); ++it) { + size_t channelIx = static_cast(*it); + uint8_t channel = _config.channels[channelIx]; + _pl1167.writeFIFO(_out_packet, _out_packet[0] + 1); - _pl1167.transmit(_config.channels[i]); + _pl1167.transmit(channel); } + return 0; } diff --git a/lib/Radio/NRF24MiLightRadio.h b/lib/Radio/NRF24MiLightRadio.h index 83532d7e..b7900e51 100644 --- a/lib/Radio/NRF24MiLightRadio.h +++ b/lib/Radio/NRF24MiLightRadio.h @@ -10,13 +10,20 @@ #include #include #include +#include +#include #ifndef _NRF24_MILIGHT_RADIO_H_ #define _NRF24_MILIGHT_RADIO_H_ class NRF24MiLightRadio : public MiLightRadio { public: - NRF24MiLightRadio(RF24& rf, const MiLightRadioConfig& config); + NRF24MiLightRadio( + RF24& rf, + const MiLightRadioConfig& config, + const std::vector& channels, + RF24Channel listenChannel + ); int begin(); bool available(); @@ -28,6 +35,9 @@ class NRF24MiLightRadio : public MiLightRadio { const MiLightRadioConfig& config(); private: + const std::vector& channels; + const size_t listenChannelIx; + PL1167_nRF24 _pl1167; const MiLightRadioConfig& _config; uint32_t _prev_packet_id; diff --git a/lib/Radio/PL1167_nRF24.cpp b/lib/Radio/PL1167_nRF24.cpp index fcc61b62..dc854747 100644 --- a/lib/Radio/PL1167_nRF24.cpp +++ b/lib/Radio/PL1167_nRF24.cpp @@ -11,90 +11,67 @@ */ #include "PL1167_nRF24.h" +#include +#include static uint16_t calc_crc(uint8_t *data, size_t data_length); -static uint8_t reverse_bits(uint8_t data); PL1167_nRF24::PL1167_nRF24(RF24 &radio) : _radio(radio) { } -int PL1167_nRF24::open() -{ +int PL1167_nRF24::open() { _radio.begin(); _radio.setAutoAck(false); - _radio.setPALevel(RF24_PA_MAX); _radio.setDataRate(RF24_1MBPS); _radio.disableCRC(); - _syncwordLength = 5; + _syncwordLength = MiLightRadioConfig::SYNCWORD_LENGTH; _radio.setAddressWidth(_syncwordLength); return recalc_parameters(); } -int PL1167_nRF24::recalc_parameters() -{ - int packet_length = _maxPacketLength + 2; - int nrf_address_pos = _syncwordLength; +int PL1167_nRF24::recalc_parameters() { + size_t nrf_address_length = _syncwordLength; - if (_syncword0 & 0x01) { - _nrf_pipe[ --nrf_address_pos ] = reverse_bits( ( (_syncword0 << 4) & 0xf0 ) + 0x05 ); - } else { - _nrf_pipe[ --nrf_address_pos ] = reverse_bits( ( (_syncword0 << 4) & 0xf0 ) + 0x0a ); + // +2 for CRC + size_t packet_length = _maxPacketLength + 2; + + // Read an extra byte if we don't include the trailer in the syncword + if (_syncwordLength < 5) { + ++packet_length; } - _nrf_pipe[ --nrf_address_pos ] = reverse_bits( (_syncword0 >> 4) & 0xff); - _nrf_pipe[ --nrf_address_pos ] = reverse_bits( ( (_syncword0 >> 12) & 0x0f ) + ( (_syncword3 << 4) & 0xf0) ); - _nrf_pipe[ --nrf_address_pos ] = reverse_bits( (_syncword3 >> 4) & 0xff); - _nrf_pipe[ --nrf_address_pos ] = reverse_bits( ( (_syncword3 >> 12) & 0x0f ) + 0x50 ); // kh: spi says trailer is always "5" ? - _receive_length = packet_length; + if (packet_length > sizeof(_packet) || nrf_address_length < 3) { + return -1; + } - _radio.openWritingPipe(_nrf_pipe); - _radio.openReadingPipe(1, _nrf_pipe); + if (_syncwordBytes != nullptr) { + _radio.openWritingPipe(_syncwordBytes); + _radio.openReadingPipe(1, _syncwordBytes); + } - _radio.setChannel(2 + _channel); + _receive_length = packet_length; + _radio.setChannel(2 + _channel); _radio.setPayloadSize( packet_length ); - return 0; -} - -int PL1167_nRF24::setPreambleLength(uint8_t preambleLength) -{ return 0; } -/* kh- no thanks, I'll take care of this */ - - -int PL1167_nRF24::setSyncword(uint16_t syncword0, uint16_t syncword3) -{ - _syncwordLength = 5; - _syncword0 = syncword0; - _syncword3 = syncword3; - return recalc_parameters(); + return 0; } -int PL1167_nRF24::setTrailerLength(uint8_t trailerLength) -{ return 0; } -/* kh- no thanks, I'll take care of that. - One could argue there is potential value to "defining" the trailer - such that - we can use those "values" for internal (repeateR?) functions since they are - ignored by the real PL1167.. But there is no value in _this_ implementation... -*/ - -int PL1167_nRF24::setCRC(bool crc) -{ - _crc = crc; +int PL1167_nRF24::setSyncword(const uint8_t syncword[], size_t syncwordLength) { + _syncwordLength = syncwordLength; + _syncwordBytes = syncword; return recalc_parameters(); } -int PL1167_nRF24::setMaxPacketLength(uint8_t maxPacketLength) -{ +int PL1167_nRF24::setMaxPacketLength(uint8_t maxPacketLength) { _maxPacketLength = maxPacketLength; return recalc_parameters(); } -int PL1167_nRF24::receive(uint8_t channel) -{ +int PL1167_nRF24::receive(uint8_t channel) { if (channel != _channel) { _channel = channel; int retval = recalc_parameters(); @@ -148,8 +125,7 @@ int PL1167_nRF24::writeFIFO(const uint8_t data[], size_t data_length) return data_length; } -int PL1167_nRF24::transmit(uint8_t channel) -{ +int PL1167_nRF24::transmit(uint8_t channel) { if (channel != _channel) { _channel = channel; int retval = recalc_parameters(); @@ -163,16 +139,16 @@ int PL1167_nRF24::transmit(uint8_t channel) uint8_t tmp[sizeof(_packet)]; int outp=0; - uint16_t crc; - if (_crc) { - crc = calc_crc(_packet, _packet_length); - } + uint16_t crc = calc_crc(_packet, _packet_length); - for (int inp = 0; inp < _packet_length + (_crc ? 2 : 0) + 1; inp++) { + // +1 for packet length + // +2 for crc + // = 3 + for (int inp = 0; inp < _packet_length + 3; inp++) { if (inp < _packet_length) { - tmp[outp++] = reverse_bits(_packet[inp]);} - else if (_crc && inp < _packet_length + 2) { - tmp[outp++] = reverse_bits((crc >> ( (inp - _packet_length) * 8)) & 0xff); + tmp[outp++] = reverseBits(_packet[inp]);} + else if (inp < _packet_length + 2) { + tmp[outp++] = reverseBits((crc >> ( (inp - _packet_length) * 8)) & 0xff); } } @@ -182,9 +158,18 @@ int PL1167_nRF24::transmit(uint8_t channel) return 0; } - -int PL1167_nRF24::internal_receive() -{ +/** + * The over-the-air packet structure sent by the PL1167 is as follows (lengths + * measured in bits) + * + * Preamble ( 8) | Syncword (32) | Trailer ( 4) | Packet Len ( 8) | Packet (...) + * + * Note that because the Trailer is 4 bits, the remaining data is not byte-aligned. + * + * Bit-order is reversed. + * + */ +int PL1167_nRF24::internal_receive() { uint8_t tmp[sizeof(_packet)]; int outp = 0; @@ -193,45 +178,57 @@ int PL1167_nRF24::internal_receive() // HACK HACK HACK: Reset radio open(); -#ifdef DEBUG_PRINTF - printf("Packet received: "); - for (int i = 0; i < _receive_length; i++) { - printf("%02X", tmp[i]); - } - printf("\n"); -#endif +// Currently, the syncword width is set to 5 in order to include the +// PL1167 trailer. The trailer is 4 bits, which pushes packet data +// out of byte-alignment. +// +// The following code reads un-byte-aligned packet data. +// +// #ifdef DEBUG_PRINTF +// Serial.printf_P(PSTR("Packet received (%d bytes) RAW: "), outp); +// for (int i = 0; i < _receive_length; i++) { +// Serial.printf_P(PSTR("%02X "), tmp[i]); +// } +// Serial.print(F("\n")); +// #endif +// +// uint16_t buffer = tmp[0]; +// +// for (int inp = 1; inp < _receive_length; inp++) { +// uint8_t currentByte = tmp[inp]; +// tmp[outp++] = reverseBits((buffer << 4) | (currentByte >> 4)); +// buffer = (buffer << 8) | currentByte; +// } for (int inp = 0; inp < _receive_length; inp++) { - tmp[outp++] = reverse_bits(tmp[inp]); + tmp[outp++] = reverseBits(tmp[inp]); } - #ifdef DEBUG_PRINTF - printf("Packet transformed: "); + Serial.printf_P(PSTR("Packet received (%d bytes): "), outp); for (int i = 0; i < outp; i++) { - printf("%02X", tmp[i]); + Serial.printf_P(PSTR("%02X "), tmp[i]); } - printf("\n"); + Serial.print(F("\n")); #endif - - if (_crc) { - if (outp < 2) { + if (outp < 2) { #ifdef DEBUG_PRINTF - printf("Failed CRC: outp < 2\n"); + Serial.println(F("Failed CRC: outp < 2")); #endif - return 0; - } - uint16_t crc = calc_crc(tmp, outp - 2); - if ( ((crc & 0xff) != tmp[outp - 2]) || (((crc >> 8) & 0xff) != tmp[outp - 1]) ) { + return 0; + } + + uint16_t crc = calc_crc(tmp, outp - 2); + uint16_t recvCrc = (tmp[outp - 1] << 8) | tmp[outp - 2]; + + if ( crc != recvCrc ) { #ifdef DEBUG_PRINTF - uint16_t recv_crc = ((tmp[outp - 2] & 0xFF) << 8) | (tmp[outp - 1] & 0xFF); - printf("Failed CRC: expected %d, got %d\n", crc, recv_crc); + Serial.printf_P(PSTR("Failed CRC: expected %04X, got %04X\n"), crc, recvCrc); #endif - return 0; - } - outp -= 2; + return 0; } + outp -= 2; memcpy(_packet, tmp, outp); @@ -239,7 +236,7 @@ int PL1167_nRF24::internal_receive() _received = true; #ifdef DEBUG_PRINTF - printf("Successfully parsed packet of length %d\n", _packet_length); + Serial.printf_P(PSTR("Successfully parsed packet of length %d\n"), _packet_length); #endif return outp; @@ -261,14 +258,4 @@ static uint16_t calc_crc(uint8_t *data, size_t data_length) { } } return state; -} - -static uint8_t reverse_bits(uint8_t data) { - uint8_t result = 0; - for (int i = 0; i < 8; i++) { - result <<= 1; - result |= data & 1; - data >>= 1; - } - return result; -} +} \ No newline at end of file diff --git a/lib/Radio/PL1167_nRF24.h b/lib/Radio/PL1167_nRF24.h index 42166004..b38dde78 100644 --- a/lib/Radio/PL1167_nRF24.h +++ b/lib/Radio/PL1167_nRF24.h @@ -20,11 +20,10 @@ class PL1167_nRF24 { public: PL1167_nRF24(RF24& radio); int open(); - int setPreambleLength(uint8_t preambleLength); - int setSyncword(uint16_t syncword0, uint16_t syncword3); - int setTrailerLength(uint8_t trailerLength); - int setCRC(bool crc); + + int setSyncword(const uint8_t syncword[], size_t syncwordLength); int setMaxPacketLength(uint8_t maxPacketLength); + int writeFIFO(const uint8_t data[], size_t data_length); int transmit(uint8_t channel); int receive(uint8_t channel); @@ -33,11 +32,8 @@ class PL1167_nRF24 { private: RF24 &_radio; - bool _crc; - uint8_t _preambleLength = 1; - uint16_t _syncword0 = 0, _syncword3 = 0; + const uint8_t* _syncwordBytes = nullptr; uint8_t _syncwordLength = 4; - uint8_t _trailerLength = 4; uint8_t _maxPacketLength = 8; uint8_t _channel = 0; diff --git a/lib/Radio/RadioUtils.cpp b/lib/Radio/RadioUtils.cpp new file mode 100644 index 00000000..aa97a144 --- /dev/null +++ b/lib/Radio/RadioUtils.cpp @@ -0,0 +1,18 @@ +#include + +#include +#include +#include + +uint8_t reverseBits(uint8_t byte) { + uint8_t result = byte; + uint8_t i = 7; + + for (byte >>= 1; byte; byte >>= 1) { + result <<= 1; + result |= byte & 1; + --i; + } + + return result << i; +} \ No newline at end of file diff --git a/lib/Radio/RadioUtils.h b/lib/Radio/RadioUtils.h new file mode 100644 index 00000000..f909bec8 --- /dev/null +++ b/lib/Radio/RadioUtils.h @@ -0,0 +1,8 @@ +#pragma once + +#include + +/** + * Reverse the bits of a given byte + */ +uint8_t reverseBits(uint8_t byte); \ No newline at end of file diff --git a/lib/Settings/AboutHelper.cpp b/lib/Settings/AboutHelper.cpp new file mode 100644 index 00000000..aac1233d --- /dev/null +++ b/lib/Settings/AboutHelper.cpp @@ -0,0 +1,28 @@ +#include +#include +#include +#include + +String AboutHelper::generateAboutString(bool abbreviated) { + DynamicJsonDocument buffer(1024); + + generateAboutObject(buffer, abbreviated); + + String body; + serializeJson(buffer, body); + + return body; +} + +void AboutHelper::generateAboutObject(JsonDocument& obj, bool abbreviated) { + obj["firmware"] = QUOTE(FIRMWARE_NAME); + obj["version"] = QUOTE(MILIGHT_HUB_VERSION); + obj["ip_address"] = WiFi.localIP().toString(); + obj["reset_reason"] = ESP.getResetReason(); + + if (! abbreviated) { + obj["variant"] = QUOTE(FIRMWARE_VARIANT); + obj["free_heap"] = ESP.getFreeHeap(); + obj["arduino_version"] = ESP.getCoreVersion(); + } +} \ No newline at end of file diff --git a/lib/Settings/AboutHelper.h b/lib/Settings/AboutHelper.h new file mode 100644 index 00000000..0a25f856 --- /dev/null +++ b/lib/Settings/AboutHelper.h @@ -0,0 +1,13 @@ +#include +#include + +#ifndef _ABOUT_STRING_HELPER_H +#define _ABOUT_STRING_HELPER_H + +class AboutHelper { +public: + static String generateAboutString(bool abbreviated = false); + static void generateAboutObject(JsonDocument& obj, bool abbreviated = false); +}; + +#endif \ No newline at end of file diff --git a/lib/Settings/Settings.cpp b/lib/Settings/Settings.cpp index 8582b0c1..1bc1eded 100644 --- a/lib/Settings/Settings.cpp +++ b/lib/Settings/Settings.cpp @@ -3,13 +3,28 @@ #include #include #include +#include #define PORT_POSITION(s) ( s.indexOf(':') ) -bool Settings::hasAuthSettings() { +GatewayConfig::GatewayConfig(uint16_t deviceId, uint16_t port, uint8_t protocolVersion) + : deviceId(deviceId) + , port(port) + , protocolVersion(protocolVersion) +{ } + +bool Settings::isAuthenticationEnabled() const { return adminUsername.length() > 0 && adminPassword.length() > 0; } +const String& Settings::getUsername() const { + return adminUsername; +} + +const String& Settings::getPassword() const { + return adminPassword; +} + bool Settings::isAutoRestartEnabled() { return _autoRestartPeriod > 0; } @@ -22,133 +37,191 @@ size_t Settings::getAutoRestartPeriod() { return std::max(_autoRestartPeriod, static_cast(MINIMUM_RESTART_PERIOD)); } -void Settings::deserialize(Settings& settings, String json) { - DynamicJsonBuffer jsonBuffer; - JsonObject& parsedSettings = jsonBuffer.parseObject(json); - settings.patch(parsedSettings); +void Settings::updateDeviceIds(JsonArray arr) { + this->deviceIds.clear(); + + for (size_t i = 0; i < arr.size(); ++i) { + this->deviceIds.push_back(arr[i]); + } } -void Settings::updateDeviceIds(JsonArray& arr) { - if (arr.success()) { - if (this->deviceIds) { - delete this->deviceIds; - } +void Settings::updateGatewayConfigs(JsonArray arr) { + gatewayConfigs.clear(); - this->deviceIds = new uint16_t[arr.size()]; - this->numDeviceIds = arr.size(); - arr.copyTo(this->deviceIds, arr.size()); + for (size_t i = 0; i < arr.size(); i++) { + JsonArray params = arr[i]; + + if (params.size() == 3) { + std::shared_ptr ptr = std::make_shared(parseInt(params[0]), params[1], params[2]); + gatewayConfigs.push_back(std::move(ptr)); + } else { + Serial.print(F("Settings - skipped parsing gateway ports settings for element #")); + Serial.println(i); + } } } -void Settings::updateGatewayConfigs(JsonArray& arr) { - if (arr.success()) { - if (this->gatewayConfigs) { - delete[] this->gatewayConfigs; - } +void Settings::patch(JsonObject parsedSettings) { + if (parsedSettings.isNull()) { + Serial.println(F("Skipping patching loaded settings. Parsed settings was null.")); + return; + } - this->gatewayConfigs = new GatewayConfig*[arr.size()]; - this->numGatewayConfigs = arr.size(); + this->setIfPresent(parsedSettings, "admin_username", adminUsername); + this->setIfPresent(parsedSettings, "admin_password", adminPassword); + this->setIfPresent(parsedSettings, "ce_pin", cePin); + this->setIfPresent(parsedSettings, "csn_pin", csnPin); + this->setIfPresent(parsedSettings, "reset_pin", resetPin); + this->setIfPresent(parsedSettings, "led_pin", ledPin); + this->setIfPresent(parsedSettings, "packet_repeats", packetRepeats); + this->setIfPresent(parsedSettings, "http_repeat_factor", httpRepeatFactor); + this->setIfPresent(parsedSettings, "auto_restart_period", _autoRestartPeriod); + this->setIfPresent(parsedSettings, "mqtt_server", _mqttServer); + this->setIfPresent(parsedSettings, "mqtt_username", mqttUsername); + this->setIfPresent(parsedSettings, "mqtt_password", mqttPassword); + this->setIfPresent(parsedSettings, "mqtt_topic_pattern", mqttTopicPattern); + this->setIfPresent(parsedSettings, "mqtt_update_topic_pattern", mqttUpdateTopicPattern); + this->setIfPresent(parsedSettings, "mqtt_state_topic_pattern", mqttStateTopicPattern); + this->setIfPresent(parsedSettings, "mqtt_client_status_topic", mqttClientStatusTopic); + this->setIfPresent(parsedSettings, "simple_mqtt_client_status", simpleMqttClientStatus); + this->setIfPresent(parsedSettings, "discovery_port", discoveryPort); + this->setIfPresent(parsedSettings, "listen_repeats", listenRepeats); + this->setIfPresent(parsedSettings, "state_flush_interval", stateFlushInterval); + this->setIfPresent(parsedSettings, "mqtt_state_rate_limit", mqttStateRateLimit); + this->setIfPresent(parsedSettings, "packet_repeat_throttle_threshold", packetRepeatThrottleThreshold); + this->setIfPresent(parsedSettings, "packet_repeat_throttle_sensitivity", packetRepeatThrottleSensitivity); + this->setIfPresent(parsedSettings, "packet_repeat_minimum", packetRepeatMinimum); + this->setIfPresent(parsedSettings, "enable_automatic_mode_switching", enableAutomaticModeSwitching); + this->setIfPresent(parsedSettings, "led_mode_packet_count", ledModePacketCount); + this->setIfPresent(parsedSettings, "hostname", hostname); + this->setIfPresent(parsedSettings, "wifi_static_ip", wifiStaticIP); + this->setIfPresent(parsedSettings, "wifi_static_ip_gateway", wifiStaticIPGateway); + this->setIfPresent(parsedSettings, "wifi_static_ip_netmask", wifiStaticIPNetmask); + this->setIfPresent(parsedSettings, "packet_repeats_per_loop", packetRepeatsPerLoop); + this->setIfPresent(parsedSettings, "home_assistant_discovery_prefix", homeAssistantDiscoveryPrefix); + + if (parsedSettings.containsKey("wifi_mode")) { + this->wifiMode = wifiModeFromString(parsedSettings["wifi_mode"]); + } - for (size_t i = 0; i < arr.size(); i++) { - JsonArray& params = arr[i]; + if (parsedSettings.containsKey("rf24_channels")) { + JsonArray arr = parsedSettings["rf24_channels"]; + rf24Channels = JsonHelpers::jsonArrToVector(arr, RF24ChannelHelpers::valueFromName); + } - if (params.success() && params.size() == 3) { - this->gatewayConfigs[i] = new GatewayConfig(parseInt(params[0]), params[1], params[2]); - } else { - Serial.print(F("Settings - skipped parsing gateway ports settings for element #")); - Serial.println(i); - } - } + if (parsedSettings.containsKey("rf24_listen_channel")) { + this->rf24ListenChannel = RF24ChannelHelpers::valueFromName(parsedSettings["rf24_listen_channel"]); } -} -void Settings::updateGroupStateFields(JsonArray &arr) { - if (arr.success()) { - if (this->groupStateFields) { - delete this->groupStateFields; - } + if (parsedSettings.containsKey("rf24_power_level")) { + this->rf24PowerLevel = RF24PowerLevelHelpers::valueFromName(parsedSettings["rf24_power_level"]); + } + + if (parsedSettings.containsKey("led_mode_wifi_config")) { + this->ledModeWifiConfig = LEDStatus::stringToLEDMode(parsedSettings["led_mode_wifi_config"]); + } - this->groupStateFields = new GroupStateField[arr.size()]; - this->numGroupStateFields = arr.size(); + if (parsedSettings.containsKey("led_mode_wifi_failed")) { + this->ledModeWifiFailed = LEDStatus::stringToLEDMode(parsedSettings["led_mode_wifi_failed"]); + } - for (size_t i = 0; i < arr.size(); i++) { - String name = arr[i]; - name.toLowerCase(); + if (parsedSettings.containsKey("led_mode_operating")) { + this->ledModeOperating = LEDStatus::stringToLEDMode(parsedSettings["led_mode_operating"]); + } - this->groupStateFields[i] = GroupStateFieldHelpers::getFieldByName(name.c_str()); - } + if (parsedSettings.containsKey("led_mode_packet")) { + this->ledModePacket = LEDStatus::stringToLEDMode(parsedSettings["led_mode_packet"]); + } + + if (parsedSettings.containsKey("radio_interface_type")) { + this->radioInterfaceType = Settings::typeFromString(parsedSettings["radio_interface_type"]); + } + + if (parsedSettings.containsKey("device_ids")) { + JsonArray arr = parsedSettings["device_ids"]; + updateDeviceIds(arr); + } + if (parsedSettings.containsKey("gateway_configs")) { + JsonArray arr = parsedSettings["gateway_configs"]; + updateGatewayConfigs(arr); + } + if (parsedSettings.containsKey("group_state_fields")) { + JsonArray arr = parsedSettings["group_state_fields"]; + groupStateFields = JsonHelpers::jsonArrToVector(arr, GroupStateFieldHelpers::getFieldByName); + } + + if (parsedSettings.containsKey("group_id_aliases")) { + parseGroupIdAliases(parsedSettings); } } -void Settings::patch(JsonObject& parsedSettings) { - if (parsedSettings.success()) { - this->setIfPresent(parsedSettings, "admin_username", adminUsername); - this->setIfPresent(parsedSettings, "admin_password", adminPassword); - this->setIfPresent(parsedSettings, "ce_pin", cePin); - this->setIfPresent(parsedSettings, "csn_pin", csnPin); - this->setIfPresent(parsedSettings, "reset_pin", resetPin); - this->setIfPresent(parsedSettings, "led_pin", ledPin); - this->setIfPresent(parsedSettings, "packet_repeats", packetRepeats); - this->setIfPresent(parsedSettings, "http_repeat_factor", httpRepeatFactor); - this->setIfPresent(parsedSettings, "auto_restart_period", _autoRestartPeriod); - this->setIfPresent(parsedSettings, "mqtt_server", _mqttServer); - this->setIfPresent(parsedSettings, "mqtt_username", mqttUsername); - this->setIfPresent(parsedSettings, "mqtt_password", mqttPassword); - this->setIfPresent(parsedSettings, "mqtt_topic_pattern", mqttTopicPattern); - this->setIfPresent(parsedSettings, "mqtt_update_topic_pattern", mqttUpdateTopicPattern); - this->setIfPresent(parsedSettings, "mqtt_state_topic_pattern", mqttStateTopicPattern); - this->setIfPresent(parsedSettings, "discovery_port", discoveryPort); - this->setIfPresent(parsedSettings, "listen_repeats", listenRepeats); - this->setIfPresent(parsedSettings, "state_flush_interval", stateFlushInterval); - this->setIfPresent(parsedSettings, "mqtt_state_rate_limit", mqttStateRateLimit); - this->setIfPresent(parsedSettings, "packet_repeat_throttle_threshold", packetRepeatThrottleThreshold); - this->setIfPresent(parsedSettings, "packet_repeat_throttle_sensitivity", packetRepeatThrottleSensitivity); - this->setIfPresent(parsedSettings, "packet_repeat_minimum", packetRepeatMinimum); - this->setIfPresent(parsedSettings, "enable_automatic_mode_switching", enableAutomaticModeSwitching); - this->setIfPresent(parsedSettings, "led_mode_packet_count", ledModePacketCount); - - if (parsedSettings.containsKey("led_mode_wifi_config")) { - this->ledModeWifiConfig = LEDStatus::stringToLEDMode(parsedSettings["led_mode_wifi_config"]); - } +std::map::const_iterator Settings::findAlias(MiLightRemoteType deviceType, uint16_t deviceId, uint8_t groupId) { + BulbId searchId{ deviceId, groupId, deviceType }; - if (parsedSettings.containsKey("led_mode_wifi_failed")) { - this->ledModeWifiFailed = LEDStatus::stringToLEDMode(parsedSettings["led_mode_wifi_failed"]); + for (auto it = groupIdAliases.begin(); it != groupIdAliases.end(); ++it) { + if (searchId == it->second) { + return it; } + } - if (parsedSettings.containsKey("led_mode_operating")) { - this->ledModeOperating = LEDStatus::stringToLEDMode(parsedSettings["led_mode_operating"]); - } + return groupIdAliases.end(); +} - if (parsedSettings.containsKey("led_mode_packet")) { - this->ledModePacket = LEDStatus::stringToLEDMode(parsedSettings["led_mode_packet"]); - } +void Settings::parseGroupIdAliases(JsonObject json) { + JsonObject aliases = json["group_id_aliases"]; - if (parsedSettings.containsKey("radio_interface_type")) { - this->radioInterfaceType = Settings::typeFromString(parsedSettings["radio_interface_type"]); - } + // Save group IDs that were deleted so that they can be processed by discovery + // if necessary + for (auto it = groupIdAliases.begin(); it != groupIdAliases.end(); ++it) { + deletedGroupIdAliases[it->second.getCompactId()] = it->second; + } - if (parsedSettings.containsKey("device_ids")) { - JsonArray& arr = parsedSettings["device_ids"]; - updateDeviceIds(arr); - } - if (parsedSettings.containsKey("gateway_configs")) { - JsonArray& arr = parsedSettings["gateway_configs"]; - updateGatewayConfigs(arr); - } - if (parsedSettings.containsKey("group_state_fields")) { - JsonArray& arr = parsedSettings["group_state_fields"]; - updateGroupStateFields(arr); - } + groupIdAliases.clear(); + + for (JsonPair kv : aliases) { + JsonArray bulbIdProps = kv.value(); + BulbId bulbId = { + bulbIdProps[1].as(), + bulbIdProps[2].as(), + MiLightRemoteTypeHelpers::remoteTypeFromString(bulbIdProps[0].as()) + }; + groupIdAliases[kv.key().c_str()] = bulbId; + + // If added this round, do not mark as deleted. + deletedGroupIdAliases.erase(bulbId.getCompactId()); + } +} + +void Settings::dumpGroupIdAliases(JsonObject json) { + JsonObject aliases = json.createNestedObject("group_id_aliases"); + + for (std::map::iterator itr = groupIdAliases.begin(); itr != groupIdAliases.end(); ++itr) { + JsonArray bulbProps = aliases.createNestedArray(itr->first); + BulbId bulbId = itr->second; + bulbProps.add(MiLightRemoteTypeHelpers::remoteTypeToString(bulbId.deviceType)); + bulbProps.add(bulbId.deviceId); + bulbProps.add(bulbId.groupId); } } void Settings::load(Settings& settings) { if (SPIFFS.exists(SETTINGS_FILE)) { + // Clear in-memory settings + settings = Settings(); + File f = SPIFFS.open(SETTINGS_FILE, "r"); - String settingsContents = f.readStringUntil(SETTINGS_TERMINATOR); + + DynamicJsonDocument json(MILIGHT_HUB_SETTINGS_BUFFER_SIZE); + auto error = deserializeJson(json, f); f.close(); - deserialize(settings, settingsContents); + if (! error) { + JsonObject parsedSettings = json.as(); + settings.patch(parsedSettings); + } else { + Serial.print(F("Error parsing saved settings file: ")); + Serial.println(error.c_str()); + } } else { settings.save(); } @@ -172,9 +245,8 @@ void Settings::save() { } } -void Settings::serialize(Stream& stream, const bool prettyPrint) { - DynamicJsonBuffer jsonBuffer; - JsonObject& root = jsonBuffer.createObject(); +void Settings::serialize(Print& stream, const bool prettyPrint) { + DynamicJsonDocument root(MILIGHT_HUB_SETTINGS_BUFFER_SIZE); root["admin_username"] = this->adminUsername; root["admin_password"] = this->adminPassword; @@ -192,6 +264,8 @@ void Settings::serialize(Stream& stream, const bool prettyPrint) { root["mqtt_topic_pattern"] = this->mqttTopicPattern; root["mqtt_update_topic_pattern"] = this->mqttUpdateTopicPattern; root["mqtt_state_topic_pattern"] = this->mqttStateTopicPattern; + root["mqtt_client_status_topic"] = this->mqttClientStatusTopic; + root["simple_mqtt_client_status"] = this->simpleMqttClientStatus; root["discovery_port"] = this->discoveryPort; root["listen_repeats"] = this->listenRepeats; root["state_flush_interval"] = this->stateFlushInterval; @@ -205,39 +279,39 @@ void Settings::serialize(Stream& stream, const bool prettyPrint) { root["led_mode_operating"] = LEDStatus::LEDModeToString(this->ledModeOperating); root["led_mode_packet"] = LEDStatus::LEDModeToString(this->ledModePacket); root["led_mode_packet_count"] = this->ledModePacketCount; - - if (this->deviceIds) { - JsonArray& arr = jsonBuffer.createArray(); - arr.copyFrom(this->deviceIds, this->numDeviceIds); - root["device_ids"] = arr; - } - - if (this->gatewayConfigs) { - JsonArray& arr = jsonBuffer.createArray(); - for (size_t i = 0; i < this->numGatewayConfigs; i++) { - JsonArray& elmt = jsonBuffer.createArray(); - elmt.add(this->gatewayConfigs[i]->deviceId); - elmt.add(this->gatewayConfigs[i]->port); - elmt.add(this->gatewayConfigs[i]->protocolVersion); - arr.add(elmt); - } - - root["gateway_configs"] = arr; + root["hostname"] = this->hostname; + root["rf24_power_level"] = RF24PowerLevelHelpers::nameFromValue(this->rf24PowerLevel); + root["rf24_listen_channel"] = RF24ChannelHelpers::nameFromValue(rf24ListenChannel); + root["wifi_static_ip"] = this->wifiStaticIP; + root["wifi_static_ip_gateway"] = this->wifiStaticIPGateway; + root["wifi_static_ip_netmask"] = this->wifiStaticIPNetmask; + root["packet_repeats_per_loop"] = this->packetRepeatsPerLoop; + root["home_assistant_discovery_prefix"] = this->homeAssistantDiscoveryPrefix; + root["wifi_mode"] = wifiModeToString(this->wifiMode); + + JsonArray channelArr = root.createNestedArray("rf24_channels"); + JsonHelpers::vectorToJsonArr(channelArr, rf24Channels, RF24ChannelHelpers::nameFromValue); + + JsonArray deviceIdsArr = root.createNestedArray("device_ids"); + JsonHelpers::copyFrom(deviceIdsArr, this->deviceIds); + + JsonArray gatewayConfigsArr = root.createNestedArray("gateway_configs"); + for (size_t i = 0; i < this->gatewayConfigs.size(); i++) { + JsonArray elmt = gatewayConfigsArr.createNestedArray(); + elmt.add(this->gatewayConfigs[i]->deviceId); + elmt.add(this->gatewayConfigs[i]->port); + elmt.add(this->gatewayConfigs[i]->protocolVersion); } - if (this->groupStateFields) { - JsonArray& arr = jsonBuffer.createArray(); - for (size_t i = 0; i < this->numGroupStateFields; i++) { - arr.add(GroupStateFieldHelpers::getFieldName(this->groupStateFields[i])); - } + JsonArray groupStateFieldArr = root.createNestedArray("group_state_fields"); + JsonHelpers::vectorToJsonArr(groupStateFieldArr, groupStateFields, GroupStateFieldHelpers::getFieldName); - root["group_state_fields"] = arr; - } + dumpGroupIdAliases(root.as()); if (prettyPrint) { - root.prettyPrintTo(stream); + serializeJsonPretty(root, stream); } else { - root.printTo(stream); + serializeJson(root, stream); } } @@ -279,3 +353,25 @@ String Settings::typeToString(RadioInterfaceType type) { return "nRF24"; } } + +WifiMode Settings::wifiModeFromString(const String& mode) { + if (mode.equalsIgnoreCase("b")) { + return WifiMode::B; + } else if (mode.equalsIgnoreCase("g")) { + return WifiMode::G; + } else { + return WifiMode::N; + } +} + +String Settings::wifiModeToString(WifiMode mode) { + switch (mode) { + case WifiMode::B: + return "b"; + case WifiMode::G: + return "g"; + case WifiMode::N: + default: + return "n"; + } +} \ No newline at end of file diff --git a/lib/Settings/Settings.h b/lib/Settings/Settings.h index 9aad3a0b..37ef5476 100644 --- a/lib/Settings/Settings.h +++ b/lib/Settings/Settings.h @@ -2,15 +2,33 @@ #include #include #include +#include +#include #include #include +#include + +#include +#include + +#include +#include +#include #ifndef _SETTINGS_H_INCLUDED #define _SETTINGS_H_INCLUDED +#ifndef MILIGHT_HUB_SETTINGS_BUFFER_SIZE +#define MILIGHT_HUB_SETTINGS_BUFFER_SIZE 4096 +#endif + #define XQUOTE(x) #x #define QUOTE(x) XQUOTE(x) +#ifndef FIRMWARE_NAME +#define FIRMWARE_NAME unknown +#endif + #ifndef FIRMWARE_VARIANT #define FIRMWARE_VARIANT unknown #endif @@ -38,28 +56,28 @@ #define MINIMUM_RESTART_PERIOD 1 #define DEFAULT_MQTT_PORT 1883 +#define MAX_IP_ADDR_LEN 15 enum RadioInterfaceType { nRF24 = 0, LT8900 = 1, }; -static const GroupStateField DEFAULT_GROUP_STATE_FIELDS[] = { +enum class WifiMode { + B, G, N +}; + +static const std::vector DEFAULT_GROUP_STATE_FIELDS({ GroupStateField::STATE, GroupStateField::BRIGHTNESS, GroupStateField::COMPUTED_COLOR, GroupStateField::MODE, GroupStateField::COLOR_TEMP, GroupStateField::BULB_MODE -}; +}); -class GatewayConfig { -public: - GatewayConfig(uint16_t deviceId, uint16_t port, uint8_t protocolVersion) - : deviceId(deviceId), - port(port), - protocolVersion(protocolVersion) - { } +struct GatewayConfig { + GatewayConfig(uint16_t deviceId, uint16_t port, uint8_t protocolVersion); const uint16_t deviceId; const uint16_t port; @@ -72,66 +90,61 @@ class Settings { adminUsername(""), adminPassword(""), // CE and CSN pins from nrf24l01 - cePin(16), + cePin(4), csnPin(15), resetPin(0), ledPin(-2), radioInterfaceType(nRF24), - deviceIds(NULL), - gatewayConfigs(NULL), - numDeviceIds(0), - numGatewayConfigs(0), packetRepeats(50), httpRepeatFactor(1), listenRepeats(3), - _autoRestartPeriod(0), discoveryPort(48899), + simpleMqttClientStatus(false), stateFlushInterval(10000), mqttStateRateLimit(500), packetRepeatThrottleThreshold(200), packetRepeatThrottleSensitivity(0), packetRepeatMinimum(3), - groupStateFields(NULL), - numGroupStateFields(0), enableAutomaticModeSwitching(false), ledModeWifiConfig(LEDStatus::LEDMode::FastToggle), ledModeWifiFailed(LEDStatus::LEDMode::On), ledModeOperating(LEDStatus::LEDMode::SlowBlip), ledModePacket(LEDStatus::LEDMode::Flicker), - ledModePacketCount(3) - { - if (groupStateFields == NULL) { - numGroupStateFields = size(DEFAULT_GROUP_STATE_FIELDS); - groupStateFields = new GroupStateField[numGroupStateFields]; - memcpy(groupStateFields, DEFAULT_GROUP_STATE_FIELDS, numGroupStateFields * sizeof(GroupStateField)); - } - } + ledModePacketCount(3), + hostname("milight-hub"), + rf24PowerLevel(RF24PowerLevelHelpers::defaultValue()), + rf24Channels(RF24ChannelHelpers::allValues()), + groupStateFields(DEFAULT_GROUP_STATE_FIELDS), + rf24ListenChannel(RF24Channel::RF24_LOW), + packetRepeatsPerLoop(10), + wifiMode(WifiMode::N), + _autoRestartPeriod(0) + { } + + ~Settings() { } + + bool isAuthenticationEnabled() const; + const String& getUsername() const; + const String& getPassword() const; - ~Settings() { - if (deviceIds) { - delete deviceIds; - } - } - - bool hasAuthSettings(); bool isAutoRestartEnabled(); size_t getAutoRestartPeriod(); - static void deserialize(Settings& settings, String json); static void load(Settings& settings); static RadioInterfaceType typeFromString(const String& s); static String typeToString(RadioInterfaceType type); + static std::vector defaultListenChannels(); void save(); String toJson(const bool prettyPrint = true); - void serialize(Stream& stream, const bool prettyPrint = false); - void updateDeviceIds(JsonArray& arr); - void updateGatewayConfigs(JsonArray& arr); - void updateGroupStateFields(JsonArray& arr); - void patch(JsonObject& obj); + void serialize(Print& stream, const bool prettyPrint = false); + void updateDeviceIds(JsonArray arr); + void updateGatewayConfigs(JsonArray arr); + void patch(JsonObject obj); String mqttServer(); uint16_t mqttPort(); + std::map::const_iterator findAlias(MiLightRemoteType deviceType, uint16_t deviceId, uint8_t groupId); String adminUsername; String adminPassword; @@ -140,26 +153,22 @@ class Settings { uint8_t resetPin; int8_t ledPin; RadioInterfaceType radioInterfaceType; - uint16_t *deviceIds; - GatewayConfig **gatewayConfigs; - size_t numGatewayConfigs; - size_t numDeviceIds; size_t packetRepeats; size_t httpRepeatFactor; + uint8_t listenRepeats; + uint16_t discoveryPort; String _mqttServer; String mqttUsername; String mqttPassword; String mqttTopicPattern; String mqttUpdateTopicPattern; String mqttStateTopicPattern; - GroupStateField *groupStateFields; - size_t numGroupStateFields; - uint16_t discoveryPort; - uint8_t listenRepeats; + String mqttClientStatusTopic; + bool simpleMqttClientStatus; size_t stateFlushInterval; size_t mqttStateRateLimit; - size_t packetRepeatThrottleSensitivity; size_t packetRepeatThrottleThreshold; + size_t packetRepeatThrottleSensitivity; size_t packetRepeatMinimum; bool enableAutomaticModeSwitching; LEDStatus::LEDMode ledModeWifiConfig; @@ -167,15 +176,36 @@ class Settings { LEDStatus::LEDMode ledModeOperating; LEDStatus::LEDMode ledModePacket; size_t ledModePacketCount; - + String hostname; + RF24PowerLevel rf24PowerLevel; + std::vector deviceIds; + std::vector rf24Channels; + std::vector groupStateFields; + std::vector> gatewayConfigs; + RF24Channel rf24ListenChannel; + String wifiStaticIP; + String wifiStaticIPNetmask; + String wifiStaticIPGateway; + size_t packetRepeatsPerLoop; + std::map groupIdAliases; + std::map deletedGroupIdAliases; + String homeAssistantDiscoveryPrefix; + WifiMode wifiMode; protected: size_t _autoRestartPeriod; + void parseGroupIdAliases(JsonObject json); + void dumpGroupIdAliases(JsonObject json); + + static WifiMode wifiModeFromString(const String& mode); + static String wifiModeToString(WifiMode mode); + template - void setIfPresent(JsonObject& obj, const char* key, T& var) { + void setIfPresent(JsonObject obj, const char* key, T& var) { if (obj.containsKey(key)) { - var = obj.get(key); + JsonVariant val = obj[key]; + var = val.as(); } } }; diff --git a/lib/Settings/StringStream.h b/lib/Settings/StringStream.h index 40335835..a543cc6b 100644 --- a/lib/Settings/StringStream.h +++ b/lib/Settings/StringStream.h @@ -1,7 +1,7 @@ /* * Adapated from https://gist.github.com/cmaglie/5883185 */ - + #ifndef _STRING_STREAM_H_INCLUDED_ #define _STRING_STREAM_H_INCLUDED_ @@ -18,7 +18,7 @@ class StringStream : public Stream virtual int peek() { return position < string.length() ? string[position] : -1; } virtual void flush() { }; // Print methods - virtual size_t write(uint8_t c) { string += (char)c; }; + virtual size_t write(uint8_t c) { string += (char)c; return 1; }; private: String &string; diff --git a/lib/Transitions/ChangeFieldOnFinishTransition.cpp b/lib/Transitions/ChangeFieldOnFinishTransition.cpp new file mode 100644 index 00000000..df4bb03f --- /dev/null +++ b/lib/Transitions/ChangeFieldOnFinishTransition.cpp @@ -0,0 +1,61 @@ +#include +#include + +ChangeFieldOnFinishTransition::Builder::Builder( + size_t id, + GroupStateField field, + uint16_t arg, + std::shared_ptr delegate +) + : Transition::Builder(delegate->id, delegate->bulbId, delegate->callback) + , delegate(delegate) + , field(field) + , arg(arg) +{ } + +std::shared_ptr ChangeFieldOnFinishTransition::Builder::_build() const { + delegate->setDurationRaw(this->getOrComputeDuration()); + delegate->setNumPeriods(this->getOrComputeNumPeriods()); + delegate->setPeriod(this->getOrComputePeriod()); + + return std::make_shared( + delegate->build(), + field, + arg, + delegate->getPeriod() + ); +} + +ChangeFieldOnFinishTransition::ChangeFieldOnFinishTransition( + std::shared_ptr delegate, + GroupStateField field, + uint16_t arg, + size_t period +) : Transition(delegate->id, delegate->bulbId, period, delegate->callback) + , delegate(delegate) + , field(field) + , arg(arg) + , changeSent(false) +{ } + +bool ChangeFieldOnFinishTransition::isFinished() { + return delegate->isFinished() && changeSent; +} + +void ChangeFieldOnFinishTransition::step() { + if (! delegate->isFinished()) { + delegate->step(); + } else { + callback(bulbId, field, arg); + changeSent = true; + } +} + +void ChangeFieldOnFinishTransition::childSerialize(JsonObject& json) { + json[F("type")] = F("change_on_finish"); + json[F("field")] = GroupStateFieldHelpers::getFieldName(field); + json[F("value")] = arg; + + JsonObject child = json.createNestedObject(F("child")); + delegate->childSerialize(child); +} \ No newline at end of file diff --git a/lib/Transitions/ChangeFieldOnFinishTransition.h b/lib/Transitions/ChangeFieldOnFinishTransition.h new file mode 100644 index 00000000..7ffe6cc7 --- /dev/null +++ b/lib/Transitions/ChangeFieldOnFinishTransition.h @@ -0,0 +1,37 @@ +#include + +#pragma once + +class ChangeFieldOnFinishTransition : public Transition { +public: + + class Builder : public Transition::Builder { + public: + Builder(size_t id, GroupStateField field, uint16_t arg, std::shared_ptr delgate); + + virtual std::shared_ptr _build() const override; + + private: + const std::shared_ptr delegate; + const GroupStateField field; + const uint16_t arg; + }; + + ChangeFieldOnFinishTransition( + std::shared_ptr delegate, + GroupStateField field, + uint16_t arg, + size_t period + ); + + virtual bool isFinished() override; + +private: + std::shared_ptr delegate; + const GroupStateField field; + const uint16_t arg; + bool changeSent; + + virtual void step() override; + virtual void childSerialize(JsonObject& json) override; +}; \ No newline at end of file diff --git a/lib/Transitions/ColorTransition.cpp b/lib/Transitions/ColorTransition.cpp new file mode 100644 index 00000000..354bcc91 --- /dev/null +++ b/lib/Transitions/ColorTransition.cpp @@ -0,0 +1,152 @@ +#include +#include + +ColorTransition::Builder::Builder(size_t id, const BulbId& bulbId, TransitionFn callback, const ParsedColor& start, const ParsedColor& end) + : Transition::Builder(id, bulbId, callback) + , start(start) + , end(end) +{ } + +std::shared_ptr ColorTransition::Builder::_build() const { + size_t duration = getOrComputeDuration(); + size_t numPeriods = getOrComputeNumPeriods(); + size_t period = getOrComputePeriod(); + + int16_t dr = end.r - start.r + , dg = end.g - start.g + , db = end.b - start.b; + + RgbColor stepSizes( + calculateStepSizePart(dr, duration, period), + calculateStepSizePart(dg, duration, period), + calculateStepSizePart(db, duration, period) + ); + + return std::make_shared( + id, + bulbId, + start, + end, + stepSizes, + duration, + period, + numPeriods, + callback + ); +} + +ColorTransition::RgbColor::RgbColor() + : r(0) + , g(0) + , b(0) +{ } + +ColorTransition::RgbColor::RgbColor(const ParsedColor& color) + : r(color.r) + , g(color.g) + , b(color.b) +{ } + +ColorTransition::RgbColor::RgbColor(int16_t r, int16_t g, int16_t b) + : r(r) + , g(g) + , b(b) +{ } + +bool ColorTransition::RgbColor::operator==(const RgbColor& other) { + return r == other.r && g == other.g && b == other.b; +} + +ColorTransition::ColorTransition( + size_t id, + const BulbId& bulbId, + const ParsedColor& startColor, + const ParsedColor& endColor, + RgbColor stepSizes, + size_t duration, + size_t period, + size_t numPeriods, + TransitionFn callback +) : Transition(id, bulbId, period, callback) + , endColor(endColor) + , currentColor(startColor) + , stepSizes(stepSizes) + , lastHue(400) // use impossible values to force a packet send + , lastSaturation(200) + , finished(false) +{ + int16_t dr = endColor.r - startColor.r + , dg = endColor.g - startColor.g + , db = endColor.b - startColor.b; + // Calculate step sizes in terms of the period + stepSizes.r = calculateStepSizePart(dr, duration, period); + stepSizes.g = calculateStepSizePart(dg, duration, period); + stepSizes.b = calculateStepSizePart(db, duration, period); +} + +size_t ColorTransition::calculateColorPeriod(ColorTransition* t, const ParsedColor& start, const ParsedColor& end, size_t stepSize, size_t duration) { + int16_t dr = end.r - start.r + , dg = end.g - start.g + , db = end.b - start.b; + + int16_t max = std::max(std::max(dr, dg), db); + int16_t min = std::min(std::min(dr, dg), db); + int16_t maxAbs = std::abs(min) > std::abs(max) ? min : max; + + return Transition::calculatePeriod(maxAbs, stepSize, duration); +} + +int16_t ColorTransition::calculateStepSizePart(int16_t distance, size_t duration, size_t period) { + double stepSize = (distance / static_cast(duration)) * period; + int16_t rounded = std::ceil(std::abs(stepSize)); + + if (distance < 0) { + rounded = -rounded; + } + + return rounded; +} + +void ColorTransition::step() { + ParsedColor parsedColor = ParsedColor::fromRgb(currentColor.r, currentColor.g, currentColor.b); + + if (parsedColor.hue != lastHue) { + callback(bulbId, GroupStateField::HUE, parsedColor.hue); + lastHue = parsedColor.hue; + } + if (parsedColor.saturation != lastSaturation) { + callback(bulbId, GroupStateField::SATURATION, parsedColor.saturation); + lastSaturation = parsedColor.saturation; + } + + if (currentColor == endColor) { + finished = true; + } else { + Transition::stepValue(currentColor.r, endColor.r, stepSizes.r); + Transition::stepValue(currentColor.g, endColor.g, stepSizes.g); + Transition::stepValue(currentColor.b, endColor.b, stepSizes.b); + } +} + +bool ColorTransition::isFinished() { + return finished; +} + +void ColorTransition::childSerialize(JsonObject& json) { + json[F("type")] = F("color"); + + JsonArray currentColorArr = json.createNestedArray(F("current_color")); + currentColorArr.add(currentColor.r); + currentColorArr.add(currentColor.g); + currentColorArr.add(currentColor.b); + + JsonArray endColorArr = json.createNestedArray(F("end_color")); + endColorArr.add(endColor.r); + endColorArr.add(endColor.g); + endColorArr.add(endColor.b); + + JsonArray stepSizesArr = json.createNestedArray(F("step_sizes")); + stepSizesArr.add(stepSizes.r); + stepSizesArr.add(stepSizes.g); + stepSizesArr.add(stepSizes.b); +} \ No newline at end of file diff --git a/lib/Transitions/ColorTransition.h b/lib/Transitions/ColorTransition.h new file mode 100644 index 00000000..2ec7b148 --- /dev/null +++ b/lib/Transitions/ColorTransition.h @@ -0,0 +1,58 @@ +#include +#include + +#pragma once + +class ColorTransition : public Transition { +public: + struct RgbColor { + RgbColor(); + RgbColor(const ParsedColor& color); + RgbColor(int16_t r, int16_t g, int16_t b); + bool operator==(const RgbColor& other); + + int16_t r, g, b; + }; + + class Builder : public Transition::Builder { + public: + Builder(size_t id, const BulbId& bulbId, TransitionFn callback, const ParsedColor& start, const ParsedColor& end); + + virtual std::shared_ptr _build() const override; + + private: + const ParsedColor& start; + const ParsedColor& end; + RgbColor stepSizes; + }; + + ColorTransition( + size_t id, + const BulbId& bulbId, + const ParsedColor& startColor, + const ParsedColor& endColor, + RgbColor stepSizes, + size_t duration, + size_t period, + size_t numPeriods, + TransitionFn callback + ); + + static size_t calculateColorPeriod(ColorTransition* t, const ParsedColor& start, const ParsedColor& end, size_t stepSize, size_t duration); + inline static int16_t calculateStepSizePart(int16_t distance, size_t duration, size_t period); + virtual bool isFinished() override; + +protected: + const RgbColor endColor; + RgbColor currentColor; + RgbColor stepSizes; + + // Store these to avoid wasted packets + uint16_t lastHue; + uint16_t lastSaturation; + bool finished; + + virtual void step() override; + virtual void childSerialize(JsonObject& json) override; + static inline void stepPart(uint16_t& current, uint16_t end, int16_t step); +}; \ No newline at end of file diff --git a/lib/Transitions/FieldTransition.cpp b/lib/Transitions/FieldTransition.cpp new file mode 100644 index 00000000..aee943c1 --- /dev/null +++ b/lib/Transitions/FieldTransition.cpp @@ -0,0 +1,76 @@ +#include +#include +#include + +FieldTransition::Builder::Builder(size_t id, const BulbId& bulbId, TransitionFn callback, GroupStateField field, uint16_t start, uint16_t end) + : Transition::Builder(id, bulbId, callback) + , stepSize(0) + , field(field) + , start(start) + , end(end) +{ } + +std::shared_ptr FieldTransition::Builder::_build() const { + size_t numPeriods = getOrComputeNumPeriods(); + size_t period = getOrComputePeriod(); + + int16_t distance = end - start; + int16_t stepSize = ceil(std::abs(distance / static_cast(numPeriods))); + + if (end < start) { + stepSize = -stepSize; + } + if (stepSize == 0) { + stepSize = end > start ? 1 : -1; + } + + return std::make_shared( + id, + bulbId, + field, + start, + end, + stepSize, + period, + callback + ); +} + +FieldTransition::FieldTransition( + size_t id, + const BulbId& bulbId, + GroupStateField field, + uint16_t startValue, + uint16_t endValue, + int16_t stepSize, + size_t period, + TransitionFn callback +) : Transition(id, bulbId, period, callback) + , field(field) + , currentValue(startValue) + , endValue(endValue) + , stepSize(stepSize) + , finished(false) +{ } + +void FieldTransition::step() { + callback(bulbId, field, currentValue); + + if (currentValue != endValue) { + Transition::stepValue(currentValue, endValue, stepSize); + } else { + finished = true; + } +} + +bool FieldTransition::isFinished() { + return finished; +} + +void FieldTransition::childSerialize(JsonObject& json) { + json[F("type")] = F("field"); + json[F("field")] = GroupStateFieldHelpers::getFieldName(field); + json[F("current_value")] = currentValue; + json[F("end_value")] = endValue; + json[F("step_size")] = stepSize; +} \ No newline at end of file diff --git a/lib/Transitions/FieldTransition.h b/lib/Transitions/FieldTransition.h new file mode 100644 index 00000000..dfd44402 --- /dev/null +++ b/lib/Transitions/FieldTransition.h @@ -0,0 +1,48 @@ +#include +#include +#include +#include +#include +#include + +#pragma once + +class FieldTransition : public Transition { +public: + + class Builder : public Transition::Builder { + public: + Builder(size_t id, const BulbId& bulbId, TransitionFn callback, GroupStateField field, uint16_t start, uint16_t end); + + virtual std::shared_ptr _build() const override; + + private: + size_t stepSize; + GroupStateField field; + uint16_t start; + uint16_t end; + }; + + FieldTransition( + size_t id, + const BulbId& bulbId, + GroupStateField field, + uint16_t startValue, + uint16_t endValue, + int16_t stepSize, + size_t period, + TransitionFn callback + ); + + virtual bool isFinished() override; + +private: + const GroupStateField field; + int16_t currentValue; + const int16_t endValue; + const int16_t stepSize; + bool finished; + + virtual void step() override; + virtual void childSerialize(JsonObject& json) override; +}; \ No newline at end of file diff --git a/lib/Transitions/Transition.cpp b/lib/Transitions/Transition.cpp new file mode 100644 index 00000000..40765dc9 --- /dev/null +++ b/lib/Transitions/Transition.cpp @@ -0,0 +1,169 @@ +#include +#include +#include + +Transition::Builder::Builder(size_t id, const BulbId& bulbId, TransitionFn callback) + : id(id) + , bulbId(bulbId) + , callback(callback) + , duration(0) + , period(0) + , numPeriods(0) +{ } + +Transition::Builder& Transition::Builder::setDuration(float duration) { + this->duration = duration * DURATION_UNIT_MULTIPLIER; + return *this; +} + +void Transition::Builder::setDurationRaw(size_t duration) { + this->duration = duration; +} + +Transition::Builder& Transition::Builder::setPeriod(size_t period) { + this->period = period; + return *this; +} + +Transition::Builder& Transition::Builder::setNumPeriods(size_t numPeriods) { + this->numPeriods = numPeriods; + return *this; +} + +size_t Transition::Builder::getNumPeriods() const { + return this->numPeriods; +} + +size_t Transition::Builder::getDuration() const { + return this->duration; +} + +size_t Transition::Builder::getPeriod() const { + return this->period; +} + +bool Transition::Builder::isSetDuration() const { + return this->duration > 0; +} + +bool Transition::Builder::isSetPeriod() const { + return this->period > 0; +} + +bool Transition::Builder::isSetNumPeriods() const { + return this->numPeriods > 0; +} + +size_t Transition::Builder::numSetParams() const { + size_t setCount = 0; + + if (isSetDuration()) { ++setCount; } + if (isSetPeriod()) { ++setCount; } + if (isSetNumPeriods()) { ++setCount; } + + return setCount; +} + +size_t Transition::Builder::getOrComputePeriod() const { + if (period > 0) { + return period; + } else if (duration > 0 && numPeriods > 0) { + size_t computed = floor(duration / static_cast(numPeriods)); + return max(MIN_PERIOD, computed); + } else { + return 0; + } +} + +size_t Transition::Builder::getOrComputeDuration() const { + if (duration > 0) { + return duration; + } else if (period > 0 && numPeriods > 0) { + return period * numPeriods; + } else { + return 0; + } +} + +size_t Transition::Builder::getOrComputeNumPeriods() const { + if (numPeriods > 0) { + return numPeriods; + } else if (period > 0 && duration > 0) { + size_t _numPeriods = ceil(duration / static_cast(period)); + return max(static_cast(1), _numPeriods); + } else { + return 0; + } +} + +std::shared_ptr Transition::Builder::build() { + // Set defaults for underspecified transitions + size_t numSet = numSetParams(); + + if (numSet == 0) { + setDuration(DEFAULT_DURATION); + setPeriod(DEFAULT_PERIOD); + } else if (numSet == 1) { + // If duration is unbound, bind it + if (! isSetDuration()) { + setDurationRaw(DEFAULT_DURATION); + // Otherwise, bind the period + } else { + setPeriod(DEFAULT_PERIOD); + } + } + + return _build(); +} + +Transition::Transition( + size_t id, + const BulbId& bulbId, + size_t period, + TransitionFn callback +) : id(id) + , bulbId(bulbId) + , callback(callback) + , period(period) + , lastSent(0) +{ } + +void Transition::tick() { + unsigned long now = millis(); + + if ((lastSent + period) <= now + && ((!isFinished() || lastSent == 0))) { // always send at least once + + step(); + lastSent = now; + } +} + +size_t Transition::calculatePeriod(int16_t distance, size_t stepSize, size_t duration) { + float fPeriod = + distance != 0 + ? (duration / (distance / static_cast(stepSize))) + : 0; + + return static_cast(round(fPeriod)); +} + +void Transition::stepValue(int16_t& current, int16_t end, int16_t stepSize) { + int16_t delta = end - current; + if (std::abs(delta) < std::abs(stepSize)) { + current += delta; + } else { + current += stepSize; + } +} + +void Transition::serialize(JsonObject& json) { + json[F("id")] = id; + json[F("period")] = period; + json[F("last_sent")] = lastSent; + + JsonObject bulbParams = json.createNestedObject("bulb"); + bulbId.serialize(bulbParams); + + childSerialize(json); +} \ No newline at end of file diff --git a/lib/Transitions/Transition.h b/lib/Transitions/Transition.h new file mode 100644 index 00000000..4e21c8e6 --- /dev/null +++ b/lib/Transitions/Transition.h @@ -0,0 +1,89 @@ +#include +#include +#include +#include +#include +#include +#include + +#pragma once + +class Transition { +public: + using TransitionFn = std::function; + + // transition commands are in seconds, convert to ms. + static const uint16_t DURATION_UNIT_MULTIPLIER = 1000; + + + class Builder { + public: + Builder(size_t id, const BulbId& bulbId, TransitionFn callback); + + Builder& setDuration(float duration); + Builder& setPeriod(size_t period); + Builder& setNumPeriods(size_t numPeriods); + + void setDurationRaw(size_t duration); + + bool isSetDuration() const; + bool isSetPeriod() const; + bool isSetNumPeriods() const; + + size_t getOrComputePeriod() const; + size_t getOrComputeDuration() const; + size_t getOrComputeNumPeriods() const; + + size_t getDuration() const; + size_t getPeriod() const; + size_t getNumPeriods() const; + + std::shared_ptr build(); + + const size_t id; + const BulbId& bulbId; + const TransitionFn callback; + + private: + size_t duration; + size_t period; + size_t numPeriods; + + virtual std::shared_ptr _build() const = 0; + size_t numSetParams() const; + }; + + // Default time to wait between steps. Do this rather than having a fixed step size because it's + // more capable of adapting to different situations. + static const size_t DEFAULT_PERIOD = 450; + static const size_t DEFAULT_NUM_PERIODS = 10; + static const size_t DEFAULT_DURATION = DEFAULT_PERIOD*DEFAULT_NUM_PERIODS; + + // If period goes lower than this, throttle other parameters up to adjust. + static const size_t MIN_PERIOD = 150; + + const size_t id; + const BulbId bulbId; + const TransitionFn callback; + + Transition( + size_t id, + const BulbId& bulbId, + size_t period, + TransitionFn callback + ); + + void tick(); + virtual bool isFinished() = 0; + void serialize(JsonObject& doc); + virtual void step() = 0; + virtual void childSerialize(JsonObject& doc) = 0; + + static size_t calculatePeriod(int16_t distance, size_t stepSize, size_t duration); + +protected: + const size_t period; + unsigned long lastSent; + + static void stepValue(int16_t& current, int16_t end, int16_t stepSize); +}; \ No newline at end of file diff --git a/lib/Transitions/TransitionController.cpp b/lib/Transitions/TransitionController.cpp new file mode 100644 index 00000000..833ed00d --- /dev/null +++ b/lib/Transitions/TransitionController.cpp @@ -0,0 +1,135 @@ +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace std::placeholders; + +TransitionController::TransitionController() + : callback(std::bind(&TransitionController::transitionCallback, this, _1, _2, _3)) + , currentId(0) +{ } + +void TransitionController::clearListeners() { + observers.clear(); +} + +void TransitionController::addListener(Transition::TransitionFn fn) { + observers.push_back(fn); +} + +std::shared_ptr TransitionController::buildColorTransition(const BulbId& bulbId, const ParsedColor& start, const ParsedColor& end) { + return std::make_shared( + currentId++, + bulbId, + callback, + start, + end + ); +} + +std::shared_ptr TransitionController::buildFieldTransition(const BulbId& bulbId, GroupStateField field, uint16_t start, uint16_t end) { + return std::make_shared( + currentId++, + bulbId, + callback, + field, + start, + end + ); +} + +std::shared_ptr TransitionController::buildStatusTransition(const BulbId& bulbId, MiLightStatus status, uint8_t startLevel) { + std::shared_ptr transition; + + if (status == ON) { + // Make sure bulb is on before transitioning brightness + callback(bulbId, GroupStateField::STATUS, ON); + + transition = buildFieldTransition(bulbId, GroupStateField::LEVEL, startLevel, 100); + } else { + transition = std::make_shared( + currentId++, + GroupStateField::STATUS, + OFF, + buildFieldTransition(bulbId, GroupStateField::LEVEL, startLevel, 0) + ); + } + + return transition; +} + +void TransitionController::addTransition(std::shared_ptr transition) { + activeTransitions.add(transition); +} + +void TransitionController::transitionCallback(const BulbId& bulbId, GroupStateField field, uint16_t arg) { + for (auto it = observers.begin(); it != observers.end(); ++it) { + (*it)(bulbId, field, arg); + } +} + +void TransitionController::clear() { + activeTransitions.clear(); +} + +void TransitionController::loop() { + auto current = activeTransitions.getHead(); + + while (current != nullptr) { + auto next = current->next; + + Transition& t = *current->data; + t.tick(); + + if (t.isFinished()) { + activeTransitions.remove(current); + } + + current = next; + } +} + +ListNode>* TransitionController::getTransitions() { + return activeTransitions.getHead(); +} + +ListNode>* TransitionController::findTransition(size_t id) { + auto current = getTransitions(); + + while (current != nullptr) { + if (current->data->id == id) { + return current; + } + current = current->next; + } + + return nullptr; +} + +Transition* TransitionController::getTransition(size_t id) { + auto node = findTransition(id); + + if (node == nullptr) { + return nullptr; + } else { + return node->data.get(); + } +} + +bool TransitionController::deleteTransition(size_t id) { + auto node = findTransition(id); + + if (node == nullptr) { + return false; + } else { + activeTransitions.remove(node); + return true; + } +} \ No newline at end of file diff --git a/lib/Transitions/TransitionController.h b/lib/Transitions/TransitionController.h new file mode 100644 index 00000000..09402c32 --- /dev/null +++ b/lib/Transitions/TransitionController.h @@ -0,0 +1,37 @@ +#include +#include +#include +#include +#include +#include + +#pragma once + +class TransitionController { +public: + TransitionController(); + + void clearListeners(); + void addListener(Transition::TransitionFn fn); + + std::shared_ptr buildColorTransition(const BulbId& bulbId, const ParsedColor& start, const ParsedColor& end); + std::shared_ptr buildFieldTransition(const BulbId& bulbId, GroupStateField field, uint16_t start, uint16_t end); + std::shared_ptr buildStatusTransition(const BulbId& bulbId, MiLightStatus toStatus, uint8_t startLevel); + + void addTransition(std::shared_ptr transition); + void clear(); + void loop(); + + ListNode>* getTransitions(); + Transition* getTransition(size_t id); + ListNode>* findTransition(size_t id); + bool deleteTransition(size_t id); + +private: + Transition::TransitionFn callback; + LinkedList> activeTransitions; + std::vector observers; + size_t currentId; + + void transitionCallback(const BulbId& bulbId, GroupStateField field, uint16_t arg); +}; \ No newline at end of file diff --git a/lib/Types/BulbId.cpp b/lib/Types/BulbId.cpp new file mode 100644 index 00000000..037078da --- /dev/null +++ b/lib/Types/BulbId.cpp @@ -0,0 +1,60 @@ +#include +#include + +BulbId::BulbId() + : deviceId(0), + groupId(0), + deviceType(REMOTE_TYPE_UNKNOWN) +{ } + +BulbId::BulbId(const BulbId &other) + : deviceId(other.deviceId), + groupId(other.groupId), + deviceType(other.deviceType) +{ } + +BulbId::BulbId( + const uint16_t deviceId, const uint8_t groupId, const MiLightRemoteType deviceType +) + : deviceId(deviceId), + groupId(groupId), + deviceType(deviceType) +{ } + +void BulbId::operator=(const BulbId &other) { + deviceId = other.deviceId; + groupId = other.groupId; + deviceType = other.deviceType; +} + +// determine if now BulbId's are the same. This compared deviceID (the controller/remote ID) and +// groupId (the group number on the controller, 1-4 or 1-8 depending), but ignores the deviceType +// (type of controller/remote) as this doesn't directly affect the identity of the bulb +bool BulbId::operator==(const BulbId &other) { + return deviceId == other.deviceId + && groupId == other.groupId + && deviceType == other.deviceType; +} + +uint32_t BulbId::getCompactId() const { + uint32_t id = (deviceId << 24) | (deviceType << 8) | groupId; + return id; +} + +String BulbId::getHexDeviceId() const { + char hexDeviceId[7]; + sprintf_P(hexDeviceId, PSTR("0x%X"), deviceId); + return hexDeviceId; +} + +void BulbId::serialize(JsonObject json) const { + json[GroupStateFieldNames::DEVICE_ID] = deviceId; + json[GroupStateFieldNames::GROUP_ID] = groupId; + json[GroupStateFieldNames::DEVICE_TYPE] = MiLightRemoteTypeHelpers::remoteTypeToString(deviceType); +} + +void BulbId::serialize(JsonArray json) const { + json.add(deviceId); + json.add(MiLightRemoteTypeHelpers::remoteTypeToString(deviceType)); + json.add(groupId); +} \ No newline at end of file diff --git a/lib/Types/BulbId.h b/lib/Types/BulbId.h new file mode 100644 index 00000000..c96a579c --- /dev/null +++ b/lib/Types/BulbId.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include +#include + +struct BulbId { + uint16_t deviceId; + uint8_t groupId; + MiLightRemoteType deviceType; + + BulbId(); + BulbId(const BulbId& other); + BulbId(const uint16_t deviceId, const uint8_t groupId, const MiLightRemoteType deviceType); + bool operator==(const BulbId& other); + void operator=(const BulbId& other); + + uint32_t getCompactId() const; + String getHexDeviceId() const; + void serialize(JsonObject json) const; + void serialize(JsonArray json) const; +}; \ No newline at end of file diff --git a/lib/Types/GroupStateField.cpp b/lib/Types/GroupStateField.cpp index 3dc43a91..33434b69 100644 --- a/lib/Types/GroupStateField.cpp +++ b/lib/Types/GroupStateField.cpp @@ -1,6 +1,28 @@ #include #include +static const char* STATE_NAMES[] = { + GroupStateFieldNames::UNKNOWN, + GroupStateFieldNames::STATE, + GroupStateFieldNames::STATUS, + GroupStateFieldNames::BRIGHTNESS, + GroupStateFieldNames::LEVEL, + GroupStateFieldNames::HUE, + GroupStateFieldNames::SATURATION, + GroupStateFieldNames::COLOR, + GroupStateFieldNames::MODE, + GroupStateFieldNames::KELVIN, + GroupStateFieldNames::COLOR_TEMP, + GroupStateFieldNames::BULB_MODE, + GroupStateFieldNames::COMPUTED_COLOR, + GroupStateFieldNames::EFFECT, + GroupStateFieldNames::DEVICE_ID, + GroupStateFieldNames::GROUP_ID, + GroupStateFieldNames::DEVICE_TYPE, + GroupStateFieldNames::OH_COLOR, + GroupStateFieldNames::HEX_COLOR +}; + GroupStateField GroupStateFieldHelpers::getFieldByName(const char* name) { for (size_t i = 0; i < size(STATE_NAMES); i++) { if (0 == strcmp(name, STATE_NAMES[i])) { @@ -18,3 +40,13 @@ const char* GroupStateFieldHelpers::getFieldName(GroupStateField field) { } return STATE_NAMES[0]; } + +bool GroupStateFieldHelpers::isBrightnessField(GroupStateField field) { + switch (field) { + case GroupStateField::BRIGHTNESS: + case GroupStateField::LEVEL: + return true; + default: + return false; + } +} \ No newline at end of file diff --git a/lib/Types/GroupStateField.h b/lib/Types/GroupStateField.h index 05f60b4b..e58dc2dc 100644 --- a/lib/Types/GroupStateField.h +++ b/lib/Types/GroupStateField.h @@ -1,24 +1,29 @@ #ifndef _GROUP_STATE_FIELDS_H #define _GROUP_STATE_FIELDS_H -static const char* STATE_NAMES[] = { - "unknown", - "state", - "status", - "brightness", - "level", - "hue", - "saturation", - "color", - "mode", - "kelvin", - "color_temp", - "bulb_mode", - "computed_color", - "effect", - "device_id", - "group_id", - "device_type" +namespace GroupStateFieldNames { + static const char UNKNOWN[] = "unknown"; + static const char STATE[] = "state"; + static const char STATUS[] = "status"; + static const char BRIGHTNESS[] = "brightness"; + static const char LEVEL[] = "level"; + static const char HUE[] = "hue"; + static const char SATURATION[] = "saturation"; + static const char COLOR[] = "color"; + static const char MODE[] = "mode"; + static const char KELVIN[] = "kelvin"; + static const char TEMPERATURE[] = "temperature"; //alias for kelvin + static const char COLOR_TEMP[] = "color_temp"; + static const char BULB_MODE[] = "bulb_mode"; + static const char COMPUTED_COLOR[] = "computed_color"; + static const char EFFECT[] = "effect"; + static const char DEVICE_ID[] = "device_id"; + static const char GROUP_ID[] = "group_id"; + static const char DEVICE_TYPE[] = "device_type"; + static const char OH_COLOR[] = "oh_color"; + static const char HEX_COLOR[] = "hex_color"; + static const char COMMAND[] = "command"; + static const char COMMANDS[] = "commands"; }; enum class GroupStateField { @@ -38,13 +43,16 @@ enum class GroupStateField { EFFECT, DEVICE_ID, GROUP_ID, - DEVICE_TYPE + DEVICE_TYPE, + OH_COLOR, + HEX_COLOR }; class GroupStateFieldHelpers { public: static const char* getFieldName(GroupStateField field); static GroupStateField getFieldByName(const char* name); + static bool isBrightnessField(GroupStateField field); }; #endif diff --git a/lib/Types/MiLightCommands.h b/lib/Types/MiLightCommands.h new file mode 100644 index 00000000..7542391d --- /dev/null +++ b/lib/Types/MiLightCommands.h @@ -0,0 +1,18 @@ +#pragma once + +namespace MiLightCommandNames { + static const char UNPAIR[] = "unpair"; + static const char PAIR[] = "pair"; + static const char SET_WHITE[] = "set_white"; + static const char NIGHT_MODE[] = "night_mode"; + static const char LEVEL_UP[] = "level_up"; + static const char LEVEL_DOWN[] = "level_down"; + static const char TEMPERATURE_UP[] = "temperature_up"; + static const char TEMPERATURE_DOWN[] = "temperature_down"; + static const char NEXT_MODE[] = "next_mode"; + static const char PREVIOUS_MODE[] = "previous_mode"; + static const char MODE_SPEED_DOWN[] = "mode_speed_down"; + static const char MODE_SPEED_UP[] = "mode_speed_up"; + static const char TOGGLE[] = "toggle"; + static const char TRANSITION[] = "transition"; +}; \ No newline at end of file diff --git a/lib/Types/MiLightConstants.h b/lib/Types/MiLightConstants.h deleted file mode 100644 index bd4fdc47..00000000 --- a/lib/Types/MiLightConstants.h +++ /dev/null @@ -1,19 +0,0 @@ -#ifndef _MILIGHT_BUTTONS -#define _MILIGHT_BUTTONS - -enum MiLightRemoteType { - REMOTE_TYPE_UNKNOWN = 255, - REMOTE_TYPE_RGBW = 0, - REMOTE_TYPE_CCT = 1, - REMOTE_TYPE_RGB_CCT = 2, - REMOTE_TYPE_RGB = 3, - REMOTE_TYPE_FUT089 = 4, - REMOTE_TYPE_FUT091 = 5 -}; - -enum MiLightStatus { - ON = 0, - OFF = 1 -}; - -#endif diff --git a/lib/Types/MiLightRemoteType.cpp b/lib/Types/MiLightRemoteType.cpp new file mode 100644 index 00000000..2e08f7c2 --- /dev/null +++ b/lib/Types/MiLightRemoteType.cpp @@ -0,0 +1,68 @@ +#include +#include + +static const char* REMOTE_NAME_RGBW = "rgbw"; +static const char* REMOTE_NAME_CCT = "cct"; +static const char* REMOTE_NAME_RGB_CCT = "rgb_cct"; +static const char* REMOTE_NAME_FUT089 = "fut089"; +static const char* REMOTE_NAME_RGB = "rgb"; +static const char* REMOTE_NAME_FUT091 = "fut091"; +static const char* REMOTE_NAME_FUT020 = "fut020"; + +const MiLightRemoteType MiLightRemoteTypeHelpers::remoteTypeFromString(const String& type) { + if (type.equalsIgnoreCase(REMOTE_NAME_RGBW) || type.equalsIgnoreCase("fut096")) { + return REMOTE_TYPE_RGBW; + } + + if (type.equalsIgnoreCase(REMOTE_NAME_CCT) || type.equalsIgnoreCase("fut007")) { + return REMOTE_TYPE_CCT; + } + + if (type.equalsIgnoreCase(REMOTE_NAME_RGB_CCT) || type.equalsIgnoreCase("fut092")) { + return REMOTE_TYPE_RGB_CCT; + } + + if (type.equalsIgnoreCase(REMOTE_NAME_FUT089)) { + return REMOTE_TYPE_FUT089; + } + + if (type.equalsIgnoreCase(REMOTE_NAME_RGB) || type.equalsIgnoreCase("fut098")) { + return REMOTE_TYPE_RGB; + } + + if (type.equalsIgnoreCase("v2_cct") || type.equalsIgnoreCase(REMOTE_NAME_FUT091)) { + return REMOTE_TYPE_FUT091; + } + + if (type.equalsIgnoreCase(REMOTE_NAME_FUT020)) { + return REMOTE_TYPE_FUT020; + } + + Serial.print(F("remoteTypeFromString: ERROR - tried to fetch remote config for type: ")); + Serial.println(type); + + return REMOTE_TYPE_UNKNOWN; +} + +const String MiLightRemoteTypeHelpers::remoteTypeToString(const MiLightRemoteType type) { + switch (type) { + case REMOTE_TYPE_RGBW: + return REMOTE_NAME_RGBW; + case REMOTE_TYPE_CCT: + return REMOTE_NAME_CCT; + case REMOTE_TYPE_RGB_CCT: + return REMOTE_NAME_RGB_CCT; + case REMOTE_TYPE_FUT089: + return REMOTE_NAME_FUT089; + case REMOTE_TYPE_RGB: + return REMOTE_NAME_RGB; + case REMOTE_TYPE_FUT091: + return REMOTE_NAME_FUT091; + case REMOTE_TYPE_FUT020: + return REMOTE_NAME_FUT020; + default: + Serial.print(F("remoteTypeToString: ERROR - tried to fetch remote config name for unknown type: ")); + Serial.println(type); + return "unknown"; + } +} \ No newline at end of file diff --git a/lib/Types/MiLightRemoteType.h b/lib/Types/MiLightRemoteType.h new file mode 100644 index 00000000..1b051dd0 --- /dev/null +++ b/lib/Types/MiLightRemoteType.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +enum MiLightRemoteType { + REMOTE_TYPE_UNKNOWN = 255, + REMOTE_TYPE_RGBW = 0, + REMOTE_TYPE_CCT = 1, + REMOTE_TYPE_RGB_CCT = 2, + REMOTE_TYPE_RGB = 3, + REMOTE_TYPE_FUT089 = 4, + REMOTE_TYPE_FUT091 = 5, + REMOTE_TYPE_FUT020 = 6 +}; + +class MiLightRemoteTypeHelpers { +public: + static const MiLightRemoteType remoteTypeFromString(const String& type); + static const String remoteTypeToString(const MiLightRemoteType type); +}; \ No newline at end of file diff --git a/lib/Types/MiLightStatus.cpp b/lib/Types/MiLightStatus.cpp new file mode 100644 index 00000000..88cf23dd --- /dev/null +++ b/lib/Types/MiLightStatus.cpp @@ -0,0 +1,13 @@ +#include +#include + +MiLightStatus parseMilightStatus(JsonVariant val) { + if (val.is()) { + return val.as() ? ON : OFF; + } else if (val.is()) { + return static_cast(val.as()); + } else { + String strStatus(val.as()); + return (strStatus.equalsIgnoreCase("on") || strStatus.equalsIgnoreCase("true")) ? ON : OFF; + } +} \ No newline at end of file diff --git a/lib/Types/MiLightStatus.h b/lib/Types/MiLightStatus.h new file mode 100644 index 00000000..d75bc6d6 --- /dev/null +++ b/lib/Types/MiLightStatus.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +enum MiLightStatus { + ON = 0, + OFF = 1 +}; + +MiLightStatus parseMilightStatus(JsonVariant s); \ No newline at end of file diff --git a/lib/Types/ParsedColor.cpp b/lib/Types/ParsedColor.cpp new file mode 100644 index 00000000..f65f5b10 --- /dev/null +++ b/lib/Types/ParsedColor.cpp @@ -0,0 +1,66 @@ +#include +#include +#include +#include +#include + +ParsedColor ParsedColor::fromRgb(uint16_t r, uint16_t g, uint16_t b) { + double hsv[3]; + RGBConverter converter; + converter.rgbToHsv(r, g, b, hsv); + + uint16_t hue = round(hsv[0]*360); + uint8_t saturation = round(hsv[1]*100); + + return ParsedColor{ + .success = true, + .hue = hue, + .r = r, + .g = g, + .b = b, + .saturation = saturation + }; +} + +ParsedColor ParsedColor::fromJson(JsonVariant json) { + uint16_t r, g, b; + + if (json.is()) { + JsonObject color = json.as(); + + r = color["r"]; + g = color["g"]; + b = color["b"]; + } else if (json.is()) { + const char* colorStr = json.as(); + const size_t len = strlen(colorStr); + + if (colorStr[0] == '#' && len == 7) { + uint8_t parsedHex[3]; + hexStrToBytes(colorStr+1, len-1, parsedHex, 3); + + r = parsedHex[0]; + g = parsedHex[1]; + b = parsedHex[2]; + } else { + char colorCStr[len+1]; + uint8_t parsedRgbColors[3] = {0, 0, 0}; + + strcpy(colorCStr, colorStr); + TokenIterator colorValueItr(colorCStr, len, ','); + + for (size_t i = 0; i < 3 && colorValueItr.hasNext(); ++i) { + parsedRgbColors[i] = atoi(colorValueItr.nextToken()); + } + + r = parsedRgbColors[0]; + g = parsedRgbColors[1]; + b = parsedRgbColors[2]; + } + } else { + Serial.println(F("GroupState::parseJsonColor - unknown format for color")); + return ParsedColor{ .success = false }; + } + + return ParsedColor::fromRgb(r, g, b); +} \ No newline at end of file diff --git a/lib/Types/ParsedColor.h b/lib/Types/ParsedColor.h new file mode 100644 index 00000000..f53c4ee1 --- /dev/null +++ b/lib/Types/ParsedColor.h @@ -0,0 +1,13 @@ +#include +#include + +#pragma once + +struct ParsedColor { + bool success; + uint16_t hue, r, g, b; + uint8_t saturation; + + static ParsedColor fromRgb(uint16_t r, uint16_t g, uint16_t b); + static ParsedColor fromJson(JsonVariant json); +}; \ No newline at end of file diff --git a/lib/Types/RF24Channel.cpp b/lib/Types/RF24Channel.cpp new file mode 100644 index 00000000..c15a6930 --- /dev/null +++ b/lib/Types/RF24Channel.cpp @@ -0,0 +1,45 @@ +#include +#include + +static const char* RF24_CHANNEL_NAMES[] = { + "LOW", + "MID", + "HIGH" +}; + +String RF24ChannelHelpers::nameFromValue(const RF24Channel& value) { + const size_t ix = static_cast(value); + + if (ix >= size(RF24_CHANNEL_NAMES)) { + Serial.println(F("ERROR: unknown RF24 channel label - this is a bug!")); + return nameFromValue(defaultValue()); + } + + return RF24_CHANNEL_NAMES[ix]; +} + +RF24Channel RF24ChannelHelpers::valueFromName(const String& name) { + for (size_t i = 0; i < size(RF24_CHANNEL_NAMES); ++i) { + if (name == RF24_CHANNEL_NAMES[i]) { + return static_cast(i); + } + } + + Serial.printf_P(PSTR("WARN: tried to fetch unknown RF24 channel: %s, using default.\n"), name.c_str()); + + return defaultValue(); +} + +RF24Channel RF24ChannelHelpers::defaultValue() { + return RF24Channel::RF24_HIGH; +} + +std::vector RF24ChannelHelpers::allValues() { + std::vector vec; + + for (size_t i = 0; i < size(RF24_CHANNEL_NAMES); ++i) { + vec.push_back(valueFromName(RF24_CHANNEL_NAMES[i])); + } + + return vec; +} \ No newline at end of file diff --git a/lib/Types/RF24Channel.h b/lib/Types/RF24Channel.h new file mode 100644 index 00000000..60cf2955 --- /dev/null +++ b/lib/Types/RF24Channel.h @@ -0,0 +1,21 @@ +#include +#include + +#ifndef _RF24_CHANNELS_H +#define _RF24_CHANNELS_H + +enum class RF24Channel { + RF24_LOW = 0, + RF24_MID = 1, + RF24_HIGH = 2 +}; + +class RF24ChannelHelpers { +public: + static String nameFromValue(const RF24Channel& value); + static RF24Channel valueFromName(const String& name); + static RF24Channel defaultValue(); + static std::vector allValues(); +}; + +#endif \ No newline at end of file diff --git a/lib/Types/RF24PowerLevel.cpp b/lib/Types/RF24PowerLevel.cpp new file mode 100644 index 00000000..0beea273 --- /dev/null +++ b/lib/Types/RF24PowerLevel.cpp @@ -0,0 +1,40 @@ +#include +#include + +static const char* RF24_POWER_LEVEL_NAMES[] = { + "MIN", + "LOW", + "HIGH", + "MAX" +}; + +String RF24PowerLevelHelpers::nameFromValue(const RF24PowerLevel& value) { + const size_t ix = static_cast(value); + + if (ix >= size(RF24_POWER_LEVEL_NAMES)) { + Serial.println(F("ERROR: unknown RF24 power level label - this is a bug!")); + return nameFromValue(defaultValue()); + } + + return RF24_POWER_LEVEL_NAMES[ix]; +} + +RF24PowerLevel RF24PowerLevelHelpers::valueFromName(const String& name) { + for (size_t i = 0; i < size(RF24_POWER_LEVEL_NAMES); ++i) { + if (name == RF24_POWER_LEVEL_NAMES[i]) { + return static_cast(i); + } + } + + Serial.printf_P(PSTR("WARN: tried to fetch unknown RF24 power level: %s, using default.\n"), name.c_str()); + + return defaultValue(); +} + +uint8_t RF24PowerLevelHelpers::rf24ValueFromValue(const RF24PowerLevel& rF24PowerLevel) { + return static_cast(rF24PowerLevel); +} + +RF24PowerLevel RF24PowerLevelHelpers::defaultValue() { + return RF24PowerLevel::RF24_MAX; +} \ No newline at end of file diff --git a/lib/Types/RF24PowerLevel.h b/lib/Types/RF24PowerLevel.h new file mode 100644 index 00000000..905698a5 --- /dev/null +++ b/lib/Types/RF24PowerLevel.h @@ -0,0 +1,22 @@ +#include +#include + +#ifndef _RF24_POWER_LEVEL_H +#define _RF24_POWER_LEVEL_H + +enum class RF24PowerLevel { + RF24_MIN = RF24_PA_MIN, // -18 dBm + RF24_LOW = RF24_PA_LOW, // -12 dBm + RF24_HIGH = RF24_PA_HIGH, // -6 dBm + RF24_MAX = RF24_PA_MAX // 0 dBm +}; + +class RF24PowerLevelHelpers { +public: + static String nameFromValue(const RF24PowerLevel& value); + static RF24PowerLevel valueFromName(const String& name); + static RF24PowerLevel defaultValue(); + static uint8_t rf24ValueFromValue(const RF24PowerLevel& vlaue); +}; + +#endif \ No newline at end of file diff --git a/lib/Udp/MiLightDiscoveryServer.cpp b/lib/Udp/MiLightDiscoveryServer.cpp index 697f8f0a..3e501b8b 100644 --- a/lib/Udp/MiLightDiscoveryServer.cpp +++ b/lib/Udp/MiLightDiscoveryServer.cpp @@ -16,6 +16,7 @@ MiLightDiscoveryServer::MiLightDiscoveryServer(MiLightDiscoveryServer& other) MiLightDiscoveryServer& MiLightDiscoveryServer::operator=(MiLightDiscoveryServer other) { this->settings = other.settings; this->socket = other.socket; + return *this; } MiLightDiscoveryServer::~MiLightDiscoveryServer() { @@ -48,15 +49,15 @@ void MiLightDiscoveryServer::handleClient() { void MiLightDiscoveryServer::handleDiscovery(uint8_t version) { #ifdef MILIGHT_UDP_DEBUG - printf("Handling discovery for version: %u, %d configs to consider\n", version, settings.numGatewayConfigs); + printf_P(PSTR("Handling discovery for version: %u, %d configs to consider\n"), version, settings.gatewayConfigs.size()); #endif char buffer[40]; - for (size_t i = 0; i < settings.numGatewayConfigs; i++) { - GatewayConfig* config = settings.gatewayConfigs[i]; + for (size_t i = 0; i < settings.gatewayConfigs.size(); i++) { + const GatewayConfig& config = *settings.gatewayConfigs[i]; - if (config->protocolVersion != version) { + if (config.protocolVersion != version) { continue; } @@ -66,10 +67,10 @@ void MiLightDiscoveryServer::handleDiscovery(uint8_t version) { buffer, PSTR("%d.%d.%d.%d,00000000%02X%02X"), addr[0], addr[1], addr[2], addr[3], - (config->deviceId >> 8), (config->deviceId & 0xFF) + (config.deviceId >> 8), (config.deviceId & 0xFF) ); - if (config->protocolVersion == 5) { + if (config.protocolVersion == 5) { sendResponse(buffer); } else { sprintf_P(ptr, PSTR(",HF-LPB100")); @@ -79,6 +80,10 @@ void MiLightDiscoveryServer::handleDiscovery(uint8_t version) { } void MiLightDiscoveryServer::sendResponse(char* buffer) { +#ifdef MILIGHT_UDP_DEBUG + printf_P(PSTR("Sending response: %s\n"), buffer); +#endif + socket.beginPacket(socket.remoteIP(), socket.remotePort()); socket.write(buffer); socket.endPacket(); diff --git a/lib/Udp/MiLightUdpServer.cpp b/lib/Udp/MiLightUdpServer.cpp index 6d6a8227..71fa931e 100644 --- a/lib/Udp/MiLightUdpServer.cpp +++ b/lib/Udp/MiLightUdpServer.cpp @@ -40,11 +40,11 @@ void MiLightUdpServer::handleClient() { } } -MiLightUdpServer* MiLightUdpServer::fromVersion(uint8_t version, MiLightClient*& client, uint16_t port, uint16_t deviceId) { +std::shared_ptr MiLightUdpServer::fromVersion(uint8_t version, MiLightClient*& client, uint16_t port, uint16_t deviceId) { if (version == 0 || version == 5) { - return new V5MiLightUdpServer(client, port, deviceId); + return std::make_shared(client, port, deviceId); } else if (version == 6) { - return new V6MiLightUdpServer(client, port, deviceId); + return std::make_shared(client, port, deviceId); } return NULL; diff --git a/lib/Udp/MiLightUdpServer.h b/lib/Udp/MiLightUdpServer.h index 70200425..4a9066f7 100644 --- a/lib/Udp/MiLightUdpServer.h +++ b/lib/Udp/MiLightUdpServer.h @@ -2,28 +2,30 @@ #include #include +#include + // This protocol is documented here: // http://www.limitlessled.com/dev/ -#define MILIGHT_PACKET_BUFFER_SIZE 30 +#define MILIGHT_PACKET_BUFFER_SIZE 30 // Uncomment to enable Serial printing of packets // #define MILIGHT_UDP_DEBUG #ifndef _MILIGHT_UDP_SERVER -#define _MILIGHT_UDP_SERVER +#define _MILIGHT_UDP_SERVER class MiLightUdpServer { public: MiLightUdpServer(MiLightClient*& client, uint16_t port, uint16_t deviceId); - ~MiLightUdpServer(); - + virtual ~MiLightUdpServer(); + void stop(); void begin(); void handleClient(); - - static MiLightUdpServer* fromVersion(uint8_t version, MiLightClient*&, uint16_t port, uint16_t deviceId); - + + static std::shared_ptr fromVersion(uint8_t version, MiLightClient*&, uint16_t port, uint16_t deviceId); + protected: WiFiUDP socket; MiLightClient*& client; @@ -32,7 +34,7 @@ class MiLightUdpServer { uint8_t lastGroup; uint8_t packetBuffer[MILIGHT_PACKET_BUFFER_SIZE]; uint8_t responseBuffer[MILIGHT_PACKET_BUFFER_SIZE]; - + // Should return size of the response packet virtual void handlePacket(uint8_t* packet, size_t packetSize) = 0; }; diff --git a/lib/Udp/V5MiLightUdpServer.cpp b/lib/Udp/V5MiLightUdpServer.cpp index 2ce35678..f5f8b5be 100644 --- a/lib/Udp/V5MiLightUdpServer.cpp +++ b/lib/Udp/V5MiLightUdpServer.cpp @@ -29,8 +29,10 @@ void V5MiLightUdpServer::handleCommand(uint8_t command, uint8_t commandArg) { this->lastGroup = groupId; // Set night_mode for RGBW } else if (command == UDP_RGBW_GROUP_ALL_NIGHT || command == UDP_RGBW_GROUP_1_NIGHT || command == UDP_RGBW_GROUP_2_NIGHT || command == UDP_RGBW_GROUP_3_NIGHT || command == UDP_RGBW_GROUP_4_NIGHT) { - const uint8_t groupId = (command - UDP_RGBW_GROUP_1_NIGHT + 2)/2; - if (command == UDP_RGBW_GROUP_ALL_NIGHT) const uint8_t groupId = 0; + uint8_t groupId = (command - UDP_RGBW_GROUP_1_NIGHT + 2)/2; + if (command == UDP_RGBW_GROUP_ALL_NIGHT) { + groupId = 0; + } client->prepare(&FUT096Config, deviceId, groupId); client->enableNightMode(); diff --git a/lib/Udp/V6MiLightUdpServer.h b/lib/Udp/V6MiLightUdpServer.h index 29bf2278..cd250a08 100644 --- a/lib/Udp/V6MiLightUdpServer.h +++ b/lib/Udp/V6MiLightUdpServer.h @@ -63,9 +63,9 @@ class V6MiLightUdpServer : public MiLightUdpServer { static uint8_t OPEN_COMMAND_RESPONSE[]; - V6Session* firstSession; - size_t numSessions; uint16_t sessionId; + size_t numSessions; + V6Session* firstSession; uint16_t beginSession(); bool sendResponse(uint16_t sessionId, uint8_t* responseBuffer, size_t responseSize); diff --git a/lib/Udp/V6RgbCommandHandler.cpp b/lib/Udp/V6RgbCommandHandler.cpp index b2b79571..2c10f62d 100644 --- a/lib/Udp/V6RgbCommandHandler.cpp +++ b/lib/Udp/V6RgbCommandHandler.cpp @@ -4,8 +4,7 @@ bool V6RgbCommandHandler::handlePreset( MiLightClient* client, uint8_t commandLsb, uint32_t commandArg) -{ -} +{ return true; } bool V6RgbCommandHandler::handleCommand( MiLightClient* client, diff --git a/lib/WebServer/MiLightHttpServer.cpp b/lib/WebServer/MiLightHttpServer.cpp index eec94f4d..78caec8e 100644 --- a/lib/WebServer/MiLightHttpServer.cpp +++ b/lib/WebServer/MiLightHttpServer.cpp @@ -6,34 +6,80 @@ #include #include #include +#include #include -void MiLightHttpServer::begin() { - applySettings(settings); +using namespace std::placeholders; +void MiLightHttpServer::begin() { // set up HTTP end points to serve - _handleRootPage = handleServe_P(index_html_gz, index_html_gz_len); - server.onAuthenticated("/", HTTP_GET, [this]() { _handleRootPage(); }); - server.onAuthenticated("/settings", HTTP_GET, [this]() { serveSettings(); }); - server.onAuthenticated("/settings", HTTP_PUT, [this]() { handleUpdateSettings(); }); - server.onAuthenticated("/settings", HTTP_POST, [this]() { handleUpdateSettingsPost(); }, handleUpdateFile(SETTINGS_FILE)); - server.onAuthenticated("/radio_configs", HTTP_GET, [this]() { handleGetRadioConfigs(); }); - - server.onAuthenticated("/gateway_traffic", HTTP_GET, [this]() { handleListenGateway(NULL); }); - server.onPatternAuthenticated("/gateway_traffic/:type", HTTP_GET, [this](const UrlTokenBindings* b) { handleListenGateway(b); }); - - const char groupPattern[] = "/gateways/:device_id/:type/:group_id"; - server.onPatternAuthenticated(groupPattern, HTTP_PUT, [this](const UrlTokenBindings* b) { handleUpdateGroup(b); }); - server.onPatternAuthenticated(groupPattern, HTTP_POST, [this](const UrlTokenBindings* b) { handleUpdateGroup(b); }); - server.onPatternAuthenticated(groupPattern, HTTP_GET, [this](const UrlTokenBindings* b) { handleGetGroup(b); }); - - server.onPatternAuthenticated("/raw_commands/:type", HTTP_ANY, [this](const UrlTokenBindings* b) { handleSendRaw(b); }); - server.onAuthenticated("/web", HTTP_POST, [this]() { server.send_P(200, TEXT_PLAIN, PSTR("success")); }, handleUpdateFile(WEB_INDEX_FILENAME)); - server.on("/about", HTTP_GET, [this]() { handleAbout(); }); - server.onAuthenticated("/system", HTTP_POST, [this]() { handleSystemPost(); }); - server.onAuthenticated("/firmware", HTTP_POST, [this]() { handleFirmwarePost(); }, [this]() { handleFirmwareUpload(); }); + server + .buildHandler("/") + .onSimple(HTTP_GET, std::bind(&MiLightHttpServer::handleServe_P, this, index_html_gz, index_html_gz_len)); + + server + .buildHandler("/settings") + .on(HTTP_GET, std::bind(&MiLightHttpServer::serveSettings, this)) + .on(HTTP_PUT, std::bind(&MiLightHttpServer::handleUpdateSettings, this, _1)) + .on( + HTTP_POST, + std::bind(&MiLightHttpServer::handleUpdateSettingsPost, this, _1), + std::bind(&MiLightHttpServer::handleUpdateFile, this, SETTINGS_FILE) + ); + server + .buildHandler("/remote_configs") + .on(HTTP_GET, std::bind(&MiLightHttpServer::handleGetRadioConfigs, this, _1)); + + server + .buildHandler("/gateway_traffic") + .on(HTTP_GET, std::bind(&MiLightHttpServer::handleListenGateway, this, _1)); + server + .buildHandler("/gateway_traffic/:type") + .on(HTTP_GET, std::bind(&MiLightHttpServer::handleListenGateway, this, _1)); + + server + .buildHandler("/gateways/:device_id/:type/:group_id") + .on(HTTP_PUT, std::bind(&MiLightHttpServer::handleUpdateGroup, this, _1)) + .on(HTTP_POST, std::bind(&MiLightHttpServer::handleUpdateGroup, this, _1)) + .on(HTTP_DELETE, std::bind(&MiLightHttpServer::handleDeleteGroup, this, _1)) + .on(HTTP_GET, std::bind(&MiLightHttpServer::handleGetGroup, this, _1)); + + server + .buildHandler("/gateways/:device_alias") + .on(HTTP_PUT, std::bind(&MiLightHttpServer::handleUpdateGroupAlias, this, _1)) + .on(HTTP_POST, std::bind(&MiLightHttpServer::handleUpdateGroupAlias, this, _1)) + .on(HTTP_DELETE, std::bind(&MiLightHttpServer::handleDeleteGroupAlias, this, _1)) + .on(HTTP_GET, std::bind(&MiLightHttpServer::handleGetGroupAlias, this, _1)); + + server + .buildHandler("/transitions/:id") + .on(HTTP_GET, std::bind(&MiLightHttpServer::handleGetTransition, this, _1)) + .on(HTTP_DELETE, std::bind(&MiLightHttpServer::handleDeleteTransition, this, _1)); + + server + .buildHandler("/transitions") + .on(HTTP_GET, std::bind(&MiLightHttpServer::handleListTransitions, this, _1)) + .on(HTTP_POST, std::bind(&MiLightHttpServer::handleCreateTransition, this, _1)); + + server + .buildHandler("/raw_commands/:type") + .on(HTTP_ANY, std::bind(&MiLightHttpServer::handleSendRaw, this, _1)); + + server + .buildHandler("/about") + .on(HTTP_GET, std::bind(&MiLightHttpServer::handleAbout, this, _1)); + + server + .buildHandler("/system") + .on(HTTP_POST, std::bind(&MiLightHttpServer::handleSystemPost, this, _1)); + + server + .buildHandler("/firmware") + .handleOTA(); + + server.clearBuilders(); // set up web socket server wsServer.onEvent( @@ -51,22 +97,21 @@ void MiLightHttpServer::handleClient() { wsServer.loop(); } -void MiLightHttpServer::on(const char* path, HTTPMethod method, ESP8266WebServer::THandlerFunction handler) { - server.on(path, method, handler); -} - WiFiClient MiLightHttpServer::client() { return server.client(); } -void MiLightHttpServer::handleSystemPost() { - DynamicJsonBuffer buffer; - JsonObject& request = buffer.parse(server.arg("plain")); +void MiLightHttpServer::on(const char* path, HTTPMethod method, ESP8266WebServer::THandlerFunction handler) { + server.on(path, method, handler); +} + +void MiLightHttpServer::handleSystemPost(RequestContext& request) { + JsonObject requestBody = request.getJsonBody().as(); bool handled = false; - if (request.containsKey("command")) { - if (request["command"] == "restart") { + if (requestBody.containsKey(GroupStateFieldNames::COMMAND)) { + if (requestBody[GroupStateFieldNames::COMMAND] == "restart") { Serial.println(F("Restarting...")); server.send_P(200, TEXT_PLAIN, PSTR("true")); @@ -75,7 +120,7 @@ void MiLightHttpServer::handleSystemPost() { ESP.restart(); handled = true; - } else if (request["command"] == "clear_wifi_config") { + } else if (requestBody[GroupStateFieldNames::COMMAND] == "clear_wifi_config") { Serial.println(F("Resetting Wifi and then Restarting...")); server.send_P(200, TEXT_PLAIN, PSTR("true")); @@ -89,9 +134,11 @@ void MiLightHttpServer::handleSystemPost() { } if (handled) { - server.send_P(200, TEXT_PLAIN, PSTR("true")); + request.response.json["success"] = true; } else { - server.send_P(400, TEXT_PLAIN, PSTR("{\"error\":\"Unhandled command\"}")); + request.response.json["success"] = false; + request.response.json["error"] = "Unhandled command"; + request.response.setCode(400); } } @@ -101,63 +148,29 @@ void MiLightHttpServer::serveSettings() { serveFile(SETTINGS_FILE, APPLICATION_JSON); } -void MiLightHttpServer::applySettings(Settings& settings) { - if (settings.hasAuthSettings()) { - server.requireAuthentication(settings.adminUsername, settings.adminPassword); - } else { - server.disableAuthentication(); - } -} - void MiLightHttpServer::onSettingsSaved(SettingsSavedHandler handler) { this->settingsSavedHandler = handler; } -void MiLightHttpServer::handleAbout() { - DynamicJsonBuffer buffer; - JsonObject& response = buffer.createObject(); - - response["version"] = QUOTE(MILIGHT_HUB_VERSION); - response["variant"] = QUOTE(FIRMWARE_VARIANT); - response["free_heap"] = ESP.getFreeHeap(); - response["arduino_version"] = ESP.getCoreVersion(); - response["reset_reason"] = ESP.getResetReason(); +void MiLightHttpServer::onGroupDeleted(GroupDeletedHandler handler) { + this->groupDeletedHandler = handler; +} - String body; - response.printTo(body); +void MiLightHttpServer::handleAbout(RequestContext& request) { + AboutHelper::generateAboutObject(request.response.json); - server.send(200, APPLICATION_JSON, body); + JsonObject queueStats = request.response.json.createNestedObject("queue_stats"); + queueStats[F("length")] = packetSender->queueLength(); + queueStats[F("dropped_packets")] = packetSender->droppedPackets(); } -void MiLightHttpServer::handleGetRadioConfigs() { - DynamicJsonBuffer buffer; - JsonArray& arr = buffer.createArray(); +void MiLightHttpServer::handleGetRadioConfigs(RequestContext& request) { + JsonArray arr = request.response.json.to(); - for (size_t i = 0; i < MiLightRadioConfig::NUM_CONFIGS; i++) { + for (size_t i = 0; i < MiLightRemoteConfig::NUM_REMOTES; i++) { const MiLightRemoteConfig* config = MiLightRemoteConfig::ALL_REMOTES[i]; arr.add(config->name); } - - String body; - arr.printTo(body); - - server.send(200, APPLICATION_JSON, body); -} - -ESP8266WebServer::THandlerFunction MiLightHttpServer::handleServeFile( - const char* filename, - const char* contentType, - const char* defaultText) { - - return [this, filename, contentType, defaultText]() { - if (!serveFile(filename)) { - if (defaultText) { - server.send(200, contentType, defaultText); - } else { - server.send(404); - } - } - }; } bool MiLightHttpServer::serveFile(const char* file, const char* contentType) { @@ -171,48 +184,44 @@ bool MiLightHttpServer::serveFile(const char* file, const char* contentType) { return false; } -ESP8266WebServer::THandlerFunction MiLightHttpServer::handleUpdateFile(const char* filename) { - return [this, filename]() { - HTTPUpload& upload = server.upload(); +void MiLightHttpServer::handleUpdateFile(const char* filename) { + HTTPUpload& upload = server.upload(); - if (upload.status == UPLOAD_FILE_START) { - updateFile = SPIFFS.open(filename, "w"); - } else if(upload.status == UPLOAD_FILE_WRITE){ - if (updateFile.write(upload.buf, upload.currentSize) != upload.currentSize) { - Serial.println(F("Error updating web file")); - } - } else if (upload.status == UPLOAD_FILE_END) { - updateFile.close(); + if (upload.status == UPLOAD_FILE_START) { + updateFile = SPIFFS.open(filename, "w"); + } else if(upload.status == UPLOAD_FILE_WRITE){ + if (updateFile.write(upload.buf, upload.currentSize) != upload.currentSize) { + Serial.println(F("Error updating web file")); } - }; + } else if (upload.status == UPLOAD_FILE_END) { + updateFile.close(); + } } -void MiLightHttpServer::handleUpdateSettings() { - DynamicJsonBuffer buffer; - const String& rawSettings = server.arg("plain"); - JsonObject& parsedSettings = buffer.parse(rawSettings); +void MiLightHttpServer::handleUpdateSettings(RequestContext& request) { + JsonObject parsedSettings = request.getJsonBody().as(); - if (parsedSettings.success()) { + if (! parsedSettings.isNull()) { settings.patch(parsedSettings); settings.save(); - this->applySettings(settings); - if (this->settingsSavedHandler) { this->settingsSavedHandler(); } - server.send(200, APPLICATION_JSON, "true"); + request.response.json["success"] = true; Serial.println(F("Settings successfully updated")); - } else { - server.send(400, APPLICATION_JSON, "\"Invalid JSON\""); - Serial.println(F("Settings failed to update; invalid JSON")); } } -void MiLightHttpServer::handleUpdateSettingsPost() { +void MiLightHttpServer::handleUpdateSettingsPost(RequestContext& request) { Settings::load(settings); - server.send_P(200, TEXT_PLAIN, PSTR("success.")); + + if (this->settingsSavedHandler) { + this->settingsSavedHandler(); + } + + request.response.json["success"] = true; } void MiLightHttpServer::handleFirmwarePost() { @@ -260,39 +269,46 @@ void MiLightHttpServer::handleFirmwareUpload() { } -void MiLightHttpServer::handleListenGateway(const UrlTokenBindings* bindings) { - bool available = false; - bool listenAll = bindings == NULL; +void MiLightHttpServer::handleListenGateway(RequestContext& request) { + bool listenAll = !request.pathVariables.hasBinding("type"); size_t configIx = 0; - const MiLightRadioConfig* radioConfig = NULL; + std::shared_ptr radio = NULL; const MiLightRemoteConfig* remoteConfig = NULL; + const MiLightRemoteConfig* tmpRemoteConfig = NULL; + uint8_t packet[MILIGHT_MAX_PACKET_LENGTH]; - if (bindings != NULL) { - String strType(bindings->get("type")); - const MiLightRemoteConfig* remoteConfig = MiLightRemoteConfig::fromType(strType); - milightClient->prepare(remoteConfig, 0, 0); - radioConfig = &remoteConfig->radioConfig; + if (!listenAll) { + String strType(request.pathVariables.get("type")); + tmpRemoteConfig = MiLightRemoteConfig::fromType(strType); + milightClient->prepare(tmpRemoteConfig, 0, 0); } - if (radioConfig == NULL && !listenAll) { - server.send_P(400, TEXT_PLAIN, PSTR("Unknown device type supplied.")); + if (tmpRemoteConfig == NULL && !listenAll) { + request.response.setCode(400); + request.response.json["error"] = "Unknown device type supplied"; return; } + if (tmpRemoteConfig != NULL) { + radio = radios->switchRadio(tmpRemoteConfig); + } + while (remoteConfig == NULL) { - if (!server.clientConnected()) { + if (!server.client().connected()) { return; } if (listenAll) { - radioConfig = &milightClient->switchRadio(configIx++ % milightClient->getNumRadios())->config(); + radio = radios->switchRadio(configIx++ % radios->getNumRadios()); + } else { + radio->configure(); } - if (milightClient->available()) { - size_t packetLen = milightClient->read(packet); + if (radios->available()) { + size_t packetLen = radios->read(packet); remoteConfig = MiLightRemoteConfig::fromReceivedPacket( - *radioConfig, + radio->config(), packet, packetLen ); @@ -301,8 +317,8 @@ void MiLightHttpServer::handleListenGateway(const UrlTokenBindings* bindings) { yield(); } - char response[200]; - char* responseBuffer = response; + char responseBody[200]; + char* responseBuffer = responseBody; responseBuffer += sprintf_P( responseBuffer, @@ -312,56 +328,136 @@ void MiLightHttpServer::handleListenGateway(const UrlTokenBindings* bindings) { ); remoteConfig->packetFormatter->format(packet, responseBuffer); - server.send(200, "text/plain", response); + request.response.json["packet_info"] = responseBody; } -void MiLightHttpServer::sendGroupState(BulbId& bulbId, GroupState* state) { - String body; - StaticJsonBuffer<200> jsonBuffer; - JsonObject& obj = jsonBuffer.createObject(); +void MiLightHttpServer::sendGroupState(BulbId& bulbId, GroupState* state, RichHttp::Response& response) { + bool blockOnQueue = server.arg("blockOnQueue").equalsIgnoreCase("true"); - if (state != NULL) { - state->applyState(obj, bulbId, settings.groupStateFields, settings.numGroupStateFields); + // Wait for packet queue to flush out. State will not have been updated before that. + // Bit hacky to call loop outside of main loop, but should be fine. + while (blockOnQueue && packetSender->isSending()) { + packetSender->loop(); } - obj.printTo(body); + JsonObject obj = response.json.to(); + + if (blockOnQueue && state != NULL) { + state->applyState(obj, bulbId, settings.groupStateFields); + } else { + obj[F("success")] = true; + } +} - server.send(200, APPLICATION_JSON, body); +void MiLightHttpServer::_handleGetGroup(BulbId bulbId, RequestContext& request) { + sendGroupState(bulbId, stateStore->get(bulbId), request.response); } -void MiLightHttpServer::handleGetGroup(const UrlTokenBindings* urlBindings) { - const String _deviceId = urlBindings->get("device_id"); - uint8_t _groupId = atoi(urlBindings->get("group_id")); - const MiLightRemoteConfig* _remoteType = MiLightRemoteConfig::fromType(urlBindings->get("type")); +void MiLightHttpServer::handleGetGroupAlias(RequestContext& request) { + const String alias = request.pathVariables.get("device_alias"); + + std::map::iterator it = settings.groupIdAliases.find(alias); + + if (it == settings.groupIdAliases.end()) { + request.response.setCode(404); + request.response.json[F("error")] = F("Device alias not found"); + return; + } + + _handleGetGroup(it->second, request); +} + +void MiLightHttpServer::handleGetGroup(RequestContext& request) { + const String _deviceId = request.pathVariables.get(GroupStateFieldNames::DEVICE_ID); + uint8_t _groupId = atoi(request.pathVariables.get(GroupStateFieldNames::GROUP_ID)); + const MiLightRemoteConfig* _remoteType = MiLightRemoteConfig::fromType(request.pathVariables.get("type")); if (_remoteType == NULL) { char buffer[40]; sprintf_P(buffer, PSTR("Unknown device type\n")); - server.send(400, TEXT_PLAIN, buffer); + request.response.setCode(400); + request.response.json["error"] = buffer; return; } BulbId bulbId(parseInt(_deviceId), _groupId, _remoteType->type); - GroupState* state = stateStore->get(bulbId); - sendGroupState(bulbId, stateStore->get(bulbId)); + _handleGetGroup(bulbId, request); } -void MiLightHttpServer::handleUpdateGroup(const UrlTokenBindings* urlBindings) { - DynamicJsonBuffer buffer; - JsonObject& request = buffer.parse(server.arg("plain")); +void MiLightHttpServer::handleDeleteGroup(RequestContext& request) { + const String _deviceId = request.pathVariables.get(GroupStateFieldNames::DEVICE_ID); + uint8_t _groupId = atoi(request.pathVariables.get(GroupStateFieldNames::GROUP_ID)); + const MiLightRemoteConfig* _remoteType = MiLightRemoteConfig::fromType(request.pathVariables.get("type")); - if (!request.success()) { - server.send_P(400, TEXT_PLAIN, PSTR("Invalid JSON")); + if (_remoteType == NULL) { + char buffer[40]; + sprintf_P(buffer, PSTR("Unknown device type\n")); + request.response.setCode(400); + request.response.json["error"] = buffer; return; } - milightClient->setResendCount( - settings.httpRepeatFactor * settings.packetRepeats - ); + BulbId bulbId(parseInt(_deviceId), _groupId, _remoteType->type); + _handleDeleteGroup(bulbId, request); +} - String _deviceIds = urlBindings->get("device_id"); - String _groupIds = urlBindings->get("group_id"); - String _remoteTypes = urlBindings->get("type"); +void MiLightHttpServer::handleDeleteGroupAlias(RequestContext& request) { + const String alias = request.pathVariables.get("device_alias"); + + std::map::iterator it = settings.groupIdAliases.find(alias); + + if (it == settings.groupIdAliases.end()) { + request.response.setCode(404); + request.response.json[F("error")] = F("Device alias not found"); + return; + } + + _handleDeleteGroup(it->second, request); +} + +void MiLightHttpServer::_handleDeleteGroup(BulbId bulbId, RequestContext& request) { + stateStore->clear(bulbId); + + if (groupDeletedHandler != NULL) { + this->groupDeletedHandler(bulbId); + } + + request.response.json["success"] = true; +} + +void MiLightHttpServer::handleUpdateGroupAlias(RequestContext& request) { + const String alias = request.pathVariables.get("device_alias"); + + std::map::iterator it = settings.groupIdAliases.find(alias); + + if (it == settings.groupIdAliases.end()) { + request.response.setCode(404); + request.response.json[F("error")] = F("Device alias not found"); + return; + } + + BulbId& bulbId = it->second; + const MiLightRemoteConfig* config = MiLightRemoteConfig::fromType(bulbId.deviceType); + + if (config == NULL) { + char buffer[40]; + sprintf_P(buffer, PSTR("Unknown device type: %s"), bulbId.deviceType); + request.response.setCode(400); + request.response.json["error"] = buffer; + return; + } + + milightClient->prepare(config, bulbId.deviceId, bulbId.groupId); + handleRequest(request.getJsonBody().as()); + sendGroupState(bulbId, stateStore->get(bulbId), request.response); +} + +void MiLightHttpServer::handleUpdateGroup(RequestContext& request) { + JsonObject reqObj = request.getJsonBody().as(); + + String _deviceIds = request.pathVariables.get(GroupStateFieldNames::DEVICE_ID); + String _groupIds = request.pathVariables.get(GroupStateFieldNames::GROUP_ID); + String _remoteTypes = request.pathVariables.get("type"); char deviceIds[_deviceIds.length()]; char groupIds[_groupIds.length()]; char remoteTypes[_remoteTypes.length()]; @@ -383,7 +479,8 @@ void MiLightHttpServer::handleUpdateGroup(const UrlTokenBindings* urlBindings) { if (config == NULL) { char buffer[40]; sprintf_P(buffer, PSTR("Unknown device type: %s"), _remoteType); - server.send(400, "text/plain", buffer); + request.response.setCode(400); + request.response.json["error"] = buffer; return; } @@ -396,7 +493,7 @@ void MiLightHttpServer::handleUpdateGroup(const UrlTokenBindings* urlBindings) { const uint8_t groupId = atoi(groupIdItr.nextToken()); milightClient->prepare(config, deviceId, groupId); - handleRequest(request); + handleRequest(reqObj); foundBulbId = BulbId(deviceId, groupId, config->type); groupCount++; } @@ -404,44 +501,49 @@ void MiLightHttpServer::handleUpdateGroup(const UrlTokenBindings* urlBindings) { } if (groupCount == 1) { - sendGroupState(foundBulbId, stateStore->get(foundBulbId)); + sendGroupState(foundBulbId, stateStore->get(foundBulbId), request.response); } else { - server.send(200, APPLICATION_JSON, "true"); + request.response.json["success"] = true; } } void MiLightHttpServer::handleRequest(const JsonObject& request) { + milightClient->setRepeatsOverride( + settings.httpRepeatFactor * settings.packetRepeats + ); milightClient->update(request); + milightClient->clearRepeatsOverride(); } -void MiLightHttpServer::handleSendRaw(const UrlTokenBindings* bindings) { - DynamicJsonBuffer buffer; - JsonObject& request = buffer.parse(server.arg("plain")); - const MiLightRemoteConfig* config = MiLightRemoteConfig::fromType(bindings->get("type")); +void MiLightHttpServer::handleSendRaw(RequestContext& request) { + JsonObject requestBody = request.getJsonBody().as(); + const MiLightRemoteConfig* config = MiLightRemoteConfig::fromType(request.pathVariables.get("type")); if (config == NULL) { char buffer[50]; - sprintf_P(buffer, PSTR("Unknown device type: %s"), bindings->get("type")); - server.send(400, "text/plain", buffer); + sprintf_P(buffer, PSTR("Unknown device type: %s"), request.pathVariables.get("type")); + request.response.setCode(400); + request.response.json["error"] = buffer; return; } uint8_t packet[MILIGHT_MAX_PACKET_LENGTH]; - const String& hexPacket = request["packet"]; + const String& hexPacket = requestBody["packet"]; hexStrToBytes(hexPacket.c_str(), hexPacket.length(), packet, MILIGHT_MAX_PACKET_LENGTH); - size_t numRepeats = MILIGHT_DEFAULT_RESEND_COUNT; - if (request.containsKey("num_repeats")) { - numRepeats = request["num_repeats"]; + size_t numRepeats = settings.packetRepeats; + if (requestBody.containsKey("num_repeats")) { + numRepeats = requestBody["num_repeats"]; } - milightClient->prepare(config, 0, 0); + packetSender->enqueue(packet, config, numRepeats); - for (size_t i = 0; i < numRepeats; i++) { - milightClient->write(packet); + // To make this response synchronous, wait for packet to be flushed + while (packetSender->isSending()) { + packetSender->loop(); } - server.send_P(200, TEXT_PLAIN, PSTR("true")); + request.response.json["success"] = true; } void MiLightHttpServer::handleWsEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) { @@ -455,6 +557,10 @@ void MiLightHttpServer::handleWsEvent(uint8_t num, WStype_t type, uint8_t *paylo case WStype_CONNECTED: numWsClients++; break; + + default: + Serial.printf_P(PSTR("Unhandled websocket event: %d\n"), static_cast(type)); + break; } } @@ -479,14 +585,82 @@ void MiLightHttpServer::handlePacketSent(uint8_t *packet, const MiLightRemoteCon } } -ESP8266WebServer::THandlerFunction MiLightHttpServer::handleServe_P(const char* data, size_t length) { - return [this, data, length]() { - server.sendHeader("Content-Encoding", "gzip"); - server.setContentLength(CONTENT_LENGTH_UNKNOWN); - server.send(200, "text/html", ""); - server.sendContent_P(data, length); - server.sendContent(""); - server.client().stop(); - }; +void MiLightHttpServer::handleServe_P(const char* data, size_t length) { + server.sendHeader("Content-Encoding", "gzip"); + server.setContentLength(CONTENT_LENGTH_UNKNOWN); + server.send(200, "text/html", ""); + server.sendContent_P(data, length); + server.sendContent(""); + server.client().stop(); +} + +void MiLightHttpServer::handleGetTransition(RequestContext& request) { + size_t id = atoi(request.pathVariables.get("id")); + auto transition = transitions.getTransition(id); + + if (transition == nullptr) { + request.response.setCode(404); + request.response.json["error"] = "Not found"; + } else { + JsonObject response = request.response.json.to(); + transition->serialize(response); + } +} + +void MiLightHttpServer::handleDeleteTransition(RequestContext& request) { + size_t id = atoi(request.pathVariables.get("id")); + bool success = transitions.deleteTransition(id); + + if (success) { + request.response.json["success"] = true; + } else { + request.response.setCode(404); + request.response.json["error"] = "Not found"; + } +} + +void MiLightHttpServer::handleListTransitions(RequestContext& request) { + auto current = transitions.getTransitions(); + JsonArray transitions = request.response.json.to().createNestedArray(F("transitions")); + + while (current != nullptr) { + JsonObject json = transitions.createNestedObject(); + current->data->serialize(json); + current = current->next; + } } +void MiLightHttpServer::handleCreateTransition(RequestContext& request) { + JsonObject body = request.getJsonBody().as(); + + if (! body.containsKey(GroupStateFieldNames::DEVICE_ID) + || ! body.containsKey(GroupStateFieldNames::GROUP_ID) + || ! body.containsKey(F("remote_type"))) { + char buffer[200]; + sprintf_P(buffer, PSTR("Must specify required keys: device_id, group_id, remote_type")); + + request.response.setCode(400); + request.response.json[F("error")] = buffer; + return; + } + + const String _deviceId = body[GroupStateFieldNames::DEVICE_ID]; + uint8_t _groupId = body[GroupStateFieldNames::GROUP_ID]; + const MiLightRemoteConfig* _remoteType = MiLightRemoteConfig::fromType(body[F("remote_type")].as()); + + if (_remoteType == nullptr) { + char buffer[40]; + sprintf_P(buffer, PSTR("Unknown device type\n")); + request.response.setCode(400); + request.response.json[F("error")] = buffer; + return; + } + + milightClient->prepare(_remoteType, parseInt(_deviceId), _groupId); + + if (milightClient->handleTransition(request.getJsonBody().as(), request.response.json)) { + request.response.json[F("success")] = true; + } else { + request.response.setCode(400); + } +} \ No newline at end of file diff --git a/lib/WebServer/MiLightHttpServer.h b/lib/WebServer/MiLightHttpServer.h index 681555c7..58da261f 100644 --- a/lib/WebServer/MiLightHttpServer.h +++ b/lib/WebServer/MiLightHttpServer.h @@ -1,8 +1,11 @@ -#include +#include #include #include #include #include +#include +#include +#include #ifndef _MILIGHT_HTTP_SERVER #define _MILIGHT_HTTP_SERVER @@ -10,68 +13,98 @@ #define MAX_DOWNLOAD_ATTEMPTS 3 typedef std::function SettingsSavedHandler; +typedef std::function GroupDeletedHandler; + +using RichHttpConfig = RichHttp::Generics::Configs::EspressifBuiltin; +using RequestContext = RichHttpConfig::RequestContextType; const char TEXT_PLAIN[] PROGMEM = "text/plain"; const char APPLICATION_JSON[] = "application/json"; class MiLightHttpServer { public: - MiLightHttpServer(Settings& settings, MiLightClient*& milightClient, GroupStateStore*& stateStore) - : server(80), - wsServer(WebSocketsServer(81)), - numWsClients(0), - milightClient(milightClient), - settings(settings), - stateStore(stateStore) - { - this->applySettings(settings); - } + MiLightHttpServer( + Settings& settings, + MiLightClient*& milightClient, + GroupStateStore*& stateStore, + PacketSender*& packetSender, + RadioSwitchboard*& radios, + TransitionController& transitions + ) + : authProvider(settings) + , server(80, authProvider) + , wsServer(WebSocketsServer(81)) + , numWsClients(0) + , milightClient(milightClient) + , settings(settings) + , stateStore(stateStore) + , packetSender(packetSender) + , radios(radios) + , transitions(transitions) + { } void begin(); void handleClient(); void onSettingsSaved(SettingsSavedHandler handler); + void onGroupDeleted(GroupDeletedHandler handler); void on(const char* path, HTTPMethod method, ESP8266WebServer::THandlerFunction handler); void handlePacketSent(uint8_t* packet, const MiLightRemoteConfig& config); WiFiClient client(); protected: - ESP8266WebServer::THandlerFunction handleServeFile( - const char* filename, - const char* contentType, - const char* defaultText = NULL); - void serveSettings(); bool serveFile(const char* file, const char* contentType = "text/html"); - ESP8266WebServer::THandlerFunction handleUpdateFile(const char* filename); - ESP8266WebServer::THandlerFunction handleServe_P(const char* data, size_t length); - void applySettings(Settings& settings); - void sendGroupState(BulbId& bulbId, GroupState* state); - - void handleUpdateSettings(); - void handleUpdateSettingsPost(); - void handleGetRadioConfigs(); - void handleAbout(); - void handleSystemPost(); + void handleServe_P(const char* data, size_t length); + void sendGroupState(BulbId& bulbId, GroupState* state, RichHttp::Response& response); + + void serveSettings(); + void handleUpdateSettings(RequestContext& request); + void handleUpdateSettingsPost(RequestContext& request); + void handleUpdateFile(const char* filename); + + void handleGetRadioConfigs(RequestContext& request); + + void handleAbout(RequestContext& request); + void handleSystemPost(RequestContext& request); void handleFirmwareUpload(); void handleFirmwarePost(); - void handleListenGateway(const UrlTokenBindings* urlBindings); - void handleSendRaw(const UrlTokenBindings* urlBindings); - void handleUpdateGroup(const UrlTokenBindings* urlBindings); - void handleGetGroup(const UrlTokenBindings* urlBindings); + void handleListenGateway(RequestContext& request); + void handleSendRaw(RequestContext& request); + + void handleUpdateGroup(RequestContext& request); + void handleUpdateGroupAlias(RequestContext& request); + + void handleGetGroup(RequestContext& request); + void handleGetGroupAlias(RequestContext& request); + void _handleGetGroup(BulbId bulbId, RequestContext& request); + + void handleDeleteGroup(RequestContext& request); + void handleDeleteGroupAlias(RequestContext& request); + void _handleDeleteGroup(BulbId bulbId, RequestContext& request); + + void handleGetTransition(RequestContext& request); + void handleDeleteTransition(RequestContext& request); + void handleCreateTransition(RequestContext& request); + void handleListTransitions(RequestContext& request); void handleRequest(const JsonObject& request); void handleWsEvent(uint8_t num, WStype_t type, uint8_t * payload, size_t length); File updateFile; - WebServer server; + PassthroughAuthProvider authProvider; + RichHttpServer server; WebSocketsServer wsServer; - Settings& settings; + size_t numWsClients; MiLightClient*& milightClient; + Settings& settings; GroupStateStore*& stateStore; SettingsSavedHandler settingsSavedHandler; - size_t numWsClients; + GroupDeletedHandler groupDeletedHandler; ESP8266WebServer::THandlerFunction _handleRootPage; + PacketSender*& packetSender; + RadioSwitchboard*& radios; + TransitionController& transitions; }; diff --git a/lib/WebServer/PatternHandler.cpp b/lib/WebServer/PatternHandler.cpp deleted file mode 100644 index 60ae79ba..00000000 --- a/lib/WebServer/PatternHandler.cpp +++ /dev/null @@ -1,62 +0,0 @@ -#include - -PatternHandler::PatternHandler( - const String& pattern, - const HTTPMethod method, - const PatternHandler::TPatternHandlerFn fn) - : method(method), - fn(fn), - _pattern(new char[pattern.length() + 1]), - patternTokens(NULL) -{ - strcpy(_pattern, pattern.c_str()); - patternTokens = new TokenIterator(_pattern, pattern.length(), '/'); -} - -PatternHandler::~PatternHandler() { - delete _pattern; - delete patternTokens; -} - -bool PatternHandler::canHandle(HTTPMethod requestMethod, String requestUri) { - if (this->method != HTTP_ANY && requestMethod != this->method) { - return false; - } - - bool canHandle = true; - - char requestUriCopy[requestUri.length() + 1]; - strcpy(requestUriCopy, requestUri.c_str()); - TokenIterator requestTokens(requestUriCopy, requestUri.length(), '/'); - - patternTokens->reset(); - while (patternTokens->hasNext() && requestTokens.hasNext()) { - const char* patternToken = patternTokens->nextToken(); - const char* requestToken = requestTokens.nextToken(); - - if (patternToken[0] != ':' && strcmp(patternToken, requestToken) != 0) { - canHandle = false; - break; - } - - if (patternTokens->hasNext() != requestTokens.hasNext()) { - canHandle = false; - break; - } - } - - return canHandle; -} - -bool PatternHandler::handle(ESP8266WebServer& server, HTTPMethod requestMethod, String requestUri) { - if (! canHandle(requestMethod, requestUri)) { - return false; - } - - char requestUriCopy[requestUri.length()]; - strcpy(requestUriCopy, requestUri.c_str()); - TokenIterator requestTokens(requestUriCopy, requestUri.length(), '/'); - - UrlTokenBindings bindings(*patternTokens, requestTokens); - fn(&bindings); -} diff --git a/lib/WebServer/PatternHandler.h b/lib/WebServer/PatternHandler.h deleted file mode 100644 index e7c14fc7..00000000 --- a/lib/WebServer/PatternHandler.h +++ /dev/null @@ -1,30 +0,0 @@ -#ifndef _PATTERNHANDLER_H -#define _PATTERNHANDLER_H - -#include -#include -#include -#include -#include - -class PatternHandler : public RequestHandler { -public: - typedef std::function TPatternHandlerFn; - - PatternHandler(const String& pattern, - const HTTPMethod method, - const TPatternHandlerFn fn); - - ~PatternHandler(); - - bool canHandle(HTTPMethod requestMethod, String requestUri) override; - bool handle(ESP8266WebServer& server, HTTPMethod requesetMethod, String requestUri) override; - -private: - char* _pattern; - TokenIterator* patternTokens; - const HTTPMethod method; - const PatternHandler::TPatternHandlerFn fn; -}; - -#endif diff --git a/lib/WebServer/WebServer.cpp b/lib/WebServer/WebServer.cpp deleted file mode 100644 index d54e80dc..00000000 --- a/lib/WebServer/WebServer.cpp +++ /dev/null @@ -1,68 +0,0 @@ -#include -#include - -void WebServer::onAuthenticated(const String &uri, THandlerFunction handler) { - THandlerFunction authHandler = [this, handler]() { - if (this->validateAuthentiation()) { - handler(); - } - }; - - ESP8266WebServer::on(uri, authHandler); -} - -void WebServer::onAuthenticated(const String &uri, HTTPMethod method, THandlerFunction handler) { - THandlerFunction authHandler = [this, handler]() { - if (this->validateAuthentiation()) { - handler(); - } - }; - - ESP8266WebServer::on(uri, method, authHandler); -} - -void WebServer::onAuthenticated(const String &uri, HTTPMethod method, THandlerFunction handler, THandlerFunction ufn) { - THandlerFunction authHandler = [this, handler]() { - if (this->validateAuthentiation()) { - handler(); - } - }; - - ESP8266WebServer::on(uri, method, authHandler, ufn); -} - -void WebServer::onPattern(const String& pattern, const HTTPMethod method, PatternHandler::TPatternHandlerFn handler) { - addHandler(new PatternHandler(pattern, method, handler)); -} - -void WebServer::onPatternAuthenticated(const String& pattern, const HTTPMethod method, PatternHandler::TPatternHandlerFn fn) { - PatternHandler::TPatternHandlerFn authHandler = [this, fn](UrlTokenBindings* bindings) { - if (this->validateAuthentiation()) { - fn(bindings); - } - }; - - addHandler(new PatternHandler(pattern, method, authHandler)); -} - - - -void WebServer::requireAuthentication(const String& username, const String& password) { - this->username = String(username); - this->password = String(password); - this->authEnabled = true; -} - -void WebServer::disableAuthentication() { - this->authEnabled = false; -} - -bool WebServer::validateAuthentiation() { - if (this->authEnabled && - !authenticate(this->username.c_str(), this->password.c_str())) { - requestAuthentication(); - return false; - } - return true; -} - diff --git a/lib/WebServer/WebServer.h b/lib/WebServer/WebServer.h deleted file mode 100644 index 89dda045..00000000 --- a/lib/WebServer/WebServer.h +++ /dev/null @@ -1,43 +0,0 @@ -#ifndef _WEBSERVER_H -#define _WEBSERVER_H - -#include -#include -#include -#include - -#define HTTP_DOWNLOAD_UNIT_SIZE 1460 -#define HTTP_MAX_SEND_WAIT 5000 //ms to wait for data chunk to be ACKed -#define HTTP_MAX_CLOSE_WAIT 2000 //ms to wait for the client to close the connection - -class WebServer : public ESP8266WebServer { -public: - WebServer(int port) : ESP8266WebServer(port) { } - - void onAuthenticated(const String &uri, THandlerFunction handler); - void onAuthenticated(const String &uri, HTTPMethod method, THandlerFunction fn); - void onAuthenticated(const String &uri, HTTPMethod method, THandlerFunction fn, THandlerFunction ufn); - void onPattern(const String& pattern, const HTTPMethod method, PatternHandler::TPatternHandlerFn fn); - void onPatternAuthenticated(const String& pattern, const HTTPMethod method, PatternHandler::TPatternHandlerFn handler); - bool matchesPattern(const String& pattern, const String& url); - void requireAuthentication(const String& username, const String& password); - void disableAuthentication(); - bool validateAuthentiation(); - - inline bool clientConnected() { - return _currentClient && _currentClient.connected(); - } - - bool authenticationRequired() { - return authEnabled; - } - -protected: - - bool authEnabled; - String username; - String password; - -}; - -#endif diff --git a/platformio.ini b/platformio.ini index 920cb814..15db9450 100644 --- a/platformio.ini +++ b/platformio.ini @@ -10,23 +10,35 @@ [common] framework = arduino -platform = espressif8266@~1.8.0 +platform = espressif8266@~1.8 board_f_cpu = 160000000L lib_deps_builtin = SPI lib_deps_external = - sidoh/RF24 sidoh/WiFiManager#cmidgley - ArduinoJson - PubSubClient - https://github.com/ratkins/RGBConverter - Hash - WebSockets - CircularBuffer - ESP8266WebServer + RF24@~1.3.2 + ArduinoJson@~6.10.1 + PubSubClient@~2.7 + ratkins/RGBConverter@07010f2 + WebSockets@~2.1.2 + CircularBuffer@~1.2.0 + PathVariableHandlers@~2.0.0 + RichHttpServer@~2.0.2 extra_scripts = pre:.build_web.py -build_flags = !python .get_version.py -DMQTT_MAX_PACKET_SIZE=200 -DHTTP_UPLOAD_BUFLEN=128 -Idist -Ilib/DataStructures +test_ignore = remote +upload_speed = 460800 +build_flags = + !python .get_version.py + # For compatibility with WebSockets 2.1.4 and v2.4 of the Arduino SDK + -D USING_AXTLS + -D MQTT_MAX_PACKET_SIZE=250 + -D HTTP_UPLOAD_BUFLEN=128 + -D FIRMWARE_NAME=milight-hub + -D RICH_HTTP_REQUEST_BUFFER_SIZE=2048 + -D RICH_HTTP_RESPONSE_BUFFER_SIZE=2048 + -Idist -Ilib/DataStructures +# -D STATE_DEBUG # -D DEBUG_PRINTF # -D MQTT_DEBUG # -D MILIGHT_UDP_DEBUG @@ -35,50 +47,71 @@ build_flags = !python .get_version.py -DMQTT_MAX_PACKET_SIZE=200 -DHTTP_UPLOAD_B [env:nodemcuv2] platform = ${common.platform} framework = ${common.framework} +upload_speed = ${common.upload_speed} board = nodemcuv2 -upload_speed = 115200 build_flags = ${common.build_flags} -Wl,-Tesp8266.flash.4m1m.ld -D FIRMWARE_VARIANT=nodemcuv2 extra_scripts = ${common.extra_scripts} lib_deps = ${common.lib_deps_builtin} ${common.lib_deps_external} +test_ignore = ${common.test_ignore} [env:d1_mini] platform = ${common.platform} framework = ${common.framework} +upload_speed = ${common.upload_speed} board = d1_mini build_flags = ${common.build_flags} -Wl,-Tesp8266.flash.4m1m.ld -D FIRMWARE_VARIANT=d1_mini extra_scripts = ${common.extra_scripts} lib_deps = ${common.lib_deps_builtin} ${common.lib_deps_external} +test_ignore = ${common.test_ignore} [env:esp12] platform = ${common.platform} framework = ${common.framework} +upload_speed = ${common.upload_speed} board = esp12e build_flags = ${common.build_flags} -Wl,-Tesp8266.flash.4m1m.ld -D FIRMWARE_VARIANT=esp12 extra_scripts = ${common.extra_scripts} lib_deps = ${common.lib_deps_builtin} ${common.lib_deps_external} +test_ignore = ${common.test_ignore} [env:esp07] platform = ${common.platform} framework = ${common.framework} +upload_speed = ${common.upload_speed} board = esp07 build_flags = ${common.build_flags} -Wl,-Tesp8266.flash.1m64.ld -D FIRMWARE_VARIANT=esp07 extra_scripts = ${common.extra_scripts} lib_deps = ${common.lib_deps_builtin} ${common.lib_deps_external} +test_ignore = ${common.test_ignore} [env:huzzah] platform = ${common.platform} framework = ${common.framework} +upload_speed = ${common.upload_speed} board = huzzah build_flags = ${common.build_flags} -D FIRMWARE_VARIANT=huzzah extra_scripts = ${common.extra_scripts} lib_deps = ${common.lib_deps_builtin} ${common.lib_deps_external} +test_ignore = ${common.test_ignore} + +[env:d1_mini_pro] +platform = ${common.platform} +framework = ${common.framework} +upload_speed = ${common.upload_speed} +board = d1_mini_pro +build_flags = ${common.build_flags} -Wl,-Tesp8266.flash.4m1m.ld -D FIRMWARE_VARIANT=d1_mini_PRO +extra_scripts = ${common.extra_scripts} +lib_deps = + ${common.lib_deps_builtin} + ${common.lib_deps_external} +test_ignore = ${common.test_ignore} \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp index 2b2275fd..46a49aab 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -8,10 +8,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -21,16 +23,28 @@ #include #include #include -#include +#include +#include +#include +#include + +#include +#include WiFiManager wifiManager; +// because of callbacks, these need to be in the higher scope :( +WiFiManagerParameter* wifiStaticIP = NULL; +WiFiManagerParameter* wifiStaticIPNetmask = NULL; +WiFiManagerParameter* wifiStaticIPGateway = NULL; static LEDStatus *ledStatus; Settings settings; MiLightClient* milightClient = NULL; -MiLightRadioFactory* radioFactory = NULL; +RadioSwitchboard* radios = nullptr; +PacketSender* packetSender = nullptr; +std::shared_ptr radioFactory; MiLightHttpServer *httpServer = NULL; MqttClient* mqttClient = NULL; MiLightDiscoveryServer* discoveryServer = NULL; @@ -39,42 +53,33 @@ uint8_t currentRadioType = 0; // For tracking and managing group state GroupStateStore* stateStore = NULL; BulbStateUpdater* bulbStateUpdater = NULL; +TransitionController transitions; int numUdpServers = 0; -MiLightUdpServer** udpServers = NULL; +std::vector> udpServers; WiFiUDP udpSeder; /** * Set up UDP servers (both v5 and v6). Clean up old ones if necessary. */ void initMilightUdpServers() { - if (udpServers) { - for (int i = 0; i < numUdpServers; i++) { - if (udpServers[i]) { - delete udpServers[i]; - } - } - - delete udpServers; - } + udpServers.clear(); - udpServers = new MiLightUdpServer*[settings.numGatewayConfigs]; - numUdpServers = settings.numGatewayConfigs; + for (size_t i = 0; i < settings.gatewayConfigs.size(); ++i) { + const GatewayConfig& config = *settings.gatewayConfigs[i]; - for (size_t i = 0; i < settings.numGatewayConfigs; i++) { - GatewayConfig* config = settings.gatewayConfigs[i]; - MiLightUdpServer* server = MiLightUdpServer::fromVersion( - config->protocolVersion, + std::shared_ptr server = MiLightUdpServer::fromVersion( + config.protocolVersion, milightClient, - config->port, - config->deviceId + config.port, + config.deviceId ); if (server == NULL) { Serial.print(F("Error creating UDP server with protocol version: ")); - Serial.println(config->protocolVersion); + Serial.println(config.protocolVersion); } else { - udpServers[i] = server; + udpServers.push_back(std::move(server)); udpServers[i]->begin(); } } @@ -87,8 +92,8 @@ void initMilightUdpServers() { * is read. */ void onPacketSentHandler(uint8_t* packet, const MiLightRemoteConfig& config) { - StaticJsonBuffer<200> buffer; - JsonObject& result = buffer.createObject(); + StaticJsonDocument<200> buffer; + JsonObject result = buffer.to(); BulbId bulbId = config.packetFormatter->parsePacket(packet, result); @@ -106,15 +111,20 @@ void onPacketSentHandler(uint8_t* packet, const MiLightRemoteConfig& config) { // update state to reflect changes from this packet GroupState* groupState = stateStore->get(bulbId); + // pass in previous scratch state as well + const GroupState stateUpdates(groupState, result); + if (groupState != NULL) { - groupState->patch(result); - stateStore->set(bulbId, *groupState); + groupState->patch(stateUpdates); + + // Copy state before setting it to avoid group 0 re-initialization clobbering it + stateStore->set(bulbId, stateUpdates); } if (mqttClient) { // Sends the state delta derived from the raw packet char output[200]; - result.printTo(output); + serializeJson(result, output); mqttClient->sendUpdate(remoteConfig, bulbId.deviceId, bulbId.groupId, output); // Sends the entire state @@ -131,16 +141,19 @@ void onPacketSentHandler(uint8_t* packet, const MiLightRemoteConfig& config) { * called. */ void handleListen() { - if (! settings.listenRepeats) { + // Do not handle listens while there are packets enqueued to be sent + // Doing so causes the radio module to need to be reinitialized inbetween + // repeats, which slows things down. + if (! settings.listenRepeats || packetSender->isSending()) { return; } - MiLightRadio* radio = milightClient->switchRadio(currentRadioType++ % milightClient->getNumRadios()); + std::shared_ptr radio = radios->switchRadio(currentRadioType++ % radios->getNumRadios()); for (size_t i = 0; i < settings.listenRepeats; i++) { - if (milightClient->available()) { + if (radios->available()) { uint8_t readPacket[MILIGHT_MAX_PACKET_LENGTH]; - size_t packetLen = milightClient->read(readPacket); + size_t packetLen = radios->read(readPacket); const MiLightRemoteConfig* remoteConfig = MiLightRemoteConfig::fromReceivedPacket( radio->config(), @@ -189,9 +202,6 @@ void applySettings() { if (milightClient) { delete milightClient; } - if (radioFactory) { - delete radioFactory; - } if (mqttClient) { delete mqttClient; delete bulbStateUpdater; @@ -202,6 +212,12 @@ void applySettings() { if (stateStore) { delete stateStore; } + if (packetSender) { + delete packetSender; + } + if (radios) { + delete radios; + } radioFactory = MiLightRadioFactory::fromSettings(settings); @@ -211,20 +227,32 @@ void applySettings() { stateStore = new GroupStateStore(MILIGHT_MAX_STATE_ITEMS, settings.stateFlushInterval); + radios = new RadioSwitchboard(radioFactory, stateStore, settings); + packetSender = new PacketSender(*radios, settings, onPacketSentHandler); + milightClient = new MiLightClient( - radioFactory, + *radios, + *packetSender, stateStore, - &settings + settings, + transitions ); - milightClient->begin(); - milightClient->onPacketSent(onPacketSentHandler); milightClient->onUpdateBegin(onUpdateBegin); milightClient->onUpdateEnd(onUpdateEnd); - milightClient->setResendCount(settings.packetRepeats); if (settings.mqttServer().length() > 0) { mqttClient = new MqttClient(settings, milightClient); mqttClient->begin(); + mqttClient->onConnect([]() { + if (settings.homeAssistantDiscoveryPrefix.length() > 0) { + HomeAssistantDiscoveryClient discoveryClient(settings, mqttClient); + discoveryClient.sendDiscoverableDevices(settings.groupIdAliases); + discoveryClient.removeOldDevices(settings.deletedGroupIdAliases); + + settings.deletedGroupIdAliases.clear(); + } + }); + bulbStateUpdater = new BulbStateUpdater(settings, *mqttClient, *stateStore); } @@ -244,6 +272,23 @@ void applySettings() { ledStatus->changePin(settings.ledPin); ledStatus->continuous(settings.ledModeOperating); } + + WiFi.hostname(settings.hostname); + + WiFiPhyMode_t wifiMode; + switch (settings.wifiMode) { + case WifiMode::B: + wifiMode = WIFI_PHY_MODE_11B; + break; + case WifiMode::G: + wifiMode = WIFI_PHY_MODE_11G; + break; + default: + case WifiMode::N: + wifiMode = WIFI_PHY_MODE_11N; + break; + } + WiFi.setPhyMode(wifiMode); } /** @@ -262,10 +307,30 @@ void handleLED() { ledStatus->handle(); } +void wifiExtraSettingsChange() { + settings.wifiStaticIP = wifiStaticIP->getValue(); + settings.wifiStaticIPNetmask = wifiStaticIPNetmask->getValue(); + settings.wifiStaticIPGateway = wifiStaticIPGateway->getValue(); + settings.save(); +} + +// Called when a group is deleted via the REST API. Will publish an empty message to +// the MQTT topic to delete retained state +void onGroupDeleted(const BulbId& id) { + if (mqttClient != NULL) { + mqttClient->sendState( + *MiLightRemoteConfig::fromType(id.deviceType), + id.deviceId, + id.groupId, + "" + ); + } +} + void setup() { Serial.begin(9600); String ssid = "ESP" + String(ESP.getChipId()); - + // load up our persistent settings from the file system SPIFFS.begin(); Settings::load(settings); @@ -285,7 +350,48 @@ void setup() { // that change is only on the development branch so we are going to continue to use this fork until // that is merged and ready. wifiManager.setSetupLoopCallback(handleLED); + + // Allows us to have static IP config in the captive portal. Yucky pointers to pointers, just to have the settings carry through + wifiManager.setSaveConfigCallback(wifiExtraSettingsChange); + + wifiStaticIP = new WiFiManagerParameter( + "staticIP", + "Static IP (Leave blank for dhcp)", + settings.wifiStaticIP.c_str(), + MAX_IP_ADDR_LEN + ); + wifiManager.addParameter(wifiStaticIP); + + wifiStaticIPNetmask = new WiFiManagerParameter( + "netmask", + "Netmask (required if IP given)", + settings.wifiStaticIPNetmask.c_str(), + MAX_IP_ADDR_LEN + ); + wifiManager.addParameter(wifiStaticIPNetmask); + + wifiStaticIPGateway = new WiFiManagerParameter( + "gateway", + "Default Gateway (optional, only used if static IP)", + settings.wifiStaticIPGateway.c_str(), + MAX_IP_ADDR_LEN + ); + wifiManager.addParameter(wifiStaticIPGateway); + + // We have a saved static IP, let's try and use it. + if (settings.wifiStaticIP.length() > 0) { + Serial.printf_P(PSTR("We have a static IP: %s\n"), settings.wifiStaticIP.c_str()); + + IPAddress _ip, _subnet, _gw; + _ip.fromString(settings.wifiStaticIP); + _subnet.fromString(settings.wifiStaticIPNetmask); + _gw.fromString(settings.wifiStaticIPGateway); + + wifiManager.setSTAStaticIPConfig(_ip,_gw,_subnet); + } + wifiManager.setConfigPortalTimeout(180); + if (wifiManager.autoConnect(ssid.c_str(), "milightHub")) { // set LED mode for successful operation ledStatus->continuous(settings.ledModeOperating); @@ -313,11 +419,24 @@ void setup() { SSDP.setDeviceType("upnp:rootdevice"); SSDP.begin(); - httpServer = new MiLightHttpServer(settings, milightClient, stateStore); + httpServer = new MiLightHttpServer(settings, milightClient, stateStore, packetSender, radios, transitions); httpServer->onSettingsSaved(applySettings); + httpServer->onGroupDeleted(onGroupDeleted); httpServer->on("/description.xml", HTTP_GET, []() { SSDP.schema(httpServer->client()); }); httpServer->begin(); + transitions.addListener( + [](const BulbId& bulbId, GroupStateField field, uint16_t value) { + StaticJsonDocument<100> buffer; + + const char* fieldName = GroupStateFieldHelpers::getFieldName(field); + buffer[fieldName] = value; + + milightClient->prepare(bulbId.deviceType, bulbId.deviceId, bulbId.groupId); + milightClient->update(buffer.as()); + } + ); + Serial.printf_P(PSTR("Setup complete (version %s)\n"), QUOTE(MILIGHT_HUB_VERSION)); } @@ -329,10 +448,8 @@ void loop() { bulbStateUpdater->loop(); } - if (udpServers) { - for (size_t i = 0; i < settings.numGatewayConfigs; i++) { - udpServers[i]->handleClient(); - } + for (size_t i = 0; i < udpServers.size(); i++) { + udpServers[i]->handleClient(); } if (discoveryServer) { @@ -342,10 +459,13 @@ void loop() { handleListen(); stateStore->limitedFlush(); + packetSender->loop(); // update LED with status ledStatus->handle(); + transitions.loop(); + if (shouldRestart()) { Serial.println(F("Auto-restart triggered. Restarting...")); ESP.restart(); diff --git a/test/d1_mini/test.cpp b/test/d1_mini/test.cpp index 5f095bda..8fe10413 100644 --- a/test/d1_mini/test.cpp +++ b/test/d1_mini/test.cpp @@ -26,7 +26,7 @@ void run_packet_test(uint8_t* packet, PacketFormatter* packetFormatter, const Bu DynamicJsonBuffer jsonBuffer; JsonObject& result = jsonBuffer.createObject(); - packetFormatter->prepare(0, 0, &stateStore, &settings); + packetFormatter->prepare(0, 0); BulbId bulbId = packetFormatter->parsePacket(packet, result); TEST_ASSERT_EQUAL_INT_MESSAGE(expectedBulbId.deviceId, bulbId.deviceId, "Should get the expected device ID"); @@ -42,28 +42,28 @@ void test_fut092_packet_formatter() { uint8_t onPacket[] = {0x00, 0xDB, 0xE1, 0x24, 0x66, 0xCA, 0x54, 0x66, 0xD2}; run_packet_test( - onPacket, - &packetFormatter, - BulbId(1, 1, REMOTE_TYPE_RGB_CCT), - "state", + onPacket, + &packetFormatter, + BulbId(1, 1, REMOTE_TYPE_RGB_CCT), + "state", "OFF" ); uint8_t minColorTempPacket[] = {0x00, 0xDB, 0xE1, 0x24, 0x64, 0x3C, 0x47, 0x66, 0x31}; run_packet_test( - minColorTempPacket, - &packetFormatter, - BulbId(1, 1, REMOTE_TYPE_RGB_CCT), - "color_temp", + minColorTempPacket, + &packetFormatter, + BulbId(1, 1, REMOTE_TYPE_RGB_CCT), + "color_temp", COLOR_TEMP_MIN_MIREDS ); uint8_t maxColorTempPacket[] = {0x00, 0xDB, 0xE1, 0x24, 0x64, 0x94, 0x62, 0x66, 0x88}; run_packet_test( - maxColorTempPacket, - &packetFormatter, - BulbId(1, 1, REMOTE_TYPE_RGB_CCT), - "color_temp", + maxColorTempPacket, + &packetFormatter, + BulbId(1, 1, REMOTE_TYPE_RGB_CCT), + "color_temp", COLOR_TEMP_MAX_MIREDS ); } @@ -73,28 +73,28 @@ void test_fut091_packet_formatter() { uint8_t onPacket[] = {0x00, 0xDC, 0xE1, 0x24, 0x66, 0xCA, 0xBA, 0x66, 0xB5}; run_packet_test( - onPacket, - &packetFormatter, - BulbId(1, 1, REMOTE_TYPE_FUT091), - "state", + onPacket, + &packetFormatter, + BulbId(1, 1, REMOTE_TYPE_FUT091), + "state", "OFF" ); uint8_t minColorTempPacket[] = {0x00, 0xDC, 0xE1, 0x24, 0x64, 0x8D, 0xB9, 0x66, 0x71}; run_packet_test( - minColorTempPacket, - &packetFormatter, - BulbId(1, 1, REMOTE_TYPE_FUT091), - "color_temp", + minColorTempPacket, + &packetFormatter, + BulbId(1, 1, REMOTE_TYPE_FUT091), + "color_temp", COLOR_TEMP_MIN_MIREDS ); uint8_t maxColorTempPacket[] = {0x00, 0xDC, 0xE1, 0x24, 0x64, 0x55, 0xB7, 0x66, 0x27}; run_packet_test( - maxColorTempPacket, - &packetFormatter, - BulbId(1, 1, REMOTE_TYPE_FUT091), - "color_temp", + maxColorTempPacket, + &packetFormatter, + BulbId(1, 1, REMOTE_TYPE_FUT091), + "color_temp", COLOR_TEMP_MAX_MIREDS ); } @@ -108,9 +108,9 @@ GroupState color() { s.setState(MiLightStatus::ON); s.setBulbMode(BulbMode::BULB_MODE_COLOR); - s.setBrightness(100); s.setHue(1); s.setSaturation(10); + s.setBrightness(100); return s; } @@ -220,8 +220,7 @@ void test_store() { BulbId id1(1, 1, REMOTE_TYPE_FUT089); BulbId id2(1, 2, REMOTE_TYPE_FUT089); - // cache 1 item, flush immediately - GroupStateStore store(1, 0); + GroupStateStore store(4, 0); GroupStatePersistence persistence; persistence.clear(id1); @@ -230,28 +229,28 @@ void test_store() { GroupState initState = color(); GroupState initState2 = color(); GroupState defaultState = GroupState::defaultState(REMOTE_TYPE_FUT089); - initState2.setBrightness(255); + initState2.setBrightness(50); GroupState* storedState; - storedState = &store.get(id2); + storedState = store.get(id2); TEST_ASSERT_TRUE_MESSAGE(*storedState == defaultState, "Should return default for state that hasn't been stored"); store.set(id1, initState); - storedState = &store.get(id1); + storedState = store.get(id1); - TEST_ASSERT_TRUE_MESSAGE(*storedState == initState, "Should return cached state"); + TEST_ASSERT_TRUE_MESSAGE(storedState->isEqualIgnoreDirty(initState), "Should return stored state. Will not be cached because of internal group 0 lookups"); store.flush(); - storedState = &store.get(id1); + storedState = store.get(id1); TEST_ASSERT_FALSE_MESSAGE(storedState->isDirty(), "Should not be dirty after flushing"); TEST_ASSERT_TRUE_MESSAGE(storedState->isEqualIgnoreDirty(initState), "Should return cached state after flushing"); store.set(id2, defaultState); - storedState = &store.get(id2); + storedState = store.get(id2); TEST_ASSERT_TRUE_MESSAGE(storedState->isEqualIgnoreDirty(defaultState), "Should return cached state"); - storedState = &store.get(id1); + storedState = store.get(id1); TEST_ASSERT_TRUE_MESSAGE(storedState->isEqualIgnoreDirty(initState), "Should return persisted state"); } @@ -269,7 +268,6 @@ void test_group_0() { GroupState initState = color(); GroupState initState2 = color(); - GroupState defaultState = GroupState::defaultState(REMOTE_TYPE_FUT089); GroupState storedState; GroupState expectedState; GroupState group0State; @@ -283,28 +281,50 @@ void test_group_0() { TEST_ASSERT_FALSE_MESSAGE(group0State.isEqualIgnoreDirty(initState), "group0 state should be different than initState"); TEST_ASSERT_FALSE_MESSAGE(group0State.isEqualIgnoreDirty(initState2), "group0 state should be different than initState2"); - storedState = store.get(id1); + storedState = *store.get(id1); TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(initState), "Should fetch persisted state"); - storedState = store.get(id2); + storedState = *store.get(id2); TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(initState2), "Should fetch persisted state"); store.set(group0Id, group0State); - storedState = store.get(id1); + storedState = *store.get(id1); expectedState = initState; expectedState.setHue(group0State.getHue()); TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(expectedState), "Saving group 0 should only update changed field"); - storedState = store.get(id2); + storedState = *store.get(id2); expectedState = initState2; expectedState.setHue(group0State.getHue()); TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(expectedState), "Saving group 0 should only update changed field"); - // Test that state for group 0 is not persisted - storedState = store.get(group0Id); - TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(defaultState), "Group 0 state should not be stored -- should return default state"); + // Test that state for group 0 is persisted + storedState = *store.get(group0Id); + TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(group0State), "Group 0 state should not be stored -- should return default state"); + + // Test that states for constituent groups are properly updated + initState.setHue(0); + initState2.setHue(100); + initState.setBrightness(50); + initState2.setBrightness(70); + store.set(id1, initState); + store.set(id2, initState2); + + storedState = *store.get(group0Id); + storedState.setHue(200); + TEST_ASSERT_FALSE_MESSAGE(storedState.isSetBrightness(), "Should not have a set field for group 0 brightness"); + + store.set(group0Id, storedState); + + storedState = *store.get(id1); + TEST_ASSERT_TRUE_MESSAGE(storedState.getBrightness() == 50, "UNSET field in group 0 update SHOULD NOT overwrite constituent group field"); + TEST_ASSERT_TRUE_MESSAGE(storedState.getHue() == 200, "SET field in group 0 update SHOULD overwrite constituent group field"); + + storedState = *store.get(id2); + TEST_ASSERT_TRUE_MESSAGE(storedState.getBrightness() == 70, "UNSET field in group 0 update SHOULD NOT overwrite constituent group field"); + TEST_ASSERT_TRUE_MESSAGE(storedState.getHue() == 200, "SET field in group 0 update SHOULD overwrite constituent group field"); // Should persist group 0 for device types with 0 groups BulbId rgbId(1, 0, REMOTE_TYPE_RGB); @@ -315,7 +335,7 @@ void test_group_0() { store.set(rgbId, rgbState); store.flush(); - storedState = store.get(rgbId); + storedState = *store.get(rgbId); TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(rgbState), "Should persist group 0 for device type with no groups"); } diff --git a/test/remote/.rspec b/test/remote/.rspec new file mode 100644 index 00000000..c99d2e73 --- /dev/null +++ b/test/remote/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/test/remote/.ruby-version b/test/remote/.ruby-version new file mode 100644 index 00000000..e70b4523 --- /dev/null +++ b/test/remote/.ruby-version @@ -0,0 +1 @@ +2.6.0 diff --git a/test/remote/Gemfile b/test/remote/Gemfile new file mode 100644 index 00000000..7e79e9e3 --- /dev/null +++ b/test/remote/Gemfile @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gem 'rspec' +gem 'mqtt', '~> 0.5' +gem 'dotenv', '~> 2.6' +gem 'multipart-post' +gem 'net-ping' +gem 'milight-easybulb', '~> 1.0' +gem 'chroma', '~> 0.2.x' \ No newline at end of file diff --git a/test/remote/Gemfile.lock b/test/remote/Gemfile.lock new file mode 100644 index 00000000..bee8d387 --- /dev/null +++ b/test/remote/Gemfile.lock @@ -0,0 +1,38 @@ +GEM + remote: https://rubygems.org/ + specs: + chroma (0.2.0) + diff-lcs (1.3) + dotenv (2.6.0) + milight-easybulb (1.0.0) + mqtt (0.5.0) + multipart-post (2.0.0) + net-ping (2.0.5) + rspec (3.8.0) + rspec-core (~> 3.8.0) + rspec-expectations (~> 3.8.0) + rspec-mocks (~> 3.8.0) + rspec-core (3.8.0) + rspec-support (~> 3.8.0) + rspec-expectations (3.8.2) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.8.0) + rspec-mocks (3.8.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.8.0) + rspec-support (3.8.0) + +PLATFORMS + ruby + +DEPENDENCIES + chroma (~> 0.2.x) + dotenv (~> 2.6) + milight-easybulb (~> 1.0) + mqtt (~> 0.5) + multipart-post + net-ping + rspec + +BUNDLED WITH + 1.17.2 diff --git a/test/remote/README.md b/test/remote/README.md new file mode 100644 index 00000000..ecedf842 --- /dev/null +++ b/test/remote/README.md @@ -0,0 +1,90 @@ +## Integration Tests + +This integration test suite is built using rspec. It integrates with espMH in a variety of ways, and monitors externally visible behaviors and states to ensure they match expectations. + +### Setup + +1. Copy `settings.json.example` to `settings.json` and make appropriate modifications for your setup. +1. Copy `espmh.env.example` to `espmh.env` and make appropriate modifications. For MQTT tests, you will need an external MQTT broker. +1. Install ruby and the bundler gem. +1. Run `bundle install`. + +### Running + +Run the tests using `bundle exec rspec`. + +### Example output + +``` +$ bundle exec rspec -f d + +Environment + needs to have a settings.json file + environment + should have a host defined + should respond to /about + client + should return IDs + +State + deleting + should remove retained state + birth and LWT + should send birth message when configured + commands and state + should affect state + should publish to state topics + should publish an update message for each new command + should respect the state update interval + :device_id token for command topic + should support hexadecimal device IDs + should support decimal device IDs + :hex_device_id for command topic + should respond to commands + :dec_device_id for command topic + should respond to commands + :hex_device_id for update/state topics + should publish updates with hexadecimal device ID + should publish state with hexadecimal device ID + :dec_device_id for update/state topics + should publish updates with hexadecimal device ID + should publish state with hexadecimal device ID + +REST Server + authentication + should not require auth unless both username and password are set + should require auth for all routes when password is set + +Settings + POST settings file + should clobber patched settings + should apply POSTed settings + radio + should store a set of channels + should store a listen channel + static ip + should boot with static IP when applied + +State + toggle command + should toggle ON to OFF + should toggle OFF to ON + deleting + should support deleting state + persistence + should persist parameters + should affect member groups when changing group 0 + should keep group 0 state + should clear group 0 state after member group state changes + should not clear group 0 state when updating member group state if value is the same + changing member state mode and then changing level should preserve group 0 brightness for original mode + fields + should support the color field + increment/decrement commands + should assume state after sufficiently many down commands + should assume state after sufficiently many up commands + should affect known state + +Finished in 2 minutes 36.9 seconds (files took 0.23476 seconds to load) +38 examples, 0 failures +``` \ No newline at end of file diff --git a/test/remote/espmh.env.example b/test/remote/espmh.env.example new file mode 100644 index 00000000..6f3aec84 --- /dev/null +++ b/test/remote/espmh.env.example @@ -0,0 +1,20 @@ +ESPMH_HOSTNAME=milight-hub-test + +# Used to test states, etc. +ESPMH_TEST_DEVICE_ID_BASE=0x2200 + +# MQTT server/auth. Used for MQTT tests +ESPMH_MQTT_SERVER=my-mqtt-server +ESPMH_MQTT_USERNAME=username +ESPMH_MQTT_PASSWORD=password +ESPMH_MQTT_TOPIC_PREFIX=milight_test/ + +# Settings to test static IP +ESPMH_STATIC_IP=192.168.1.200 +ESPMH_STATIC_IP_NETMASK=255.255.255.0 +ESPMH_STATIC_IP_GATEWAY=192.168.1.1 + +# Settings to test UDP server +ESPMH_V5_UDP_PORT=8888 +ESPMH_V6_UDP_PORT=8889 +ESPMH_DISCOVERY_PORT=8877 \ No newline at end of file diff --git a/test/remote/helpers/mqtt_helpers.rb b/test/remote/helpers/mqtt_helpers.rb new file mode 100644 index 00000000..f8486499 --- /dev/null +++ b/test/remote/helpers/mqtt_helpers.rb @@ -0,0 +1,34 @@ +require 'mqtt_client' + +module MqttHelpers + def mqtt_topic_prefix + ENV.fetch('ESPMH_MQTT_TOPIC_PREFIX') + end + + def mqtt_parameters(overrides = {}) + topic_prefix = mqtt_topic_prefix() + + { + mqtt_server: ENV.fetch('ESPMH_MQTT_SERVER'), + mqtt_username: ENV.fetch('ESPMH_MQTT_USERNAME'), + mqtt_password: ENV.fetch('ESPMH_MQTT_PASSWORD'), + mqtt_topic_pattern: "#{topic_prefix}commands/:device_id/:device_type/:group_id", + mqtt_state_topic_pattern: "#{topic_prefix}state/:device_id/:device_type/:group_id", + mqtt_update_topic_pattern: "#{topic_prefix}updates/:device_id/:device_type/:group_id" + }.merge(overrides) + end + + def create_mqtt_client(overrides = {}) + params = + mqtt_parameters + .merge({topic_prefix: mqtt_topic_prefix()}) + .merge(overrides) + + MqttClient.new( + ENV['ESPMH_LOCAL_MQTT_SERVER'] || params[:mqtt_server], + params[:mqtt_username], + params[:mqtt_password], + params[:topic_prefix] + ) + end +end \ No newline at end of file diff --git a/test/remote/helpers/state_helpers.rb b/test/remote/helpers/state_helpers.rb new file mode 100644 index 00000000..59a61898 --- /dev/null +++ b/test/remote/helpers/state_helpers.rb @@ -0,0 +1,7 @@ +module StateHelpers + ALL_REMOTE_TYPES = %w(rgb rgbw rgb_cct cct fut089 fut091) + def states_are_equal(desired_state, retrieved_state) + expect(retrieved_state).to include(*desired_state.keys) + expect(retrieved_state.select { |x| desired_state.include?(x) } ).to eq(desired_state) + end +end \ No newline at end of file diff --git a/test/remote/helpers/transition_helpers.rb b/test/remote/helpers/transition_helpers.rb new file mode 100644 index 00000000..9ec521fc --- /dev/null +++ b/test/remote/helpers/transition_helpers.rb @@ -0,0 +1,128 @@ +require 'chroma' + +module TransitionHelpers + module Defaults + DURATION = 4500 + PERIOD = 450 + NUM_PERIODS = 10 + end + + def highlight_value(a, highlight_ix) + str = a + .each_with_index + .map do |x, i| + i == highlight_ix ? ">>#{x}<<" : x + end + .join(', ') + "[#{str}]" + end + + def color_transitions_are_equal(expected:, seen:) + %i(hue saturation).each do |label| + e = expected.map { |x| x[label] } + s = seen.map { |x| x[label] } + + transitions_are_equal(expected: e, seen: s, label: label, allowed_variation: label == :saturation ? 5 : 20) + end + end + + def transitions_are_equal(expected:, seen:, allowed_variation: 0, label: nil) + generate_msg = ->(a, b, i) do + s = "Transition step value" + + if !label.nil? + s << " for #{label} " + end + + s << "at index #{i} " + + s << if allowed_variation == 0 + "should be equal to expected value. Expected: #{a}, saw: #{b}." + else + "should be within #{allowed_variation} of expected value. Expected: #{a}, saw: #{b}." + end + + s << " Steps:\n" + s << " Expected : #{highlight_value(expected, i)},\n" + s << " Seen : #{highlight_value(seen, i)}" + end + + expect(expected.length).to eq(seen.length) + + expected.zip(seen).each_with_index do |x, i| + a, b = x + diff = (a - b).abs + expect(diff).to be <= allowed_variation, generate_msg.call(a, b, i) + end + end + + def rgb_to_hs(*color) + if color.length > 1 + r, g, b = color + else + r, g, b = coerce_color(color.first) + end + + hsv = Chroma::Converters::HsvConverter.convert_rgb(Chroma::ColorModes::Rgb.new(r, g, b)) + { hue: hsv.h.round, saturation: (100*hsv.s).round } + end + + def coerce_color(c) + c.split(',').map(&:to_i) unless c.is_a?(Array) + end + + def calculate_color_transition_steps(start_color:, end_color:, duration: nil, period: nil, num_periods: Defaults::NUM_PERIODS) + start_color = coerce_color(start_color) + end_color = coerce_color(end_color) + + part_transitions = start_color.zip(end_color).map do |c| + s, e = c + calculate_transition_steps(start_value: s, end_value: e, duration: duration, period: period, num_periods: num_periods) + end + + # If some colors don't transition, they'll stay at the same value while others move. + # Turn this: [[1,2,3], [0], [4,5,6]] + # Into this: [[1,2,3], [0,0,0], [4,5,6]] + longest = part_transitions.max_by { |x| x.length }.length + part_transitions.map! { |x| x + [x.last]*(longest-x.length) } + + # Zip individual parts into 3-tuples + # Turn this: [[1,2,3], [0,0,0], [4,5,6]] + # Into this: [[1,0,4], [2,0,5], [3,0,6]] + transition_colors = part_transitions.first.zip(*part_transitions[1..part_transitions.length]) + + # Undergo the RGB -> HSV w/ value = 100 + transition_colors.map do |x| + r, g, b = x + rgb_to_hs(r, g, b) + end + end + + def calculate_transition_steps(start_value:, end_value:, duration: nil, period: nil, num_periods: Defaults::NUM_PERIODS) + if !duration.nil? || !period.nil? + period ||= Defaults::PERIOD + duration ||= Defaults::DURATION + num_periods = [1, (duration / period.to_f).ceil].max + end + + diff = end_value - start_value + step_size = [1, (diff.abs / num_periods.to_f).ceil].max + step_size = -step_size if end_value < start_value + + steps = [] + val = start_value + + while val != end_value + steps << val + + if (end_value - val).abs < step_size.abs + val += (end_value - val) + else + val += step_size + end + end + + steps << end_value + steps + end +end \ No newline at end of file diff --git a/test/remote/lib/api_client.rb b/test/remote/lib/api_client.rb new file mode 100644 index 00000000..4f3d46c1 --- /dev/null +++ b/test/remote/lib/api_client.rb @@ -0,0 +1,133 @@ +require 'json' +require 'net/http' +require 'net/http/post/multipart' +require 'uri' + +class ApiClient + def initialize(host, base_id) + @host = host + @current_id = Integer(base_id) + end + + def self.from_environment + ApiClient.new( + ENV.fetch('ESPMH_HOSTNAME'), + ENV.fetch('ESPMH_TEST_DEVICE_ID_BASE') + ) + end + + def generate_id + id = @current_id + @current_id += 1 + id + end + + def set_auth!(username, password) + @username = username + @password = password + end + + def clear_auth! + @username = nil + @password = nil + end + + def reboot + post('/system', '{"command":"restart"}') + end + + def request(type, path, req_body = nil) + uri = URI("http://#{@host}#{path}") + Net::HTTP.start(uri.host, uri.port) do |http| + req_type = Net::HTTP.const_get(type) + + req = req_type.new(uri) + if req_body + req['Content-Type'] = 'application/json' + req_body = req_body.to_json if !req_body.is_a?(String) + req.body = req_body + end + + if @username && @password + req.basic_auth(@username, @password) + end + + res = http.request(req) + + begin + res.value + rescue Exception => e + puts "REST Client Error: #{e}\nBody:\n#{res.body}" + raise e + end + + body = res.body + + if res['content-type'].downcase == 'application/json' + body = JSON.parse(body) + end + + body + end + end + + def upload_json(path, file) + `curl -s "http://#{@host}#{path}" -X POST -F 'f=@#{file}'` + end + + def patch_settings(settings) + put('/settings', settings) + end + + def get(path) + request(:Get, path) + end + + def put(path, body) + request(:Put, path, body) + end + + def post(path, body) + request(:Post, path, body) + end + + def delete(path) + request(:Delete, path) + end + + def state_path(params = {}) + query = if params[:blockOnQueue].nil? || params[:blockOnQueue] + "?blockOnQueue=true" + else + "" + end + + "/gateways/#{params[:id]}/#{params[:type]}/#{params[:group_id]}#{query}" + end + + def delete_state(params = {}) + delete(state_path(params)) + end + + def get_state(params = {}) + get(state_path(params)) + end + + def patch_state(state, params = {}) + put(state_path(params), state.to_json) + end + + def schedule_transition(_id_params, transition_params) + id_params = { + device_id: _id_params[:id], + remote_type: _id_params[:type], + group_id: _id_params[:group_id] + } + + post("/transitions", id_params.merge(transition_params)) + end + + def transitions + get('/transitions')['transitions'] + end +end \ No newline at end of file diff --git a/test/remote/lib/mqtt_client.rb b/test/remote/lib/mqtt_client.rb new file mode 100644 index 00000000..134df2f2 --- /dev/null +++ b/test/remote/lib/mqtt_client.rb @@ -0,0 +1,107 @@ +require 'mqtt' +require 'timeout' +require 'json' + +class MqttClient + BreakListenLoopError = Class.new(StandardError) + + def initialize(server, username, password, topic_prefix) + @client = MQTT::Client.connect("mqtt://#{username}:#{password}@#{server}") + @topic_prefix = topic_prefix + @listen_threads = [] + end + + def disconnect + @client.disconnect + end + + def reconnect + @client.disconnect + @client.connect + end + + def wait_for_message(topic, timeout = 10) + on_message(topic, timeout) { |topic, message| } + wait_for_listeners + end + + def id_topic_suffix(params) + if params + str_id = if params[:id_format] == 'decimal' + params[:id].to_s + else + sprintf '0x%04X', params[:id] + end + + "#{str_id}/#{params[:type]}/#{params[:group_id]}" + else + "+/+/+" + end + end + + def on_update(id_params = nil, timeout = 10, &block) + on_id_message('updates', id_params, timeout, &block) + end + + def on_state(id_params = nil, timeout = 10, &block) + on_id_message('state', id_params, timeout, &block) + end + + def on_id_message(path, id_params, timeout, &block) + sub_topic = "#{@topic_prefix}#{path}/#{id_topic_suffix(nil)}" + + on_message(sub_topic, timeout) do |topic, message| + topic_parts = topic.split('/') + topic_id_params = { + id: topic_parts[2].to_i(16), + type: topic_parts[3], + group_id: topic_parts[4].to_i, + unparsed_id: topic_parts[2] + } + + if !id_params || %w(id type group_id).all? { |k| k=k.to_sym; topic_id_params[k] == id_params[k] } + begin + message = JSON.parse(message) + rescue JSON::ParserError => e + end + + yield( topic_id_params, message ) + end + end + end + + def on_message(topic, timeout = 10, raise_error = true, &block) + @listen_threads << Thread.new do + begin + Timeout.timeout(timeout) do + @client.get(topic) do |topic, message| + ret_val = yield(topic, message) + raise BreakListenLoopError if ret_val + end + end + rescue Timeout::Error => e + puts "Timed out listening for message on: #{topic}" + raise e if raise_error + rescue BreakListenLoopError + end + end + end + + def publish(topic, state = {}, retain = false) + state = state.to_json unless state.is_a?(String) + + @client.publish(topic, state, retain) + end + + def patch_state(id_params, state = {}) + @client.publish( + "#{@topic_prefix}commands/#{id_topic_suffix(id_params)}", + state.to_json + ) + end + + def wait_for_listeners + @listen_threads.each(&:join) + @listen_threads.clear + end +end \ No newline at end of file diff --git a/test/remote/settings.json.example b/test/remote/settings.json.example new file mode 100644 index 00000000..d0840895 --- /dev/null +++ b/test/remote/settings.json.example @@ -0,0 +1,39 @@ +{ + "admin_username": "", + "admin_password": "", + "ce_pin": 16, + "csn_pin": 15, + "reset_pin": 0, + "led_pin": -2, + "radio_interface_type": "nRF24", + "packet_repeats": 50, + "http_repeat_factor": 1, + "auto_restart_period": 0, + "discovery_port": 0, + "listen_repeats": 3, + "state_flush_interval": 2000, + "mqtt_state_rate_limit": 1000, + "packet_repeat_throttle_sensitivity": 0, + "packet_repeat_throttle_threshold": 200, + "packet_repeat_minimum": 3, + "enable_automatic_mode_switching": false, + "led_mode_wifi_config": "Fast toggle", + "led_mode_wifi_failed": "On", + "led_mode_operating": "Off", + "led_mode_packet": "Flicker", + "led_mode_packet_count": 3, + "hostname": "milight-hub-test", + "rf24_power_level": "MAX", + "device_ids": [ + ], + "group_state_fields": [ + "status", + "level", + "color_temp", + "kelvin", + "bulb_mode", + "hue", + "saturation", + "effect" + ] +} diff --git a/test/remote/spec/discovery_spec.rb b/test/remote/spec/discovery_spec.rb new file mode 100644 index 00000000..6d2cd0c1 --- /dev/null +++ b/test/remote/spec/discovery_spec.rb @@ -0,0 +1,202 @@ +require 'api_client' + +RSpec.describe 'MQTT Discovery' do + before(:all) do + @client = ApiClient.new(ENV.fetch('ESPMH_HOSTNAME'), ENV.fetch('ESPMH_TEST_DEVICE_ID_BASE')) + @client.upload_json('/settings', 'settings.json') + + @test_id = 1 + @topic_prefix = mqtt_topic_prefix() + @discovery_prefix = "#{@topic_prefix}discovery/" + + @mqtt_client = create_mqtt_client() + end + + after(:all) do + # Clean up any leftover cruft + @mqtt_client.on_message("#{@discovery_prefix}#", 1, false) do |topic, message| + if message.length > 0 + @mqtt_client.publish(topic, '', true) + end + false + end + @mqtt_client.wait_for_listeners + end + + before(:each) do + mqtt_params = mqtt_parameters() + + @client.put( + '/settings', + mqtt_params + ) + + @id_params = { + id: @client.generate_id, + type: 'rgb_cct', + group_id: 1 + } + @discovery_suffix = "#{@id_params[:type]}_#{sprintf("0x%04x", @id_params[:id])}_#{@id_params[:group_id]}/config" + @test_discovery_prefix = "#{@discovery_prefix}#{@id_params[:id]}/" + end + + context 'when not configured' do + it 'should behave appropriately when MQTT is not configured' do + @client.patch_settings(mqtt_server: '', home_assistant_discovery_prefix: '') + expect { @client.get('/settings') }.to_not raise_error + end + + it 'should behave appropriately when MQTT is configured, but discovery is not' do + @client.patch_settings(mqtt_parameters().merge(home_assistant_discovery_prefix: '')) + expect { @client.get('/settings') }.to_not raise_error + end + end + + context 'discovery topics' do + it 'should send discovery messages' do + saw_message = false + + @mqtt_client.on_message("#{@test_discovery_prefix}light/+/#{@discovery_suffix}") do |topic, message| + saw_message = true + end + + @client.patch_settings( + home_assistant_discovery_prefix: @test_discovery_prefix, + group_id_aliases: { + 'test_group' => [@id_params[:type], @id_params[:id], @id_params[:group_id]] + } + ) + + @mqtt_client.wait_for_listeners + + expect(saw_message).to be(true) + end + + it 'config should have expected keys' do + saw_message = false + config = nil + + @mqtt_client.on_message("#{@test_discovery_prefix}light/+/#{@discovery_suffix}") do |topic, message| + config = JSON.parse(message) + saw_message = true + end + + @client.patch_settings( + home_assistant_discovery_prefix: @test_discovery_prefix, + group_id_aliases: { + 'test_group' => [@id_params[:type], @id_params[:id], @id_params[:group_id]] + } + ) + + @mqtt_client.wait_for_listeners + + expect(saw_message).to be(true) + expected_keys = %w( + schema + name + command_topic + state_topic + brightness + rgb + color_temp + effect + effect_list + device + ) + expect(config.keys).to include(*expected_keys) + + expect(config['effect_list']).to include(*%w(white_mode night_mode)) + expect(config['effect_list']).to include(*(0..8).map(&:to_s)) + end + + it 'should list identifiers for ESP and bulb' do + saw_message = false + config = nil + + @mqtt_client.on_message("#{@test_discovery_prefix}light/+/#{@discovery_suffix}") do |topic, message| + config = JSON.parse(message) + saw_message = config['device'] && config['device']['identifiers'] && config['device']['identifiers'][1] == @id_params[:id] + end + + @client.patch_settings( + home_assistant_discovery_prefix: @test_discovery_prefix, + group_id_aliases: { + 'test_group' => [@id_params[:type], @id_params[:id], @id_params[:group_id]] + } + ) + + @mqtt_client.wait_for_listeners + + expect(config.keys).to include('device') + + device_data = config['device'] + + expect(device_data.keys).to include(*%w(manufacturer sw_version identifiers)) + expect(device_data['manufacturer']).to eq('esp8266_milight_hub') + + ids = device_data['identifiers'] + expect(ids.length).to eq(4) + expect(ids[1]).to eq(@id_params[:id]) + expect(ids[2]).to eq(@id_params[:type]) + expect(ids[3]).to eq(@id_params[:group_id]) + end + + it 'should remove discoverable devices when alias is removed' do + seen_config = false + seen_blank_message = false + + @mqtt_client.on_message("#{@test_discovery_prefix}light/+/#{@discovery_suffix}") do |topic, message| + seen_config = seen_config || message.length > 0 + seen_blank_message = seen_blank_message || message.length == 0 + + seen_config && seen_blank_message + end + + # This should create the device + @client.patch_settings( + home_assistant_discovery_prefix: @test_discovery_prefix, + group_id_aliases: { + 'test_group' => [@id_params[:type], @id_params[:id], @id_params[:group_id]] + } + ) + + # This should clear it + @client.patch_settings( + group_id_aliases: { } + ) + + @mqtt_client.wait_for_listeners + + expect(seen_config).to be(true) + expect(seen_blank_message).to be(true), "should see deletion message" + end + + it 'should configure devices with an availability topic if client status is configured' do + expected_keys = %w( + availability_topic + payload_available + payload_not_available + ) + config = nil + + @mqtt_client.on_message("#{@test_discovery_prefix}light/+/#{@discovery_suffix}") do |topic, message| + config = JSON.parse(message) + (expected_keys - config.keys).empty? + end + + # This should create the device + @client.patch_settings( + home_assistant_discovery_prefix: @test_discovery_prefix, + group_id_aliases: { + 'test_group' => [@id_params[:type], @id_params[:id], @id_params[:group_id]] + }, + mqtt_client_status_topic: "#{@topic_prefix}status", + simple_mqtt_client_status: true + ) + + @mqtt_client.wait_for_listeners + + expect(config.keys).to include(*expected_keys) + end + end +end \ No newline at end of file diff --git a/test/remote/spec/environment_spec.rb b/test/remote/spec/environment_spec.rb new file mode 100644 index 00000000..928cb256 --- /dev/null +++ b/test/remote/spec/environment_spec.rb @@ -0,0 +1,33 @@ +require 'api_client' + +RSpec.describe 'Environment' do + before(:each) do + @host = ENV.fetch('ESPMH_HOSTNAME') + @client = ApiClient.new(ENV.fetch('ESPMH_HOSTNAME'), ENV.fetch('ESPMH_TEST_DEVICE_ID_BASE')) + end + + context 'environment' do + it 'should have a host defined' do + expect(@host).to_not be_nil + end + + it 'should respond to /about' do + response = @client.get('/about') + + expect(response).to_not be_nil + expect(response.keys).to include('version') + end + end + + context 'client' do + it 'should return IDs' do + id = @client.generate_id + + expect(@client.generate_id).to equal(id + 1) + end + end + + it 'needs to have a settings.json file' do + expect(File.exists?('settings.json')).to be(true) + end +end \ No newline at end of file diff --git a/test/remote/spec/mqtt_spec.rb b/test/remote/spec/mqtt_spec.rb new file mode 100644 index 00000000..c7f95ced --- /dev/null +++ b/test/remote/spec/mqtt_spec.rb @@ -0,0 +1,483 @@ +require 'api_client' + +RSpec.describe 'MQTT' do + before(:all) do + @client = ApiClient.new(ENV.fetch('ESPMH_HOSTNAME'), ENV.fetch('ESPMH_TEST_DEVICE_ID_BASE')) + @client.upload_json('/settings', 'settings.json') + end + + before(:each) do + mqtt_params = mqtt_parameters() + @updates_topic = mqtt_params[:updates_topic] + @topic_prefix = mqtt_topic_prefix() + + @client.put( + '/settings', + mqtt_params + ) + + @id_params = { + id: @client.generate_id, + type: 'rgb_cct', + group_id: 1 + } + @client.delete_state(@id_params) + + @mqtt_client = create_mqtt_client() + end + + context 'deleting' do + it 'should remove retained state' do + @client.patch_state({status: 'ON'}, @id_params) + + seen_blank = false + + @mqtt_client.on_state(@id_params) do |topic, message| + seen_blank = (message == "") + end + + @client.delete_state(@id_params) + @mqtt_client.wait_for_listeners + + expect(seen_blank).to eq(true) + end + end + + context 'client status topic' do + before(:all) do + @status_topic = "#{mqtt_topic_prefix()}client_status" + @client.patch_settings(mqtt_client_status_topic: @status_topic) + end + + it 'should send client status messages when configured' do + # Clear any retained messages + @mqtt_client.publish(@status_topic, nil) + + # Unfortunately, no way to easily simulate an unclean disconnect, so only test birth + # and forced disconnect + seen_statuses = Set.new + required_statuses = %w(connected disconnected_clean) + + @mqtt_client.on_message(@status_topic, 20) do |topic, message| + message = JSON.parse(message) + + seen_statuses << message['status'] + required_statuses.all? { |x| seen_statuses.include?(x) } + end + + # Force MQTT reconnect by updating settings + @client.put('/settings', fakekey: 'fakevalue') + + @mqtt_client.wait_for_listeners + + expect(seen_statuses).to include(*required_statuses) + end + + it 'should send simple client status message when configured' do + @client.patch_settings(simple_mqtt_client_status: true) + + # Clear any retained messages + @mqtt_client.publish(@status_topic, nil) + + # Unfortunately, no way to easily simulate an unclean disconnect, so only test birth + # and forced disconnect + seen_statuses = Set.new + required_statuses = %w(connected disconnected) + + @mqtt_client.on_message(@status_topic, 20) do |topic, message| + seen_statuses << message + required_statuses.all? { |x| seen_statuses.include?(x) } + end + + # Force MQTT reconnect by updating settings + @client.patch_settings(fakekey: 'fakevalue') + + @mqtt_client.wait_for_listeners + + expect(seen_statuses).to include(*required_statuses) + end + end + + context 'commands and state' do + # Check state using HTTP + it 'should affect state' do + @client.patch_state({level: 50, status: 'off'}, @id_params) + + @mqtt_client.patch_state(@id_params, status: 'on', level: 70) + + # wait for packet to be sent... + sleep(1) + + state = @client.get_state(@id_params) + + expect(state.keys).to include(*%w(level status)) + expect(state['status']).to eq('ON') + expect(state['level']).to eq(70) + end + + it 'should publish to state topics' do + desired_state = {'status' => 'ON', 'level' => 80} + seen_state = false + + @client.patch_state({status: 'off'}, @id_params) + + @mqtt_client.on_state(@id_params) do |id, message| + seen_state = desired_state.all? { |k,v| v == message[k] } + end + + @mqtt_client.patch_state(@id_params, desired_state) + @mqtt_client.wait_for_listeners + + expect(seen_state).to be(true) + end + + it 'should publish an update message for each new command' do + tweak_params = {'hue' => 49, 'brightness' => 128, 'saturation' => 50} + desired_state = {'state' => 'ON'}.merge(tweak_params) + + init_state = desired_state.merge(Hash[ + tweak_params.map do |k, v| + [k, v + 10] + end + ]) + + @client.patch_state(@id_params, init_state) + + accumulated_state = {} + @mqtt_client.on_update(@id_params) do |id, message| + desired_state == accumulated_state.merge!(message) + end + + @mqtt_client.patch_state(@id_params, desired_state) + @mqtt_client.wait_for_listeners + + expect(accumulated_state).to eq(desired_state) + end + + it 'should respect the state update interval' do + # Disable updates to prevent the negative effects of spamming commands + @client.put( + '/settings', + mqtt_update_topic_pattern: '', + mqtt_state_rate_limit: 500, + packet_repeats: 1 + ) + + # Set initial state + @client.patch_state({status: 'ON', level: 0}, @id_params) + + last_seen = 0 + update_timestamp_gaps = [] + num_updates = 50 + + @mqtt_client.on_state(@id_params) do |id, message| + next_time = Time.now + if last_seen != 0 + update_timestamp_gaps << next_time - last_seen + end + last_seen = next_time + + message['level'] == num_updates + end + + (1..num_updates).each do |i| + @mqtt_client.patch_state(@id_params, level: i) + sleep 0.1 + end + + @mqtt_client.wait_for_listeners + + # Discard first, retained messages mess with it + avg = update_timestamp_gaps.sum / update_timestamp_gaps.length + + expect(update_timestamp_gaps.length).to be >= 3 + expect((avg - 0.5).abs).to be < 0.15, "Should be within margin of error of rate limit" + end + end + + context ':device_id token for command topic' do + it 'should support hexadecimal device IDs' do + seen = false + + @mqtt_client.on_state(@id_params) do |id, message| + seen = (message['status'] == 'ON') + end + + # Will use hex by default + @mqtt_client.patch_state(@id_params, status: 'ON') + @mqtt_client.wait_for_listeners + + expect(seen).to eq(true), "Should see update for hex param" + end + + it 'should support decimal device IDs' do + seen = false + + @mqtt_client.on_state(@id_params) do |id, message| + seen = (message['status'] == 'ON') + end + + @mqtt_client.publish( + "#{@topic_prefix}commands/#{@id_params[:id]}/rgb_cct/1", + status: 'ON' + ) + @mqtt_client.wait_for_listeners + + expect(seen).to eq(true), "Should see update for decimal param" + end + end + + context ':hex_device_id for command topic' do + before(:all) do + @client.put( + '/settings', + mqtt_topic_pattern: "#{@topic_prefix}commands/:hex_device_id/:device_type/:group_id", + ) + end + + after(:all) do + @client.put( + '/settings', + mqtt_topic_pattern: "#{@topic_prefix}commands/:device_id/:device_type/:group_id", + ) + end + + it 'should respond to commands' do + seen = false + + @mqtt_client.on_state(@id_params) do |id, message| + seen = (message['status'] == 'ON') + end + + # Will use hex by default + @mqtt_client.patch_state(@id_params, status: 'ON') + @mqtt_client.wait_for_listeners + + expect(seen).to eq(true), "Should see update for hex param" + end + end + + context ':dec_device_id for command topic' do + before(:all) do + @client.put( + '/settings', + mqtt_topic_pattern: "#{@topic_prefix}commands/:dec_device_id/:device_type/:group_id", + ) + end + + after(:all) do + @client.put( + '/settings', + mqtt_topic_pattern: "#{@topic_prefix}commands/:device_id/:device_type/:group_id", + ) + end + + it 'should respond to commands' do + seen = false + + @mqtt_client.on_state(@id_params) do |id, message| + seen = (message['status'] == 'ON') + end + + # Will use hex by default + @mqtt_client.patch_state(@id_params, status: 'ON') + @mqtt_client.wait_for_listeners + + expect(seen).to eq(true), "Should see update for hex param" + end + end + + describe ':hex_device_id for update/state topics' do + before(:all) do + @client.put( + '/settings', + mqtt_state_topic_pattern: "#{@topic_prefix}state/:hex_device_id/:device_type/:group_id", + mqtt_update_topic_pattern: "#{@topic_prefix}updates/:hex_device_id/:device_type/:group_id" + ) + end + + after(:all) do + @client.put( + '/settings', + mqtt_state_topic_pattern: "#{@topic_prefix}state/:device_id/:device_type/:group_id", + mqtt_update_topic_pattern: "#{@topic_prefix}updates/:device_id/:device_type/:group_id" + ) + end + + context 'state and updates' do + it 'should publish updates with hexadecimal device ID' do + seen_update = false + + @mqtt_client.on_update(@id_params) do |id, message| + seen_update = (message['state'] == 'ON') + end + + # Will use hex by default + @mqtt_client.patch_state(@id_params, status: 'ON') + @mqtt_client.wait_for_listeners + + expect(seen_update).to eq(true) + end + + it 'should publish state with hexadecimal device ID' do + seen_state = false + + @mqtt_client.on_state(@id_params) do |id, message| + seen_state = (message['status'] == 'ON') + end + + # Will use hex by default + @mqtt_client.patch_state(@id_params, status: 'ON') + @mqtt_client.wait_for_listeners + + expect(seen_state).to eq(true) + end + end + end + + describe ':dec_device_id for update/state topics' do + before(:all) do + @client.put( + '/settings', + mqtt_state_topic_pattern: "#{@topic_prefix}state/:dec_device_id/:device_type/:group_id", + mqtt_update_topic_pattern: "#{@topic_prefix}updates/:dec_device_id/:device_type/:group_id" + ) + end + + after(:all) do + @client.put( + '/settings', + mqtt_state_topic_pattern: "#{@topic_prefix}state/:device_id/:device_type/:group_id", + mqtt_update_topic_pattern: "#{@topic_prefix}updates/:device_id/:device_type/:group_id" + ) + end + + context 'state and updates' do + it 'should publish updates with hexadecimal device ID' do + seen_update = false + @id_params = @id_params.merge(id_format: 'decimal') + + @mqtt_client.on_update(@id_params) do |id, message| + seen_update = (message['state'] == 'ON') + end + + # Will use hex by default + @mqtt_client.patch_state(@id_params, status: 'ON') + @mqtt_client.wait_for_listeners + + expect(seen_update).to eq(true) + end + + it 'should publish state with hexadecimal device ID' do + seen_state = false + @id_params = @id_params.merge(id_format: 'decimal') + + @mqtt_client.on_state(@id_params) do |id, message| + seen_state = (message['status'] == 'ON') + end + + sleep 1 + + # Will use hex by default + @mqtt_client.patch_state(@id_params, status: 'ON') + @mqtt_client.wait_for_listeners + + expect(seen_state).to eq(true) + end + end + end + + describe 'device aliases' do + before(:each) do + @aliases_topic = "#{mqtt_topic_prefix()}commands/:device_alias" + @client.patch_settings( + mqtt_topic_pattern: @aliases_topic, + group_id_aliases: { + 'test_group' => [@id_params[:type], @id_params[:id], @id_params[:group_id]] + } + ) + @client.delete_state(@id_params) + end + + context ':device_alias token' do + it 'should accept it for command topic' do + @client.patch_settings(mqtt_topic_pattern: @aliases_topic) + + @mqtt_client.publish("#{mqtt_topic_prefix()}commands/test_group", status: 'ON') + + sleep(1) + + state = @client.get_state(@id_params) + expect(state['status']).to eq('ON') + end + + it 'should support publishing state to device alias topic' do + @client.patch_settings( + mqtt_topic_pattern: @aliases_topic, + mqtt_state_topic_pattern: "#{mqtt_topic_prefix()}state/:device_alias" + ) + + seen_alias = nil + seen_state = nil + + @mqtt_client.on_message("#{mqtt_topic_prefix()}state/+") do |topic, message| + parts = topic.split('/') + + seen_alias = parts.last + seen_state = JSON.parse(message) + + seen_alias == 'test_group' + end + @mqtt_client.publish("#{mqtt_topic_prefix()}commands/test_group", status: 'ON') + + @mqtt_client.wait_for_listeners + + expect(seen_alias).to eq('test_group') + expect(seen_state['status']).to eq('ON') + end + + it 'should support publishing updates to device alias topic' do + @client.patch_settings( + mqtt_topic_pattern: @aliases_topic, + mqtt_update_topic_pattern: "#{mqtt_topic_prefix()}updates/:device_alias" + ) + + seen_alias = nil + seen_state = nil + + @mqtt_client.on_message("#{mqtt_topic_prefix()}updates/+") do |topic, message| + parts = topic.split('/') + + seen_alias = parts.last + seen_state = JSON.parse(message) + + seen_alias == 'test_group' + end + @mqtt_client.publish("#{mqtt_topic_prefix()}commands/test_group", status: 'ON') + + @mqtt_client.wait_for_listeners + + expect(seen_alias).to eq('test_group') + expect(seen_state['state']).to eq('ON') + end + + it 'should delete retained alias messages' do + seen_empty_message = false + + @client.patch_settings(mqtt_state_topic_pattern: "#{mqtt_topic_prefix()}state/:device_alias") + @client.patch_state(@id_params, status: 'ON') + + @mqtt_client.on_message("#{mqtt_topic_prefix()}state/test_group") do |topic, message| + seen_empty_message = message.empty? + end + + @client.patch_state(@id_params, hue: 100) + @client.delete_state(@id_params) + + @mqtt_client.wait_for_listeners + + expect(seen_empty_message).to eq(true) + end + end + end +end \ No newline at end of file diff --git a/test/remote/spec/rest_spec.rb b/test/remote/spec/rest_spec.rb new file mode 100644 index 00000000..7cc6387c --- /dev/null +++ b/test/remote/spec/rest_spec.rb @@ -0,0 +1,175 @@ +require 'api_client' + +RSpec.describe 'REST Server' do + before(:all) do + @client = ApiClient.new(ENV.fetch('ESPMH_HOSTNAME'), ENV.fetch('ESPMH_TEST_DEVICE_ID_BASE')) + @client.upload_json('/settings', 'settings.json') + + @username = 'a' + @password = 'a' + end + + context 'authentication' do + after(:all) do + @client.set_auth!(@username, @password) + @client.put('/settings', admin_username: '', admin_password: '') + end + + it 'should not require auth unless both username and password are set' do + @client.put('/settings', admin_username: 'abc', admin_password: '') + expect { @client.get('/settings') }.not_to raise_error + + @client.put('/settings', admin_username: '', admin_password: 'abc') + expect { @client.get('/settings') }.not_to raise_error + + @client.put('/settings', admin_username: '', admin_password: '') + expect { @client.get('/settings') }.not_to raise_error + end + + it 'should require auth for all routes when password is set' do + @client.put('/settings', admin_username: @username, admin_password: @password) + + # Try no auth + expect { @client.get('/settings') }.to raise_error(Net::HTTPServerException) + + # Try wrong username + @client.set_auth!("#{@username}wronguser", @password) + expect { @client.get('/settings') }.to raise_error(Net::HTTPServerException) + + # Try wrong password + @client.set_auth!(@username, "wrong#{@password}") + expect { @client.get('/settings') }.to raise_error(Net::HTTPServerException) + + # Try right username + @client.set_auth!(@username, @password) + expect { @client.get('/settings') }.not_to raise_error + + # Make sure all routes are protected + @client.clear_auth! + [ + '/about', + '/gateways/0/rgb_cct/1', + '/remote_configs', + '/' + ].each do |page| + expect { @client.get(page) }.to raise_error(Net::HTTPServerException), "No auth required for page: #{page}" + end + + expect { @client.post('/system', {}) }.to raise_error(Net::HTTPServerException) + expect { @client.post('/firmware', {}) }.to raise_error(Net::HTTPServerException) + + # Clear auth + @client.set_auth!(@username, @password) + @client.put('/settings', admin_username: '', admin_password: '') + @client.clear_auth! + + expect { @client.get('/settings') }.not_to raise_error + end + end + + context 'misc routes' do + it 'should respond to /about' do + result = @client.get('/about') + + expect(result['firmware']).to eq('milight-hub') + end + + it 'should respond to /system' do + expect { @client.post('/system', {}) }.to raise_error('400 "Bad Request"') + end + + it 'should respond to /remote_configs' do + result = @client.get('/remote_configs') + + expect(result).to be_a(Array) + expect(result).to include('rgb_cct') + end + end + + context 'sending raw packets' do + it 'should support sending a raw packet' do + id = { + id: 0x2222, + type: 'rgb_cct', + group_id: 1 + } + @client.delete_state(id) + + # Hard-coded packet which should turn the bulb on + result = @client.post( + '/raw_commands/rgb_cct', + packet: '00 DB BF 01 66 D1 BB 66 F7', + num_repeats: 1 + ) + expect(result['success']).to be_truthy + + sleep(1) + + state = @client.get_state(id) + expect(state['status']).to eq('ON') + end + end + + context 'device aliases' do + before(:all) do + @device_id = { + id: @client.generate_id, + type: 'rgb_cct', + group_id: 1 + } + @alias = 'test' + + @client.patch_settings( + group_id_aliases: { + @alias => [ + @device_id[:type], + @device_id[:id], + @device_id[:group_id] + ] + } + ) + + @client.delete_state(@device_id) + end + + it 'should respond with a 404 for an alias that doesn\'t exist' do + expect { + @client.put("/gateways/__#{@alias}", status: 'on') + }.to raise_error(Net::HTTPServerException) + end + + it 'should update state for known alias' do + path = "/gateways/#{@alias}?blockOnQueue=true" + + @client.put(path, status: 'ON', hue: 100) + state = @client.get(path) + + expect(state['status']).to eq('ON') + expect(state['hue']).to eq(100) + + # ensure state for the non-aliased ID is the same + state = @client.get_state(@device_id) + + expect(state['status']).to eq('ON') + expect(state['hue']).to eq(100) + end + + it 'should handle saving bad input gracefully' do + values_to_try = [ + 'string', + 123, + [ ], + { 'test' => [ 'rgb_cct' ] }, + { 'test' => [ 'rgb_cct', 1 ] }, + { 'test' => [ 'rgb_cct', '1', 2 ] }, + { 'test' => [ 'abc' ] } + ] + + values_to_try.each do |v| + expect { + @client.patch_settings(group_id_aliases: v) + }.to_not raise_error + end + end + end +end \ No newline at end of file diff --git a/test/remote/spec/settings_spec.rb b/test/remote/spec/settings_spec.rb new file mode 100644 index 00000000..8ea1516b --- /dev/null +++ b/test/remote/spec/settings_spec.rb @@ -0,0 +1,217 @@ +require 'api_client' +require 'tempfile' +require 'net/ping' + +RSpec.describe 'Settings' do + before(:all) do + @client = ApiClient.new(ENV.fetch('ESPMH_HOSTNAME'), ENV.fetch('ESPMH_TEST_DEVICE_ID_BASE')) + @client.upload_json('/settings', 'settings.json') + + @username = 'a' + @password = 'a' + end + + after(:all) do + @client.set_auth!(@username, @password) + @client.put('/settings', admin_username: '', admin_password: '') + @client.clear_auth! + end + + before(:each) do + @client.set_auth!(@username, @password) + @client.put('/settings', admin_username: '', admin_password: '') + @client.clear_auth! + end + + context 'keys' do + it 'should persist known settings keys' do + { + 'simple_mqtt_client_status' => [true, false], + 'packet_repeats_per_loop' => [10], + 'home_assistant_discovery_prefix' => ['', 'abc', 'a/b/c'], + 'wifi_mode' => %w(b g n) + }.each do |key, values| + values.each do |v| + @client.patch_settings({key => v}) + expect(@client.get('/settings')[key]).to eq(v), "Should persist #{key} possible value: #{v}" + end + end + end + end + + context 'POST settings file' do + it 'should clobber patched settings' do + file = Tempfile.new('espmh-settings.json') + file.write({ + mqtt_server: 'test123' + }.to_json) + file.close + + @client.upload_json('/settings', file.path) + + settings = @client.get('/settings') + expect(settings['mqtt_server']).to eq('test123') + + @client.put('/settings', {mqtt_server: 'abc123', mqtt_username: 'foo'}) + + settings = @client.get('/settings') + expect(settings['mqtt_server']).to eq('abc123') + expect(settings['mqtt_username']).to eq('foo') + + @client.upload_json('/settings', file.path) + settings = @client.get('/settings') + + expect(settings['mqtt_server']).to eq('test123') + expect(settings['mqtt_username']).to eq('') + + File.delete(file.path) + end + + it 'should apply POSTed settings' do + file = Tempfile.new('espmh-settings.json') + file.write({ + admin_username: @username, + admin_password: @password + }.to_json) + file.close + + @client.upload_json('/settings', file.path) + + expect { @client.get('/settings') }.to raise_error(Net::HTTPServerException) + end + end + + context 'PUT settings file' do + it 'should accept a fairly large request body' do + contents = (1..25).reduce({}) { |a, x| a[x] = "test#{x}"*10; a } + + expect { @client.put('/settings', contents) }.to_not raise_error + end + + it 'should not cause excessive memory leaks' do + start_mem = @client.get('/about')['free_heap'] + + 20.times do + @client.put('/settings', mqtt_username: 'a') + end + + end_mem = @client.get('/about')['free_heap'] + + expect(end_mem).to be_within(250).of(start_mem) + end + end + + context 'radio' do + it 'should store a set of channels' do + val = %w(HIGH LOW) + @client.put('/settings', rf24_channels: val) + result = @client.get('/settings') + expect(result['rf24_channels']).to eq(val) + + val = %w(MID LOW) + @client.put('/settings', rf24_channels: val) + result = @client.get('/settings') + expect(result['rf24_channels']).to eq(val) + + val = %w(MID LOW LOW LOW) + @client.put('/settings', rf24_channels: val) + result = @client.get('/settings') + expect(result['rf24_channels']).to eq(Set.new(val).to_a) + end + + it 'should store a listen channel' do + @client.put('/settings', rf24_listen_channel: 'MID') + result = @client.get('/settings') + expect(result['rf24_listen_channel']).to eq('MID') + + @client.put('/settings', rf24_listen_channel: 'LOW') + result = @client.get('/settings') + expect(result['rf24_listen_channel']).to eq('LOW') + end + end + + context 'group id labels' do + it 'should store ID labels' do + id = 1 + + aliases = Hash[ + StateHelpers::ALL_REMOTE_TYPES.map do |remote_type| + ["test_#{id += 1}", [remote_type, id, 1]] + end + ] + + @client.patch_settings(group_id_aliases: aliases) + settings = @client.get('/settings') + + expect(settings['group_id_aliases']).to eq(aliases) + end + end + + context 'static ip' do + it 'should boot with static IP when applied' do + static_ip = ENV.fetch('ESPMH_STATIC_IP') + + @client.put( + '/settings', + wifi_static_ip: static_ip, + wifi_static_ip_netmask: ENV.fetch('ESPMH_STATIC_IP_NETMASK'), + wifi_static_ip_gateway: ENV.fetch('ESPMH_STATIC_IP_GATEWAY') + ) + + # Reboot to apply static ip + @client.reboot + + # Wait for it to come back up + ping_test = Net::Ping::External.new(static_ip) + + 10.times do + break if ping_test.ping? + sleep 1 + end + + expect(ping_test.ping?).to be(true) + + static_client = ApiClient.new(static_ip, ENV.fetch('ESPMH_TEST_DEVICE_ID_BASE')) + static_client.put('/settings', wifi_static_ip: '') + static_client.reboot + + ping_test = Net::Ping::External.new(ENV.fetch('ESPMH_HOSTNAME')) + + 10.times do + break if ping_test.ping? + sleep 1 + end + + expect(ping_test.ping?).to be(true) + end + end + + context 'defaults' do + before(:all) do + # Clobber all settings + file = Tempfile.new('espmh-settings.json') + file.close + + @client.upload_json('/settings', file.path) + end + + it 'should have some group state fields defined' do + settings = @client.get('/settings') + + expect(settings['group_state_fields']).to_not be_empty + end + + it 'should allow for empty group state fields if set' do + @client.patch_settings(group_state_fields: []) + settings = @client.get('/settings') + + expect(settings['group_state_fields']).to eq([]) + end + + it 'for enable_automatic_mode_switching, default should be false' do + settings = @client.get('/settings') + + expect(settings['enable_automatic_mode_switching']).to eq(false) + end + end +end \ No newline at end of file diff --git a/test/remote/spec/spec_helper.rb b/test/remote/spec/spec_helper.rb new file mode 100644 index 00000000..84f40735 --- /dev/null +++ b/test/remote/spec/spec_helper.rb @@ -0,0 +1,111 @@ +require 'dotenv' +require './helpers/state_helpers' +require './helpers/mqtt_helpers' +require './helpers/transition_helpers' + +Dotenv.load('espmh.env') + +# This file was generated by the `rspec --init` command. Conventionally, all +# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. +# The generated `.rspec` file contains `--require spec_helper` which will cause +# this file to always be loaded, without a need to explicitly require it in any +# files. +# +# Given that it is always loaded, you are encouraged to keep this file as +# light-weight as possible. Requiring heavyweight dependencies from this file +# will add to the boot time of your test suite on EVERY test run, even for an +# individual file that may not need all of that loaded. Instead, consider making +# a separate helper file that requires the additional dependencies and performs +# the additional setup, and require it from the spec files that actually need +# it. +# +# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration +RSpec.configure do |config| + config.include StateHelpers + config.include MqttHelpers + config.include TransitionHelpers + + # rspec-expectations config goes here. You can use an alternate + # assertion/expectation library such as wrong or the stdlib/minitest + # assertions if you prefer. + config.expect_with :rspec do |expectations| + # This option will default to `true` in RSpec 4. It makes the `description` + # and `failure_message` of custom matchers include text for helper methods + # defined using `chain`, e.g.: + # be_bigger_than(2).and_smaller_than(4).description + # # => "be bigger than 2 and smaller than 4" + # ...rather than: + # # => "be bigger than 2" + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + # rspec-mocks config goes here. You can use an alternate test double + # library (such as bogus or mocha) by changing the `mock_with` option here. + config.mock_with :rspec do |mocks| + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended, and will default to + # `true` in RSpec 4. + mocks.verify_partial_doubles = true + end + + # This option will default to `:apply_to_host_groups` in RSpec 4 (and will + # have no way to turn it off -- the option exists only for backwards + # compatibility in RSpec 3). It causes shared context metadata to be + # inherited by the metadata hash of host groups and examples, rather than + # triggering implicit auto-inclusion in groups with matching metadata. + config.shared_context_metadata_behavior = :apply_to_host_groups + +# The settings below are suggested to provide a good initial experience +# with RSpec, but feel free to customize to your heart's content. +=begin + # This allows you to limit a spec run to individual examples or groups + # you care about by tagging them with `:focus` metadata. When nothing + # is tagged with `:focus`, all examples get run. RSpec also provides + # aliases for `it`, `describe`, and `context` that include `:focus` + # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + config.filter_run_when_matching :focus + + # Allows RSpec to persist some state between runs in order to support + # the `--only-failures` and `--next-failure` CLI options. We recommend + # you configure your source control system to ignore this file. + config.example_status_persistence_file_path = "spec/examples.txt" + + # Limits the available syntax to the non-monkey patched syntax that is + # recommended. For more details, see: + # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ + # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ + # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode + config.disable_monkey_patching! + + # This setting enables warnings. It's recommended, but in some cases may + # be too noisy due to issues in dependencies. + config.warnings = true + + # Many RSpec users commonly either run the entire suite or an individual + # file, and it's useful to allow more verbose output when running an + # individual spec file. + if config.files_to_run.one? + # Use the documentation formatter for detailed output, + # unless a formatter has already been configured + # (e.g. via a command-line flag). + config.default_formatter = "doc" + end + + # Print the 10 slowest examples and example groups at the + # end of the spec run, to help surface which specs are running + # particularly slow. + config.profile_examples = 10 + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = :random + + # Seed global randomization in this process using the `--seed` CLI option. + # Setting this allows you to use `--seed` to deterministically reproduce + # test failures related to randomization by passing the same `--seed` value + # as the one that triggered the failure. + Kernel.srand config.seed +=end +end \ No newline at end of file diff --git a/test/remote/spec/state_spec.rb b/test/remote/spec/state_spec.rb new file mode 100644 index 00000000..2d292a54 --- /dev/null +++ b/test/remote/spec/state_spec.rb @@ -0,0 +1,549 @@ +require 'api_client' + +RSpec.describe 'State' do + before(:all) do + @client = ApiClient.new(ENV.fetch('ESPMH_HOSTNAME'), ENV.fetch('ESPMH_TEST_DEVICE_ID_BASE')) + @client.upload_json('/settings', 'settings.json') + end + + before(:each) do + @id_params = { + id: @client.generate_id, + type: 'rgb_cct', + group_id: 1 + } + @client.delete_state(@id_params) + end + + context 'blockOnQueue parameter' do + it 'should not receive state if we don\'t block on the packet queue' do + response = @client.patch_state({status: 'ON'}, @id_params.merge(blockOnQueue: false)) + + expect(response).to eq({'success' => true}) + end + + it 'should receive state if we do block on the packet queue' do + response = @client.patch_state({status: 'ON'}, @id_params.merge(blockOnQueue: true)) + + expect(response).to eq({'status' => 'ON'}) + end + end + + context 'initial state' do + it 'should assume white mode for device types that are white-only' do + %w(cct fut091).each do |type| + id = @id_params.merge(type: type) + @client.delete_state(id) + state = @client.patch_state({status: 'ON'}, id) + expect(state['bulb_mode']).to eq('white'), "it should assume white mode for #{type}" + end + end + + it 'should assume color mode for device types that are rgb-only' do + %w(rgb).each do |type| + id = @id_params.merge(type: type) + @client.delete_state(id) + state = @client.patch_state({status: 'ON'}, id) + expect(state['bulb_mode']).to eq('color'), "it should assume color mode for #{type}" + end + end + end + + context 'toggle command' do + it 'should toggle ON to OFF' do + init_state = @client.patch_state({'status' => 'ON'}, @id_params) + expect(init_state['status']).to eq('ON') + + next_state = @client.patch_state({'command' => 'toggle'}, @id_params) + expect(next_state['status']).to eq('OFF') + end + + it 'should toggle OFF to ON' do + init_state = @client.patch_state({'status' => 'OFF'}, @id_params) + expect(init_state['status']).to eq('OFF') + + next_state = @client.patch_state({'command' => 'toggle'}, @id_params) + expect(next_state['status']).to eq('ON') + end + end + + context 'night mode command' do + StateHelpers::ALL_REMOTE_TYPES + .reject { |x| %w(rgb).include?(x) } # Night mode not supported for these types + .each do |type| + it "should affect state when bulb is OFF for #{type}" do + params = @id_params.merge(type: type) + @client.delete_state(params) + state = @client.patch_state({'command' => 'night_mode'}, params) + + expect(state['bulb_mode']).to eq('night') + expect(state['effect']).to eq('night_mode') + end + end + + StateHelpers::ALL_REMOTE_TYPES + .reject { |x| %w(rgb).include?(x) } # Night mode not supported for these types + .each do |type| + it "should affect state when bulb is ON for #{type}" do + params = @id_params.merge(type: type) + @client.delete_state(params) + @client.patch_state({'status' => 'ON'}, params) + state = @client.patch_state({'command' => 'night_mode'}, params) + + # RGBW bulbs have to be OFF in order for night mode to take affect + expect(state['status']).to eq('ON') if type != 'rgbw' + expect(state['bulb_mode']).to eq('night') + expect(state['effect']).to eq('night_mode') + end + end + + it 'should revert to previous mode when status is toggled' do + @client.patch_state({'status' => 'ON', 'kelvin' => 100}, @id_params) + state = @client.patch_state({'command' => 'night_mode'}, @id_params) + + expect(state['effect']).to eq('night_mode') + + state = @client.patch_state({'status' => 'OFF'}, @id_params) + + expect(state['bulb_mode']).to eq('white') + expect(state['kelvin']).to eq(100) + + @client.patch_state({'status' => 'ON', 'hue' => 0}, @id_params) + state = @client.patch_state({'command' => 'night_mode'}, @id_params) + + expect(state['effect']).to eq('night_mode') + + state = @client.patch_state({'status' => 'OFF'}, @id_params) + + expect(state['bulb_mode']).to eq('color') + expect(state['hue']).to eq(0) + end + end + + context 'deleting' do + it 'should support deleting state' do + desired_state = { + 'status' => 'ON', + 'level' => 10, + 'hue' => 49, + 'saturation' => 20 + } + @client.patch_state(desired_state, @id_params) + + resulting_state = @client.get_state(@id_params) + expect(resulting_state).to_not be_empty + + @client.delete_state(@id_params) + resulting_state = @client.get_state(@id_params) + expect(resulting_state).to be_empty + end + end + + context 'persistence' do + it 'should persist parameters' do + desired_state = { + 'status' => 'ON', + 'level' => 100, + 'hue' => 0, + 'saturation' => 100 + } + @client.patch_state(desired_state, @id_params) + patched_state = @client.get_state(@id_params) + + states_are_equal(desired_state, patched_state) + + desired_state = { + 'status' => 'ON', + 'level' => 10, + 'hue' => 49, + 'saturation' => 20 + } + @client.patch_state(desired_state, @id_params) + patched_state = @client.get_state(@id_params) + + states_are_equal(desired_state, patched_state) + end + + it 'should affect member groups when changing group 0' do + group_0_params = @id_params.merge(group_id: 0) + + desired_state = { + 'status' => 'ON', + 'level' => 100, + 'hue' => 0, + 'saturation' => 100 + } + + @client.patch_state(desired_state, group_0_params) + + individual_state = desired_state.merge('level' => 10) + patched_state = @client.patch_state(individual_state, @id_params) + + expect(patched_state).to_not eq(desired_state) + states_are_equal(individual_state, patched_state) + + group_4_state = @client.get_state(group_0_params.merge(group_id: 4)) + + states_are_equal(desired_state, group_4_state) + + @client.patch_state(desired_state, group_0_params) + group_1_state = @client.get_state(group_0_params.merge(group_id: 1)) + + states_are_equal(desired_state, group_1_state) + end + + it 'should keep group 0 state' do + group_0_params = @id_params.merge(group_id: 0) + desired_state = { + 'status' => 'ON', + 'level' => 100, + 'hue' => 0, + 'saturation' => 100 + } + + patched_state = @client.patch_state(desired_state, group_0_params) + + states_are_equal(desired_state, patched_state) + end + + it 'should clear group 0 state after member group state changes' do + group_0_params = @id_params.merge(group_id: 0) + desired_state = { + 'status' => 'ON', + 'level' => 100, + 'kelvin' => 100 + } + + @client.patch_state(desired_state, group_0_params) + @client.patch_state(desired_state.merge('kelvin' => 10), @id_params) + + resulting_state = @client.get_state(group_0_params) + + expect(resulting_state.keys).to_not include('kelvin') + states_are_equal(desired_state.reject { |x| x == 'kelvin' }, resulting_state) + end + + it 'should not clear group 0 state when updating member group state if value is the same' do + group_0_params = @id_params.merge(group_id: 0) + desired_state = { + 'status' => 'ON', + 'level' => 100, + 'kelvin' => 100 + } + + @client.patch_state(desired_state, group_0_params) + @client.patch_state(desired_state.merge('kelvin' => 100), @id_params) + + resulting_state = @client.get_state(group_0_params) + + expect(resulting_state).to include('kelvin') + states_are_equal(desired_state, resulting_state) + end + + it 'changing member state mode and then changing level should preserve group 0 brightness for original mode' do + group_0_params = @id_params.merge(group_id: 0) + desired_state = { + 'status' => 'ON', + 'level' => 100, + 'hue' => 0, + 'saturation' => 100 + } + + @client.delete_state(group_0_params) + @client.patch_state(desired_state, group_0_params) + + # color -> white mode. should not have brightness because brightness will + # have been previously unknown to group 0. + @client.patch_state(desired_state.merge('color_temp' => 253, 'level' => 11), @id_params) + resulting_state = @client.get_state(group_0_params) + expect(resulting_state.keys).to_not include('level') + + # color -> effect mode. same as above + @client.patch_state(desired_state, group_0_params) + @client.patch_state(desired_state.merge('mode' => 0), @id_params) + resulting_state = @client.get_state(group_0_params) + expect(resulting_state).to_not include('level') + + # white mode -> color. + white_mode_desired_state = {'status' => 'ON', 'color_temp' => 253, 'level' => 11} + @client.patch_state(white_mode_desired_state, group_0_params) + @client.patch_state({'hue' => 10}, @id_params) + resulting_state = @client.get_state(group_0_params) + expect(resulting_state).to_not include('level') + + @client.patch_state({'hue' => 10}, group_0_params) + resulting_state = @client.get_state(group_0_params) + expect(resulting_state['level']).to eq(100) + + # white mode -> effect mode. level never set for group 0, so level should + # level should be present. + @client.patch_state(white_mode_desired_state, group_0_params) + @client.patch_state({'mode' => 0}, @id_params) + resulting_state = @client.get_state(group_0_params) + expect(resulting_state).to_not include('level') + + # effect mode -> color. same as white mode -> color + effect_mode_desired_state = {'status' => 'ON', 'mode' => 0, 'level' => 100} + @client.patch_state(effect_mode_desired_state, group_0_params) + @client.patch_state({'hue' => 10}, @id_params) + resulting_state = @client.get_state(group_0_params) + expect(resulting_state).to_not include('level') + + # effect mode -> white + @client.patch_state(effect_mode_desired_state, group_0_params) + @client.patch_state({'color_temp' => 253}, @id_params) + resulting_state = @client.get_state(group_0_params) + expect(resulting_state).to_not include('level') + end + end + + context 'fields' do + it 'should support on/off' do + @client.patch_state({status: 'on'}, @id_params) + expect(@client.get_state(@id_params)['status']).to eq('ON') + + # test "state", which is an alias for "status" + @client.patch_state({state: 'off'}, @id_params) + expect(@client.get_state(@id_params)['status']).to eq('OFF') + end + + it 'should support boolean values for status' do + # test boolean value "true", which should be the same as "ON". + @client.patch_state({status: true}, @id_params) + expect(@client.get_state(@id_params)['status']).to eq('ON') + + @client.patch_state({state: false}, @id_params) + expect(@client.get_state(@id_params)['status']).to eq('OFF') + end + + it 'should support the color field' do + desired_state = { + 'hue' => 0, + 'saturation' => 100, + 'status' => 'ON' + } + + @client.patch_state( + desired_state.merge(hue: 100), + @id_params + ) + + @client.patch_state( + { color: '255,0,0' }, + @id_params + ) + + state = @client.get_state(@id_params) + + expect(state.keys).to include(*desired_state.keys) + expect(state.select { |x| desired_state.include?(x) } ).to eq(desired_state) + + @client.patch_state( + { color: {r: 0, g: 255, b: 0} }, + @id_params + ) + state = @client.get_state(@id_params) + + desired_state.merge!('hue' => 120) + + expect(state.keys).to include(*desired_state.keys) + expect(state.select { |x| desired_state.include?(x) } ).to eq(desired_state) + end + + it 'should support hex colors' do + { + 'FF0000': 0, + '00FF00': 120, + '0000FF': 240 + }.each do |hex_color, hue| + state = @client.patch_state({status: 'ON', color: "##{hex_color}"}, @id_params) + expect(state['hue']).to eq(hue), "Hex color #{hex_color} should map to hue = #{hue}, but was #{state['hue'].inspect}" + end + end + + it 'should support getting color in hex format' do + fields = @client.get('/settings')['group_state_fields'] + @client.patch_settings({group_state_fields: fields + ['hex_color']}) + state = @client.patch_state({status: 'ON', color: '#FF0000'}, @id_params) + expect(state['color']).to eq('#FF0000') + end + + it 'should support getting color in comma-separated format' do + fields = @client.get('/settings')['group_state_fields'] + @client.patch_settings({group_state_fields: fields+['oh_color']}) + state = @client.patch_state({status: 'ON', color: '#FF0000'}, @id_params) + expect(state['color']).to eq('255,0,0') + end + + it 'should support separate brightness fields for different modes' do + desired_state = { + 'hue' => 0, + 'level' => 50 + } + + @client.patch_state(desired_state, @id_params) + result = @client.get_state(@id_params) + expect(result['bulb_mode']).to eq('color') + expect(result['level']).to eq(50) + + + @client.patch_state({'kelvin' => 100}, @id_params) + @client.patch_state({'level' => 70}, @id_params) + result = @client.get_state(@id_params) + expect(result['bulb_mode']).to eq('white') + expect(result['level']).to eq(70) + + @client.patch_state({'hue' => 0}, @id_params) + result = @client.get_state(@id_params) + expect(result['bulb_mode']).to eq('color') + # Should retain previous brightness + expect(result['level']).to eq(50) + end + + it 'should support the mode and effect fields' do + state = @client.patch_state({status: 'ON', mode: 0}, @id_params) + expect(state['effect']).to eq("0") + + state = @client.patch_state({effect: 1}, @id_params) + expect(state['effect']).to eq("1") + end + end + + context 'increment/decrement commands' do + it 'should assume state after sufficiently many down commands' do + id = @id_params.merge(type: 'cct') + @client.delete_state(id) + + @client.patch_state({status: 'on'}, id) + + expect(@client.get_state(id)).to_not include('brightness', 'kelvin') + + 10.times do + @client.patch_state( + { commands: ['level_down', 'temperature_down'] }, + id + ) + end + + state = @client.get_state(id) + expect(state).to include('level', 'kelvin') + expect(state['level']).to eq(0) + expect(state['kelvin']).to eq(0) + end + + it 'should assume state after sufficiently many up commands' do + id = @id_params.merge(type: 'cct') + @client.delete_state(id) + + @client.patch_state({status: 'on'}, id) + + expect(@client.get_state(id)).to_not include('level', 'kelvin') + + 10.times do + @client.patch_state( + { commands: ['level_up', 'temperature_up'] }, + id + ) + end + + state = @client.get_state(id) + expect(state).to include('level', 'kelvin') + expect(state['level']).to eq(100) + expect(state['kelvin']).to eq(100) + end + + it 'should affect known state' do + id = @id_params.merge(type: 'cct') + @client.delete_state(id) + + @client.patch_state({status: 'on'}, id) + + expect(@client.get_state(id)).to_not include('level', 'kelvin') + + 10.times do + @client.patch_state( + { commands: ['level_up', 'temperature_up'] }, + id + ) + end + + @client.patch_state( + { commands: ['level_down', 'temperature_down'] }, + id + ) + + state = @client.get_state(id) + expect(state).to include('level', 'kelvin') + expect(state['level']).to eq(90) + expect(state['kelvin']).to eq(90) + end + end + + context 'state updates while off' do + it 'should not affect persisted state' do + @client.patch_state({'status' => 'OFF'}, @id_params) + state = @client.patch_state({'hue' => 100}, @id_params) + + expect(state.count).to eq(1) + expect(state).to include('status') + end + + it 'should not affect persisted state using increment/decrement' do + @client.patch_state({'status' => 'OFF'}, @id_params) + + 10.times do + @client.patch_state( + { commands: ['level_down', 'temperature_down'] }, + @id_params + ) + end + + state = @client.get_state(@id_params) + + expect(state.count).to eq(1) + expect(state).to include('status') + end + end + + context 'fut089' do + # FUT089 uses the same command ID for both kelvin and saturation command, so + # interpreting such a command depends on knowledge of the state that the bulb + # is in. + it 'should keep enough group 0 state to interpret ambiguous kelvin/saturation commands as saturation commands when in color mode' do + group0_params = @id_params.merge(type: 'fut089', group_id: 0) + + (0..8).each do |group_id| + @client.delete_state(group0_params.merge(group_id: group_id)) + end + + # Patch in separate commands so state must be kept + @client.patch_state({'status' => 'ON', 'hue' => 0}, group0_params) + @client.patch_state({'saturation' => 100}, group0_params) + + (0..8).each do |group_id| + state = @client.get_state(group0_params.merge(group_id: group_id)) + expect(state['bulb_mode']).to eq('color') + expect(state['saturation']).to eq(100) + expect(state['hue']).to eq(0) + end + end + end + + context 'fut020' do + it 'should support fut020 commands' do + id = @id_params.merge(type: 'fut020', group_id: 0) + @client.delete_state(id) + state = @client.patch_state({status: 'ON'}, id) + + expect(state['status']).to eq('ON') + end + + it 'should assume the "off" command sets state to on... commands are the same' do + id = @id_params.merge(type: 'fut020', group_id: 0) + @client.delete_state(id) + state = @client.patch_state({status: 'OFF'}, id) + + expect(state['status']).to eq('ON') + end + end +end \ No newline at end of file diff --git a/test/remote/spec/transition_spec.rb b/test/remote/spec/transition_spec.rb new file mode 100644 index 00000000..e3d2df46 --- /dev/null +++ b/test/remote/spec/transition_spec.rb @@ -0,0 +1,635 @@ +require 'api_client' + +RSpec.describe 'Transitions' do + before(:all) do + @client = ApiClient.from_environment + @client.upload_json('/settings', 'settings.json') + @transition_params = { + field: 'level', + start_value: 0, + end_value: 100, + duration: 2.0, + period: 400 + } + @num_transition_updates = (@transition_params[:duration]*1000)/@transition_params[:period] + end + + before(:each) do + mqtt_params = mqtt_parameters() + @updates_topic = mqtt_params[:updates_topic] + @topic_prefix = mqtt_topic_prefix() + + @client.put( + '/settings', + mqtt_params.merge( + mqtt_update_topic_pattern: "#{@topic_prefix}updates/:device_id/:device_type/:group_id" + ) + ) + + @id_params = { + id: @client.generate_id, + type: 'rgb_cct', + group_id: 1 + } + @client.delete_state(@id_params) + + @mqtt_client = create_mqtt_client() + + # Delete any existing transitions + @client.get('/transitions')['transitions'].each do |t| + @client.delete("/transitions/#{t['id']}") + end + end + + context 'REST routes' do + it 'should respond with an empty list when there are no transitions' do + response = @client.transitions + expect(response).to eq([]) + end + + it 'should respond with an error when missing parameters for POST /transitions' do + expect { @client.post('/transitions', {}) }.to raise_error(Net::HTTPServerException) + end + + it 'should create a new transition with a valid POST /transitions request' do + response = @client.schedule_transition(@id_params, @transition_params) + + expect(response['success']).to eq(true) + end + + it 'should list active transitions' do + @client.schedule_transition(@id_params, @transition_params) + + response = @client.transitions + + expect(response.length).to be >= 1 + end + + it 'should support getting an active transition with GET /transitions/:id' do + @client.schedule_transition(@id_params, @transition_params) + + response = @client.transitions + detail_response = @client.get("/transitions/#{response.last['id']}") + + expect(detail_response['period']).to_not eq(nil) + end + + it 'should support deleting active transitions with DELETE /transitions/:id' do + @client.schedule_transition(@id_params, @transition_params) + + response = @client.transitions + + response.each do |transition| + @client.delete("/transitions/#{transition['id']}") + end + + after_delete_response = @client.transitions + + expect(response.length).to eq(1) + expect(after_delete_response.length).to eq(0) + end + end + + context '"transition" key in state update' do + it 'should create a new transition' do + @client.patch_state({status: 'ON', level: 0}, @id_params) + @client.patch_state({level: 100, transition: 2.0}, @id_params) + + response = @client.transitions + + expect(response.length).to be > 0 + expect(response.last['type']).to eq('field') + expect(response.last['field']).to eq('level') + expect(response.last['end_value']).to eq(100) + + @client.delete("/transitions/#{response.last['id']}") + end + + it 'should transition field' do + seen_updates = 0 + last_value = nil + + @client.patch_state({status: 'ON', level: 0}, @id_params) + + @mqtt_client.on_update(@id_params) do |id, msg| + if msg.include?('brightness') + seen_updates += 1 + last_value = msg['brightness'] + end + + last_value == 255 + end + + @client.patch_state({level: 100, transition: 2.0}, @id_params) + + @mqtt_client.wait_for_listeners + + expected_updates = calculate_transition_steps(start_value: 0, end_value: 255, duration: 2000) + + expect(last_value).to eq(255) + expect(seen_updates).to eq(expected_updates.length) + end + + it 'should transition a field downwards' do + seen_updates = 0 + last_value = nil + + @client.patch_state({status: 'ON'}, @id_params) + @client.patch_state({level: 100}, @id_params) + + @mqtt_client.on_update(@id_params) do |id, msg| + if msg.include?('brightness') + seen_updates += 1 + last_value = msg['brightness'] + end + + last_value == 0 + end + + @client.patch_state({level: 0, transition: 2.0}, @id_params) + + @mqtt_client.wait_for_listeners + + expected_updates = calculate_transition_steps(start_value: 0, end_value: 255, duration: 2000) + + expect(last_value).to eq(0) + expect(seen_updates).to eq(expected_updates.length) # duration of 2000ms / 450ms period + 1 for initial packet + end + + it 'should transition two fields at once if received in the same command' do + updates = {} + + @client.patch_state({status: 'ON', hue: 0, level: 100}, @id_params) + + @mqtt_client.on_update(@id_params) do |id, msg| + msg.each do |k, v| + updates[k] ||= [] + updates[k] << v + end + + updates['hue'] && updates['brightness'] && updates['hue'].last == 250 && updates['brightness'].last == 0 + end + + @client.patch_state({level: 0, hue: 250, transition: 2.0}, @id_params) + + @mqtt_client.wait_for_listeners + + expected_updates = calculate_transition_steps(start_value: 0, end_value: 250, duration: 2000) + + expect(updates['hue'].last).to eq(250) + expect(updates['brightness'].last).to eq(0) + expect(updates['hue'].length == updates['brightness'].length).to eq(true), "Should have the same number of updates for both fields" + expect(updates['hue'].length).to eq(expected_updates.length) + end + end + + context 'transition packets' do + it 'should send an initial state packet' do + seen = false + + @mqtt_client.on_update(@id_params) do |id, message| + seen = message['brightness'] == 0 + end + + @client.schedule_transition(@id_params, @transition_params) + + @mqtt_client.wait_for_listeners + + expect(seen).to be(true) + end + + it 'should respect the period parameter' do + seen_updates = [] + start_time = Time.now + + @mqtt_client.on_update(@id_params) do |id, message| + seen_updates << message + message['brightness'] == 255 + end + + @client.schedule_transition(@id_params, @transition_params.merge(duration: 2.0, period: 500)) + + @mqtt_client.wait_for_listeners + + expected_updates = calculate_transition_steps(start_value: 0, end_value: 255, period: 500, duration: 2000) + + transitions_are_equal( + expected: expected_updates, + seen: seen_updates.map { |x| x['brightness'] }, + # Allow some variation for the lossy level -> brightness conversion + allowed_variation: 2 + ) + expect((Time.now - start_time)/4).to be >= 0.5 # Don't count the first update + end + + it 'should support two transitions for different devices at the same time' do + id1 = @id_params + id2 = @id_params.merge(type: 'fut089') + + @client.schedule_transition(id1, @transition_params) + @client.schedule_transition(id2, @transition_params) + + id1_updates = [] + id2_updates = [] + + @mqtt_client.on_update do |id, msg| + if id[:type] == id1[:type] + id1_updates << msg + else + id2_updates << msg + end + id1_updates.length == @num_transition_updates && id2_updates.length == @num_transition_updates + end + + @mqtt_client.wait_for_listeners + + expect(id1_updates.length).to eq(@num_transition_updates) + expect(id2_updates.length).to eq(@num_transition_updates) + end + + it 'should assume initial state if one is not provided' do + @client.patch_state({status: 'ON', level: 0}, @id_params) + + seen_updates = [] + + @mqtt_client.on_update(@id_params) do |id, message| + seen_updates << message + message['brightness'] == 255 + end + + @client.schedule_transition(@id_params, @transition_params.reject { |x| x == :start_value }.merge(duration: 2, period: 500)) + + @mqtt_client.wait_for_listeners + + transitions_are_equal( + expected: calculate_transition_steps(start_value: 0, end_value: 255, duration: 2000, period: 500), + seen: seen_updates.map { |x| x['brightness'] }, + # Allow some variation for the lossy level -> brightness conversion + allowed_variation: 2 + ) + end + end + + context 'status transition' do + it 'should transition from off -> on' do + seen_updates = {} + @client.patch_state({status: 'OFF'}, @id_params) + + @mqtt_client.on_update(@id_params) do |id, message| + message.each do |k, v| + seen_updates[k] ||= [] + seen_updates[k] << v + end + seen_updates['brightness'] && seen_updates['brightness'].last == 255 + end + + @client.patch_state({status: 'ON', transition: 1.0}, @id_params) + + @mqtt_client.wait_for_listeners + + expect(seen_updates['state']).to eq(['ON']) + transitions_are_equal( + expected: calculate_transition_steps(start_value: 0, end_value: 255, duration: 1000), + seen: seen_updates['brightness'], + # Allow some variation for the lossy level -> brightness conversion + allowed_variation: 3 + ) + end + + it 'should transition from on -> off' do + seen_updates = {} + @client.patch_state({status: 'ON', level: 100}, @id_params) + + @mqtt_client.on_update(@id_params) do |id, message| + message.each do |k, v| + seen_updates[k] ||= [] + seen_updates[k] << v + end + seen_updates['state'] == ['OFF'] + end + + @client.patch_state({status: 'OFF', transition: 1.0}, @id_params) + + @mqtt_client.wait_for_listeners + + expect(seen_updates['state']).to eq(['OFF']) + transitions_are_equal( + expected: calculate_transition_steps(start_value: 255, end_value: 0, duration: 1000), + seen: seen_updates['brightness'], + # Allow some variation for the lossy level -> brightness conversion + allowed_variation: 3 + ) + end + + it 'should transition from off -> on from 0 to a provided brightness, event when there is a last known brightness' do + seen_updates = {} + @client.patch_state({status: 'ON', brightness: 99}, @id_params) + @client.patch_state({status: 'OFF'}, @id_params) + + @mqtt_client.on_update(@id_params) do |id, message| + message.each do |k, v| + seen_updates[k] ||= [] + seen_updates[k] << v + end + seen_updates['brightness'] && seen_updates['brightness'].last == 128 + end + + @client.patch_state({status: 'ON', brightness: 128, transition: 1.0}, @id_params) + + @mqtt_client.wait_for_listeners + + transitions_are_equal( + expected: calculate_transition_steps(start_value: 0, end_value: 128, duration: 1000), + seen: seen_updates['brightness'], + # Allow some variation for the lossy level -> brightness conversion + allowed_variation: 4 + ) + end + + it 'should transition from off -> on from 0 to 100, even when there is a last known brightness' do + seen_updates = {} + @client.patch_state({status: 'ON', brightness: 99}, @id_params) + @client.patch_state({status: 'OFF'}, @id_params) + + @mqtt_client.on_update(@id_params) do |id, message| + message.each do |k, v| + seen_updates[k] ||= [] + seen_updates[k] << v + end + seen_updates['brightness'] && seen_updates['brightness'].last == 255 + end + + @client.patch_state({status: 'ON', transition: 1.0}, @id_params) + + @mqtt_client.wait_for_listeners + + transitions_are_equal( + expected: calculate_transition_steps(start_value: 0, end_value: 255, duration: 1000), + seen: seen_updates['brightness'], + # Allow some variation for the lossy level -> brightness conversion + allowed_variation: 4 + ) + end + + it 'should transition from on -> off with known last brightness' do + seen_updates = {} + @client.patch_state({status: 'ON', brightness: 99}, @id_params) + + @mqtt_client.on_update(@id_params) do |id, message| + message.each do |k, v| + seen_updates[k] ||= [] + seen_updates[k] << v + end + seen_updates['state'] == ['OFF'] + end + + @client.patch_state({status: 'OFF', transition: 1.0}, @id_params) + + @mqtt_client.wait_for_listeners + + transitions_are_equal( + expected: calculate_transition_steps(start_value: 99, end_value: 0, duration: 1000), + seen: seen_updates['brightness'], + # Allow some variation for the lossy level -> brightness conversion + allowed_variation: 3 + ) + end + + it 'should not transition to 100% if a brightness is specified' do + seen_updates = {} + + # Set a last known level + @client.patch_state({status: 'ON', level: 0}, @id_params) + @client.patch_state({status: 'OFF'}, @id_params) + + @mqtt_client.on_update(@id_params) do |id, message| + message.each do |k, v| + seen_updates[k] ||= [] + seen_updates[k] << v + end + seen_updates['brightness'] && seen_updates['brightness'].last == 128 + end + + @client.patch_state({status: 'ON', brightness: 128, transition: 1.0}, @id_params) + + @mqtt_client.wait_for_listeners + + expect(seen_updates['state']).to eq(['ON']) + transitions_are_equal( + expected: calculate_transition_steps(start_value: 0, end_value: 128, duration: 1000), + seen: seen_updates['brightness'], + # Allow some variation for the lossy level -> brightness conversion + allowed_variation: 3 + ) + end + end + + context 'field support' do + { + 'level' => {range: [0, 100], update_field: 'brightness', update_max: 255}, + 'brightness' => {range: [0, 255]}, + 'kelvin' => {range: [0, 100], update_field: 'color_temp', update_min: 153, update_max: 370}, + 'color_temp' => {range: [153, 370]}, + 'hue' => {range: [0, 359]}, + 'saturation' => {range: [0, 100]} + }.each do |field, params| + min, max = params[:range] + update_min = params[:update_min] || min + update_max = params[:update_max] || max + update_field = params[:update_field] || field + + it "should support field '#{field}' min --> max" do + seen_updates = [] + + @client.patch_state({'status' => 'ON', field => min}, @id_params) + + @mqtt_client.on_update(@id_params) do |id, message| + seen_updates << message + message[update_field] == update_max + end + + @client.patch_state({field => max, 'transition' => 1.0}, @id_params) + + @mqtt_client.wait_for_listeners + + transitions_are_equal( + expected: calculate_transition_steps(start_value: update_min, end_value: update_max, duration: 1000), + seen: seen_updates.map{ |x| x[update_field] }, + allowed_variation: 3 + ) + end + + it "should support field '#{field}' max --> min" do + seen_updates = [] + + @client.patch_state({'status' => 'ON', field => max}, @id_params) + + @mqtt_client.on_update(@id_params) do |id, message| + seen_updates << message + message[update_field] == update_min + end + + @client.patch_state({field => min, 'transition' => 1.0}, @id_params) + + @mqtt_client.wait_for_listeners + + transitions_are_equal( + expected: calculate_transition_steps(start_value: update_max, end_value: update_min, duration: 1000), + seen: seen_updates.map{ |x| x[update_field] }, + allowed_variation: 3 + ) + end + end + end + + context 'color support' do + it 'should support color transitions' do + response = @client.schedule_transition(@id_params, { + field: 'color', + start_value: '255,0,0', + end_value: '0,255,0', + duration: 1.0, + period: 500 + }) + expect(response['success']).to eq(true) + end + + it 'should smoothly transition from one color to another' do + seen_updates = [] + + end_color = '0,255,0' + end_hs = rgb_to_hs(end_color) + + last_hue = nil + last_sat = nil + + @mqtt_client.on_update(@id_params) do |id, message| + field, value = message.first + + if field == 'hue' + last_hue = value + elsif field == 'saturation' + last_sat = value + end + + if !last_hue.nil? && !last_sat.nil? + seen_updates << {hue: last_hue, saturation: last_sat} + end + + last_hue == end_hs[:hue] && last_sat == end_hs[:saturation] + end + + response = @client.schedule_transition(@id_params, { + field: 'color', + start_value: '255,0,0', + end_value: '0,255,0', + duration: 4.0, + period: 1000 + }) + + @mqtt_client.wait_for_listeners + + # This ends up being less even than you'd expect because RGB -> Hue/Sat is lossy. + # Raw logs show that the right thing is happening: + # + # >>> stepSizes = (-64,64,0) + # >>> start = (255,0,0) + # >>> end = (0,255,0) + # >>> current color = (191,64,0) + # >>> current color = (127,128,0) + # >>> current color = (63,192,0) + # >>> current color = (0,255,0) + expected_updates = calculate_color_transition_steps(start_color: '255,0,0', end_color: '0,255,0', duration: 4000, period: 1000) + + color_transitions_are_equal( + expected: expected_updates, + seen: seen_updates + ) + end + + it 'should handle color transitions from known state' do + seen_updates = [] + + @client.patch_state({status: 'ON', color: '255,0,0'}, @id_params) + end_color = '0,0,255' + end_hs = rgb_to_hs(end_color) + + last_hue = nil + last_sat = nil + + @mqtt_client.on_update(@id_params) do |id, message| + field, value = message.first + + if field == 'hue' + last_hue = value + elsif field == 'saturation' + last_sat = value + end + + if !last_hue.nil? && !last_sat.nil? + seen_updates << {hue: last_hue, saturation: last_sat} + end + + last_hue == end_hs[:hue] && last_sat == end_hs[:saturation] + end + + @client.patch_state({color: '0,0,255', transition: 2.0}, @id_params) + + @mqtt_client.wait_for_listeners + + expected_updates = calculate_color_transition_steps(start_color: '255,0,0', end_color: '0,0,255', duration: 2000) + + color_transitions_are_equal( + expected: expected_updates, + seen: seen_updates + ) + end + end + + context 'computed parameters' do + (@transition_defaults = { + duration: {default: 4.5, test: 2}, + num_periods: {default: 10, test: 5}, + period: {default: 450, test: 225} + }).each do |k, params| + it "it should compute other parameters given only #{k}" do + seen_values = 0 + gap = 0 + + @mqtt_client.on_update(@id_params) do |id, msg| + val = msg['brightness'] + + if val > 0 + seen_values += 1 + last_seen = val + end + + if seen_values == 3 + gap = last_seen/seen_values + end + + val == 255 + end + + t_params = {field: 'level', start_value: 0, end_value: 100}.merge({k => params[:test]}) + + start_time = Time.now + + @client.schedule_transition(@id_params, t_params) + transitions = @client.transitions + + @mqtt_client.wait_for_listeners + duration = Time.now - start_time + + expect(transitions.length).to eq(1), "Should only be one active transition" + + period = transitions.first['period'] + expected_duration = (k == :duration ? params[:test] : (TransitionHelpers::Defaults::DURATION/1000.0)) + num_periods = (expected_duration/period.to_f)*1000 + + expect(duration).to be_within(1.5).of(expected_duration) + expect(gap).to be_within(10).of((255/num_periods).ceil) + end + end + end +end \ No newline at end of file diff --git a/test/remote/spec/udp_spec.rb b/test/remote/spec/udp_spec.rb new file mode 100644 index 00000000..3b6e8424 --- /dev/null +++ b/test/remote/spec/udp_spec.rb @@ -0,0 +1,151 @@ +require 'api_client' +require 'milight' + +RSpec.describe 'UDP servers' do + before(:all) do + @host = ENV.fetch('ESPMH_HOSTNAME') + @client = ApiClient.new(@host, ENV.fetch('ESPMH_TEST_DEVICE_ID_BASE')) + @client.upload_json('/settings', 'settings.json') + + @client.patch_settings( mqtt_parameters() ) + @client.patch_settings( mqtt_update_topic_pattern: '' ) + end + + before(:each) do + @id_params = { + id: @client.generate_id, + type: 'rgbw', + group_id: 1 + } + @v6_id_params = { + id: @client.generate_id, + type: 'rgbw', + group_id: 1 + } + @client.delete_state(@id_params) + + @v5_udp_port = ENV.fetch('ESPMH_V5_UDP_PORT') + @v6_udp_port = ENV.fetch('ESPMH_V6_UDP_PORT') + @discovery_port = ENV.fetch('ESPMH_DISCOVERY_PORT') + + @client.patch_settings( + gateway_configs: [ + [ + @id_params[:id], # device ID + @v5_udp_port, + 5 # protocol version (gem uses v5) + ], + [ + @v6_id_params[:id], # device ID + @v6_udp_port, + 6 # protocol version + ] + ] + ) + @udp_client = Milight::Controller.new(ENV.fetch('ESPMH_HOSTNAME'), @v5_udp_port) + @mqtt_client = create_mqtt_client() + end + + context 'on/off commands' do + it 'should result in state changes' do + @udp_client.group(@id_params[:group_id]).on + + # Wait for packet to be processed + sleep 1 + + state = @client.get_state(@id_params) + expect(state['status']).to eq('ON') + + @udp_client.group(@id_params[:group_id]).off + + # Wait for packet to be processed + sleep 1 + + state = @client.get_state(@id_params) + expect(state['status']).to eq('OFF') + end + + it 'should result in an MQTT update' do + desired_state = { + 'status' => 'ON', + 'level' => 48 + } + seen_state = false + + @mqtt_client.on_state(@id_params) do |id, message| + seen_state = desired_state.all? { |k,v| v == message[k] } + end + @udp_client.group(@id_params[:group_id]).on.brightness(48) + @mqtt_client.wait_for_listeners + + expect(seen_state).to eq(true) + end + end + + context 'color and brightness commands' do + it 'should result in state changes' do + desired_state = { + 'status' => 'ON', + 'level' => 48, + 'hue' => 357 + } + seen_state = false + + @mqtt_client.on_state(@id_params) do |id, message| + seen_state = desired_state.all? { |k,v| v == message[k] } + end + + @udp_client.group(@id_params[:group_id]) + .on + .colour('#ff0000') + .brightness(48) + + @mqtt_client.wait_for_listeners + + expect(seen_state).to eq(true) + end + end + + context 'discovery' do + before(:all) do + @client.patch_settings( + discovery_port: ENV.fetch('ESPMH_DISCOVERY_PORT') + ) + + @discovery_host = '' + + @discovery_socket = UDPSocket.new + @discovery_socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_BROADCAST, true) + @discovery_socket.bind('0.0.0.0', 0) + end + + it 'should respond to v5 discovery' do + @discovery_socket.send('Link_Wi-Fi', 0, @discovery_host, @discovery_port) + + # wait for response + sleep 1 + + response, _ = @discovery_socket.recvfrom_nonblock(1024) + response = response.split(',') + + expect(response.length).to eq(2), "Should be a comma-separated list with two elements" + expect(response[0]).to eq(@host) + expect(response[1].to_i(16)).to eq(@id_params[:id]) + end + + it 'should respond to v6 discovery' do + @discovery_socket.send('HF-A11ASSISTHREAD', 0, @host, @discovery_port) + + # wait for response + sleep 1 + + response, _ = @discovery_socket.recvfrom_nonblock(1024) + response = response.split(',') + + expect(response.length).to eq(3), "Should be a comma-separated list with three elements" + expect(response[0]).to eq(@host) + expect(response[1].to_i(16)).to eq(@v6_id_params[:id]) + expect(response[2]).to eq('HF-LPB100') + end + end +end \ No newline at end of file diff --git a/web/src/css/style.css b/web/src/css/style.css index 591a55ee..100987be 100644 --- a/web/src/css/style.css +++ b/web/src/css/style.css @@ -165,3 +165,6 @@ ul.action-buttons { from { -webkit-transform: rotate(0deg); } to { -webkit-transform: rotate(360deg); } } + +.selectize-delete { float: right; } +.c-selectize-item { margin: 0 1em; } \ No newline at end of file diff --git a/web/src/index.html b/web/src/index.html index 57e9d599..3ec73fb1 100644 --- a/web/src/index.html +++ b/web/src/index.html @@ -9,7 +9,7 @@ - +
-
+
Hue
@@ -247,7 +240,7 @@
Commands
  • -
    +
  • @@ -270,7 +263,7 @@
    Commands

      -
      +
    • +
    • -
      +
    @@ -478,6 +496,7 @@

    Latest Version

    "; }); - + // UDP gateways tab settings += '
    '; settings += $('#gateway-servers-modal .modal-body').remove().html(); @@ -870,8 +1238,9 @@ $(function() { if ($('#tab-udp-gateways').hasClass('active')) { saveGatewayConfigs(); } else { - var obj = $('#settings') - .serializeArray() + var obj = $('#settings').serializeArray(); + + obj = obj .reduce(function(a, x) { var val = a[x.name]; @@ -884,15 +1253,11 @@ $(function() { } return a; - }, {}); - - // pretty hacky. whatever. - obj.device_ids = _.map( - $('.selectize-control .option'), - function(x) { - return $(x).data('value') - } - ); + }, + { + // Make sure the value is always an array, even if a single item is selected + rf24_channels: [] + }); // Make sure we're submitting a value for group_state_fields (will be empty // if no values were selected). @@ -909,7 +1274,7 @@ $(function() { } $('#settings-modal').modal('hide'); - + return false; }); @@ -936,3 +1301,33 @@ $(function() { loadSettings(); updateModeOptions(); }); + +$(function() { + $(document).on('change', ':file', function() { + var input = $(this), + label = input.val().replace(/\\/g, '/').replace(/.*\//, ''); + input.trigger('fileselect', [label]); + }); + + $(document).on('change', '#deviceAliases', function() { + var selectedValue = aliasesSelectize.getValue() + , selectizeItem = aliasesSelectize.options[selectedValue] + ; + + if (selectizeItem && !updatingAlias) { + updateGroupId(selectizeItem.savedGroupParams); + } + }); + + $(document).ready( function() { + $(':file').on('fileselect', function(event, label) { + + var input = $(this).parents('.input-group').find(':text'), + log = label; + + if( input.length ) { + input.val(log); + } + }); + }); +}); \ No newline at end of file