commit 9ed0e801b72789f060c4e510c439d68a086d525b Author: Carsten Schmiemann Date: Fri Jan 15 22:49:01 2021 +0100 First commit diff --git a/.build_web.py b/.build_web.py new file mode 100644 index 0000000..322099e --- /dev/null +++ b/.build_web.py @@ -0,0 +1,45 @@ +from shutil import copyfile +from subprocess import check_output, CalledProcessError +import sys +import os +import platform +import subprocess + +Import("env") + +def is_tool(name): + cmd = "where" if platform.system() == "Windows" else "which" + try: + check_output([cmd, name]) + return True + except: + return False; + +def build_web(): + if is_tool("npm"): + os.chdir("web") + print("Attempting to build webpage...") + try: + if platform.system() == "Windows": + print(check_output(["npm.cmd", "install", "--only=dev"])) + print(check_output(["node_modules\\.bin\\gulp.cmd"])) + else: + print(check_output(["npm", "install"])) + print(check_output(["node_modules/.bin/gulp"])) + copyfile("build/index.html.gz.h", "../dist/index.html.gz.h") + except OSError as e: + print("Encountered error OSError building webpage:", e) + if e.filename: + print("Filename is", e.filename) + print("WARNING: Failed to build web package. Using pre-built page.") + except CalledProcessError as e: + print(e.output) + print("Encountered error CalledProcessError building webpage:", e) + print("WARNING: Failed to build web package. Using pre-built page.") + except Exception as e: + print("Encountered error", type(e).__name__, "building webpage:", e) + print("WARNING: Failed to build web package. Using pre-built page.") + finally: + os.chdir(".."); + +build_web() diff --git a/.get_version.py b/.get_version.py new file mode 100755 index 0000000..3f3f510 --- /dev/null +++ b/.get_version.py @@ -0,0 +1,31 @@ +from subprocess import check_output +import sys +import os +import platform +import subprocess + +dir_path = os.path.dirname(os.path.realpath(__file__)) +os.chdir(dir_path) + +# http://stackoverflow.com/questions/11210104/check-if-a-program-exists-from-a-python-script +def is_tool(name): + cmd = "where" if platform.system() == "Windows" else "which" + try: + check_output([cmd, name]) + return True + except: + return False + +version = "UNKNOWN".encode() + +if is_tool("git"): + try: + version = check_output(["git", "describe", "--always"]).rstrip() + except: + try: + version = check_output(["git", "rev-parse", "--short", "HEAD"]).rstrip() + except: + pass + pass + +sys.stdout.write("-DMILIGHT_HUB_VERSION=%s %s" % (version.decode('utf-8'), ' '.join(sys.argv[1:]))) \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..f91aeb3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,41 @@ +--- +name: Bug report +about: Report an issue or unexpected behavior +title: '' +labels: bug +assignees: '' + +--- + +### Describe the bug + + + +### Steps to reproduce + + + +### Expected behavior + + + +### Setup information + +#### Firmware version + + +#### Output of http://milight-hub.local/about + +```json +"... /about output. put between the ```s" +``` + +#### Output of http://milight-hub.local/settings + + + +```json +"... /settings output. put between the ```s" +``` + +### Additional context diff --git a/.github/ISSUE_TEMPLATE/feature-request-or-general-question.md b/.github/ISSUE_TEMPLATE/feature-request-or-general-question.md new file mode 100644 index 0000000..232989f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request-or-general-question.md @@ -0,0 +1,10 @@ +--- +name: Feature request or general question +about: Suggest a new idea or ask a question +title: '' +labels: '' +assignees: '' + +--- + + diff --git a/.github/ISSUE_TEMPLATE/problem-with-new-setup-or-device-compatibility.md b/.github/ISSUE_TEMPLATE/problem-with-new-setup-or-device-compatibility.md new file mode 100644 index 0000000..d5ef75c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/problem-with-new-setup-or-device-compatibility.md @@ -0,0 +1,51 @@ +--- +name: Problem with new setup or device compatibility +about: Help troubleshooting a setup that's not working +title: '' +labels: setup-quesetion +assignees: '' + +--- + + + +### What is the model number of the device you're trying to control? + + +### What firmware version(s) have you tried? + +### Which ESP8266 board are you using? (nodemcu, d1_mini, etc.) + +### Which radio type are you using? (RGBW, RGB+CCT, etc.) + +### Have you tried controlling the device with a physical remote? + + + +### Output of http://milight-hub.local/about and http://milight-hub.local/settings + + + +#### /about + +```json +"... /about output. put between the ```s" +``` + +#### /settings + +```json +"... /settings output. put between the ```s" +``` diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7748386 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +.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 diff --git a/.prepare_docs b/.prepare_docs new file mode 100755 index 0000000..be9d67c --- /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 new file mode 100755 index 0000000..83122e3 --- /dev/null +++ b/.prepare_release @@ -0,0 +1,31 @@ +#!/bin/bash + +set -eo pipefail + +prepare_log() { + echo "[prepare release] -- $@" +} + +if [ -z "$(git tag -l --points-at HEAD)" ]; then + prepare_log "Skipping non-tagged commit." + exit 0 +fi + +VERSION=$(git describe) + +prepare_log "Preparing release for tagged version: $VERSION" + +mkdir -p dist + +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") + + cp "$file" "dist/esp8266_milight_hub_${env}-${VERSION}.bin" +done diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..19332bf --- /dev/null +++ b/.travis.yml @@ -0,0 +1,41 @@ +language: python +python: +- '3.7' +sudo: false +cache: + directories: + - "~/.platformio" +env: + - NODE_VERSION="10" +before_install: + - nvm install $NODE_VERSION +install: +- pip3 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: pages + skip_cleanup: true + local_dir: dist/docs + github_token: $GITHUB_TOKEN + keep_history: true + on: + repo: sidoh/esp8266_milight_hub + tags: true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..35e397f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Chris Mullins + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a45175 --- /dev/null +++ b/README.md @@ -0,0 +1,325 @@ +# esp8266_milight_hub [![Build Status](https://travis-ci.org/sidoh/esp8266_milight_hub.svg?branch=master)](https://travis-ci.org/sidoh/esp8266_milight_hub) [![License][shield-license]][info-license] + +This is a replacement for a Milight/LimitlessLED remote/gateway hosted on an ESP8266. Leverages [Henryk Plötz's awesome reverse-engineering work](https://hackaday.io/project/5888-reverse-engineering-the-milight-on-air-protocol). + +[Milight bulbs](https://www.amazon.com/Mi-light-Dimmable-RGBWW-Spotlight-Smart/dp/B01LPRQ4BK/r) are cheap smart bulbs that are controllable with an undocumented 2.4 GHz protocol. In order to control them, you either need a [remote](https://www.amazon.com/Mi-light-Dimmable-RGBWW-Spotlight-Smart/dp/B01LCSALV6/r?th=1) ($13), which allows you to control them directly, or a [WiFi gateway](http://futlight.com/productlist.aspx?typeid=125) ($30), which allows you to control them with a mobile app or a [UDP protocol](https://github.com/Fantasmos/LimitlessLED-DevAPI). + +This project is a replacement for the wifi gateway. + +[This guide](http://blog.christophermullins.com/2017/02/11/milight-wifi-gateway-emulator-on-an-esp8266/) on my blog details setting one of these up. + +## Why this is useful + +1. Both the remote and the WiFi gateway are limited to four groups. This means if you want to control more than four groups of bulbs, you need another remote or another gateway. This project allows you to control 262,144 groups (4*2^16, the limit imposed by the protocol). +2. This project exposes a nice REST API to control your bulbs. +3. You can secure the ESP8266 with a username/password, which is more than you can say for the Milight gateway! (The 2.4 GHz protocol is still totally insecure, so this doesn't accomplish much :). +4. Official hubs connect to remote servers to enable WAN access, and this behavior is not disableable. +5. This project is capable of passively listening for Milight packets sent from other devices (like remotes). It can publish data from intercepted packets to MQTT. This could, for example, allow the use of Milight remotes while keeping your home automation platform's state in sync. See the MQTT section for more detail. + +## Supported remotes + +The following remotes can be emulated: + +Support has been added for the following [bulb types](http://futlight.com/productlist.aspx?typeid=101): + +Model #|Name|Compatible Bulbs +-------|-----------|---------------- +|FUT096|RGB/W|
  1. FUT014
  2. FUT016
  3. FUT103
  4. | +|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. + +## What you'll need + +1. An ESP8266. I used a NodeMCU. +2. A NRF24L01+ module (~$3 on ebay). Alternatively, you can use a LT8900. +3. Some way to connect the two (7 female/female dupont cables is probably easiest). + +## Installing + +#### Connect the NRF24L01+ / LT8900 + +This project is compatible with both NRF24L01 and LT8900 radios. LT8900 is the same model used in the official MiLight devices. NRF24s are a very common 2.4 GHz radio device, but require software emulation of the LT8900's packet structure. As such, the LT8900 is more performant. + +Both modules are SPI devices and should be connected to the standard SPI pins on the ESP8266. + +##### NRF24L01+ + + +[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 (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 + +The goal here is to flash your ESP with the firmware. It's really easy to do this with [PlatformIO](http://platformio.org/): + +``` +export ESP_BOARD=nodemcuv2 +platformio run -e $ESP_BOARD --target upload +``` + +Of course make sure to substitute `nodemcuv2` with the board that you're using. + +You can find pre-compiled firmware images on the [releases](https://github.com/sidoh/esp8266_milight_hub/releases). + +#### Configure WiFi + +This project uses [WiFiManager](https://github.com/tzapu/WiFiManager) to avoid the need to hardcode AP credentials in the firmware. + +When the ESP powers on, you should be able to see a network named "ESPXXXXX", with XXXXX being an identifier for your ESP. Connect to this AP and a window should pop up prompting you to enter WiFi credentials. If your board has a built-in LED (or you wire up an LED), it will [flash to indicate the status](#led-status). + +The network password is "**milightHub**". + +#### Get IP Address + +Both mDNS and SSDP are supported. + +* OS X - you should be able to navigate to http://milight-hub.local. +* Windows - you should see a device called "ESP8266 MiLight Gateway" show up in your network explorer. +* Linux users can install [avahi](http://www.avahi.org/) (`sudo apt-get install avahi-daemon` on Ubuntu), and should then be able to navigate to http://milight-hub.local. + +#### Use it! + +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/61682228-a8151700-acc5-11e9-8b86-1e21efa6cdbe.png) + + +If it does not work as expected see [Troubleshooting](https://github.com/sidoh/esp8266_milight_hub/wiki/Troubleshooting). + +#### Pair Bulbs + +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). + +## Device Aliases + +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: + +* **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. + +## REST API + +The REST API is specified using the [OpenAPI v3](https://swagger.io/docs/specification/about/) specification. + +[openapi.yaml](docs/openapi.yaml) contains the raw spec. + +[You can view generated documentation for the master branch here.](https://sidoh.github.io/esp8266_milight_hub/branches/latest) + +[Docs for other branches can be found here](https://sidoh.github.io/esp8266_milight_hub) + +## MQTT + +To configure your ESP to integrate with MQTT, fill out the following settings: + +1. `mqtt_server`- IP or hostname should work. Specify a port with standard syntax (e.g., "mymqttbroker.com:1884"). +1. `mqtt_topic_pattern` - you can control arbitrary configurations of device ID, device type, and group ID with this. A good default choice is something like `milight/:device_id/:device_type/:group_id`. More detail is provided below. +1. (optionally) `mqtt_username` +1. (optionally) `mqtt_password` + +#### More detail on `mqtt_topic_pattern` + +`mqtt_topic_pattern` leverages single-level wildcards (documented [here](https://mosquitto.org/man/mqtt-7.html)). For example, specifying `milight/:device_id/:device_type/:group_id` will cause the ESP to subscribe to the topic `milight/+/+/+`. It will then interpret the second, third, and fourth tokens in topics it receives messages on as `:device_id`, `:device_type`, and `:group_id`, respectively. The following tokens are available: + +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](https://sidoh.github.io/esp8266_milight_hub/branches/latest/#tag/Device-Control/paths/~1gateways~1{device-id}~1{remote-type}~1{group-id}/put) uses for the `/gateways/:device_id/:device_type/:group_id` endpoint. + +#### Example: + +If `mqtt_topic_pattern` is set to `milight/:device_id/:device_type/:group_id`, you could send the following message to it (the below example uses a ruby MQTT client): + +```ruby +irb(main):001:0> require 'mqtt' +irb(main):002:0> client = MQTT::Client.new('10.133.8.11',1883) +irb(main):003:0> client.connect +irb(main):004:0> client.publish('milight/0x118D/rgb_cct/1', '{"status":"ON","color":{"r":255,"g":200,"b":255},"brightness":100}') +``` + +This will instruct the ESP to send messages to RGB+CCT bulbs with device ID `0x118D` in group 1 to turn on, set color to RGB(255,200,255), and brightness to 100. + +#### Updates + +ESPMH is capable of providing two types of updates: + +1. Delta: as packets are received, they are translated into the corresponding command (e.g., "set brightness to 50"). The translated command is sent as an update. +2. State: When an update is received, the corresponding command is applied to known group state, and the whole state for the group is transmitted. + +##### Delta updates + +To publish data from intercepted packets to an MQTT topic, configure MQTT server settings, and set the `mqtt_update_topic_pattern` to something of your choice. As with `mqtt_topic_pattern`, the tokens `:device_id`, `:device_type`, and `:group_id` will be substituted with the values from the relevant packet. `:device_id` will always be substituted with the hexadecimal value of the ID. You can also use `:hex_device_id`, or `:dec_device_id` if you prefer decimal. + +The published message is a JSON blob containing the state that was changed. + +As an example, if `mqtt_update_topic_pattern` is set to `milight/updates/:hex_device_id/:device_type/:group_id`, and the group 1 on button of a Milight remote is pressed, the following update will be dispatched: + +```ruby +irb(main):005:0> client.subscribe('milight/updates/+/+/+') +=> 27 +irb(main):006:0> puts client.get.inspect +["lights/updates/0x1C8E/rgb_cct/1", "{\"status\":\"on\"}"] +``` + +##### Full state updates + +For this mode, `mqtt_state_topic_pattern` should be set to something like `milight/states/:hex_device_id/:device_type/:group_id`. As an example: + +```ruby +irb(main):005:0> client.subscribe('milight/states/+/+/+') +=> 27 +irb(main):006:0> puts client.get.inspect +["lights/states/0x1C8E/rgb_cct/1", "{\"state\":\"ON\",\"brightness\":255,\"color_temp\":370,\"bulb_mode\":\"white\"}"] +irb(main):007:0> puts client.get.inspect +["lights/states/0x1C8E/rgb_cct/1", "{\"state\":\"ON\",\"brightness\":100,\"color_temp\":370,\"bulb_mode\":\"white\"}"] +``` + +**Make sure that `mqtt_topic_pattern`, `mqtt_state_topic_pattern`, and `matt_update_topic_pattern` are all different!** If they are they same you can put your ESP in a loop where its own updates trigger an infinite command loop. + +##### 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, but are all documented in the REST API spec under `GroupStateField`. + +#### Client Status + +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 + +You can add an arbitrary number of UDP gateways through the REST API or through the web UI. Each gateway server listens on a port and responds to the standard set of commands supported by the Milight protocol. This should allow you to use one of these with standard Milight integrations (SmartThings, Home Assistant, OpenHAB, etc.). + +You can select between versions 5 and 6 of the UDP protocol (documented [here](https://github.com/BKrajancic/LimitlessLED-DevAPI/)). 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, D1 is actually GPIO5 and therefore its pin 5. If you specify the pin as a negative number, it will invert the LED signal (the built-in LED on pin 2 (D4) is inverted, so the default is -2). + +If you want to wire up your own LED you can connect it to D1/GPIO5. Put a wire from D1 to one side of a 220 ohm resistor. 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. + +Another option is to use an external LED parallel to the (inverted) internal one, this way it will mirror the internal LED without configuring a new LED pin in the UI. To do this connect the (short) GND pin of your LED to D4. The longer one to a 220 ohm resistor and finally the other side of the resistor to a 3V3 pin. + +## 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). + +## Ready-Made Hub + +h4nc (h4nc.zigbee(a)gmail.com) created a PCB and 3D-printable case for espMH. He's offering ready-made versions. Please get in touch with him at the aforementioned email address for further information. + +Find more information from the [espmh_pcb](https://github.com/sidoh/espmh_pcb) repository. + +## Acknowledgements + +* @WoodsterDK added support for LT8900 radios. +* @cmidgley contributed many substantial features to the 1.7 release. + +[info-license]: https://github.com/sidoh/esp8266_milight_hub/blob/master/LICENSE +[shield-license]: https://img.shields.io/badge/license-MIT-blue.svg + +## Donating + +If the project brings you happiness or utility, it's more than enough for me to hear those words. + +If you're feeling especially generous, and are open to a charitable donation, that'd make me very happy. Here are some whose mission I support (in no particular order): + +* [Water.org](https://www.water.org) +* [Brain & Behavior Research Foundation](https://www.bbrfoundation.org/) +* [Electronic Frontier Foundation](https://www.eff.org/) +* [Girls Who Code](https://girlswhocode.com/) +* [San Francisco Animal Care & Control](http://www.sfanimalcare.org/make-a-donation/) diff --git a/dist/index.html.gz.h b/dist/index.html.gz.h new file mode 100644 index 0000000..1890f00 --- /dev/null +++ b/dist/index.html.gz.h @@ -0,0 +1,2 @@ +#define index_html_gz_len 12910 +static const char index_html_gz[] PROGMEM = {31,139,8,0,0,0,0,0,0,3,237,125,107,123,219,54,178,240,247,247,87,32,76,55,150,106,138,146,175,113,100,83,57,242,37,137,91,219,113,98,167,217,174,215,71,15,37,65,18,99,138,212,146,148,101,215,213,127,127,103,6,0,9,94,100,43,217,236,158,158,158,77,159,90,36,1,2,51,131,193,220,48,0,247,158,245,131,94,124,63,225,108,20,143,189,214,30,254,101,158,227,15,109,131,251,6,220,115,167,223,218,27,243,216,97,189,145,19,70,60,182,141,105,60,168,237,64,89,236,198,30,111,157,186,39,238,112,20,179,119,211,238,94,93,60,218,243,92,255,134,133,220,179,141,40,190,247,120,52,226,60,54,216,40,228,3,219,24,197,241,36,106,214,235,99,231,174,215,247,173,110,16,196,81,28,58,19,188,233,5,227,122,242,160,190,97,109,88,47,235,189,40,74,159,89,99,23,106,69,145,193,168,39,219,24,59,46,130,73,29,102,219,31,186,49,54,9,63,163,105,215,114,131,180,145,90,28,12,135,30,175,175,91,240,95,182,125,89,148,118,147,71,162,180,43,232,231,75,100,245,188,96,218,31,120,78,200,9,15,231,139,115,87,247,220,174,222,122,228,185,125,30,214,95,89,47,173,70,174,99,81,244,125,59,142,184,199,123,177,251,27,183,190,68,245,134,181,182,110,109,83,175,233,243,164,255,141,127,25,202,212,87,125,13,123,223,204,227,76,101,143,245,76,140,231,59,99,24,233,91,151,207,38,65,8,124,212,11,252,152,251,192,136,51,183,31,143,236,62,191,117,123,188,70,55,166,235,187,177,235,120,181,168,231,0,123,172,65,19,207,106,181,43,119,192,188,152,29,31,177,87,215,173,255,199,224,223,94,212,11,221,73,204,162,176,183,52,78,56,53,182,162,145,123,11,140,249,210,218,72,239,129,186,208,79,93,52,137,237,239,61,187,226,126,223,29,92,215,106,173,61,194,167,101,225,68,226,97,45,12,102,15,221,32,196,203,110,16,199,193,184,185,54,185,99,81,0,163,207,158,247,122,189,185,231,116,185,247,208,119,163,137,231,220,55,187,94,208,187,153,91,161,211,119,131,90,48,137,221,192,127,152,56,253,190,235,15,155,13,182,53,185,219,237,77,195,40,8,155,147,192,5,162,132,115,132,121,236,248,253,90,119,10,173,251,209,131,231,70,113,141,96,104,250,129,207,119,199,78,56,116,253,102,99,55,105,166,240,14,243,220,4,0,215,135,193,231,53,130,67,190,91,11,113,190,55,215,248,120,55,184,229,225,192,11,102,77,103,26,7,115,107,16,132,227,26,140,76,120,255,160,186,97,13,182,222,0,12,27,122,41,19,215,56,142,97,224,61,208,200,53,215,27,124,156,169,148,165,132,14,136,32,18,224,19,87,44,30,134,65,88,101,226,183,230,250,131,32,121,5,241,157,235,5,189,192,3,82,133,188,191,59,128,174,107,17,204,128,166,245,18,187,77,43,53,187,28,96,224,15,146,201,154,43,149,149,76,177,51,0,50,167,165,85,40,149,67,219,141,125,133,54,162,60,183,134,78,204,103,206,125,13,8,77,133,64,41,39,110,18,245,230,207,101,11,204,234,135,193,164,31,204,96,92,131,200,197,1,110,74,30,206,81,183,248,70,109,204,253,169,62,88,130,56,207,97,110,13,6,110,175,22,249,238,96,144,165,198,115,122,198,251,53,89,7,0,190,171,141,56,13,232,86,67,27,209,218,189,28,83,213,217,99,195,97,137,187,5,133,179,145,27,243,90,204,199,147,218,196,237,221,0,245,100,135,235,208,95,215,233,221,12,195,96,234,247,155,248,142,19,214,134,200,236,128,103,37,14,24,209,202,124,238,108,247,215,6,3,214,48,159,15,224,103,171,241,23,188,112,26,141,6,91,107,52,254,82,221,45,101,86,197,223,27,130,251,70,83,94,210,189,96,61,108,101,55,55,229,210,250,48,238,126,217,91,32,97,122,21,124,149,213,216,6,31,47,0,35,59,63,151,194,23,24,84,224,10,248,109,91,219,219,219,47,1,225,6,220,109,128,70,132,127,116,39,233,208,104,12,216,118,82,105,0,119,59,170,18,54,67,244,17,200,220,58,30,252,149,48,74,25,164,11,31,32,167,154,224,30,31,196,77,107,43,193,20,113,214,208,47,31,230,137,55,141,106,32,202,225,47,34,56,121,200,176,175,78,233,158,7,120,55,65,252,141,242,68,207,183,193,132,68,106,14,220,16,164,88,48,168,161,165,146,149,159,66,26,213,144,136,211,8,196,154,44,140,131,73,190,68,76,63,196,109,217,110,61,103,97,175,216,76,105,167,217,130,167,58,178,200,140,81,109,75,26,33,195,170,38,133,200,66,205,160,158,8,89,78,131,150,240,56,40,2,166,254,127,9,74,33,17,242,52,64,114,232,214,172,205,87,101,68,24,120,252,110,215,241,220,161,95,131,137,58,142,154,61,46,84,201,192,229,94,31,222,246,38,229,234,32,21,162,107,214,58,114,75,158,125,64,4,122,247,147,145,11,146,203,138,38,48,141,0,214,7,199,119,199,14,201,57,124,196,214,34,6,114,21,69,30,103,98,70,236,214,102,188,123,227,198,181,108,205,245,146,170,115,212,193,169,224,4,251,1,234,223,130,146,3,40,20,214,192,113,243,110,208,87,42,73,169,221,245,6,202,232,1,216,34,218,220,166,103,255,53,230,125,215,97,129,239,221,51,208,233,156,251,12,148,35,171,96,163,52,66,172,185,129,2,190,250,80,214,236,22,54,177,76,27,155,59,11,219,104,44,219,198,203,237,157,5,109,172,109,237,44,217,198,171,87,235,139,218,88,219,198,54,172,113,208,7,139,10,43,48,43,66,195,49,240,107,25,190,45,90,50,156,115,197,156,170,212,2,209,33,245,15,75,249,162,70,210,72,114,249,246,96,187,164,70,4,211,74,213,24,108,111,207,167,158,229,8,32,22,217,56,154,105,147,173,185,200,178,41,169,71,214,133,20,60,189,145,235,245,171,15,5,246,254,175,27,126,63,8,193,58,141,24,114,232,195,32,12,198,15,160,89,253,8,205,152,38,217,160,149,181,42,11,131,24,76,129,74,163,58,143,131,71,202,55,182,27,125,62,172,194,168,169,25,144,109,127,93,116,160,10,211,134,50,237,47,44,78,154,183,18,251,191,214,135,171,152,103,44,19,171,87,75,203,81,32,164,182,28,152,124,115,176,115,201,160,213,157,130,190,19,59,77,152,171,67,94,191,171,225,152,129,154,139,248,246,166,217,110,183,247,219,237,163,246,17,252,197,223,131,246,126,176,255,161,221,126,51,132,219,3,252,211,254,128,127,142,219,170,92,253,59,106,103,255,101,239,235,27,59,103,237,253,195,213,131,217,97,253,203,135,168,253,106,242,233,125,123,255,227,180,14,247,239,62,204,218,171,195,243,176,125,190,121,218,109,183,255,182,51,216,105,31,220,117,95,193,253,219,155,217,193,231,122,59,104,31,254,250,101,181,125,190,245,234,224,195,225,203,55,237,155,246,65,189,238,183,127,250,245,231,250,135,246,135,250,108,179,221,27,222,214,219,231,47,215,14,102,7,23,135,231,59,237,213,246,205,175,112,31,2,26,103,245,213,70,187,126,119,212,107,159,175,133,7,179,246,105,61,184,105,31,14,86,223,181,143,235,47,1,165,147,250,86,212,62,60,127,181,213,110,55,62,1,60,155,245,33,220,255,53,92,5,74,188,175,15,15,189,131,58,180,119,219,56,107,183,55,234,71,179,253,205,35,128,111,103,22,190,108,159,55,142,57,180,255,242,124,179,221,29,236,156,180,219,211,159,234,179,131,95,14,111,161,254,168,247,182,125,240,110,245,112,184,255,91,125,22,1,126,47,235,237,253,203,213,191,125,104,159,30,220,54,218,206,208,175,183,127,186,172,31,126,56,92,61,58,199,254,95,70,237,247,131,250,97,251,176,126,120,7,240,14,215,97,36,54,207,234,195,253,70,125,56,107,31,254,237,55,120,255,4,232,5,240,239,255,218,94,157,253,227,180,253,159,127,255,249,247,159,127,255,199,255,129,156,203,252,251,20,193,159,213,179,89,187,253,246,183,227,228,233,241,151,70,251,231,81,187,253,110,180,35,159,4,248,231,140,46,247,103,248,183,14,255,107,111,208,191,113,27,69,254,97,244,69,123,118,113,50,91,0,202,254,169,127,150,125,18,158,30,206,126,57,249,152,213,5,135,135,159,250,139,176,249,233,80,171,123,240,105,19,127,126,62,204,162,248,230,0,235,244,240,114,91,60,161,119,254,42,129,64,157,244,249,100,216,118,218,41,156,39,63,131,214,122,255,1,219,217,204,2,179,127,115,158,92,159,111,1,13,14,199,127,131,247,118,110,128,78,39,183,0,231,193,96,21,222,253,173,14,226,246,188,142,52,26,172,194,159,119,245,161,188,7,109,3,245,253,250,141,172,127,120,127,9,247,91,227,158,108,175,109,203,48,25,170,89,131,161,103,2,215,154,234,197,72,148,8,218,162,193,214,42,13,116,97,76,203,26,6,193,16,28,177,137,27,229,226,92,95,254,49,229,225,125,125,195,90,179,214,228,13,133,232,50,81,46,213,174,8,21,199,252,46,174,127,113,110,29,241,212,104,13,166,62,25,84,44,154,78,48,104,23,117,40,90,214,1,91,46,4,88,43,213,7,12,18,133,60,158,134,190,1,22,152,227,93,136,18,3,92,130,153,235,247,131,217,139,23,254,212,243,158,217,182,184,181,244,90,243,158,19,247,70,149,184,42,155,120,182,54,159,39,93,250,124,118,57,226,99,126,18,56,125,222,135,174,146,146,24,110,110,157,144,197,246,15,21,227,121,55,246,223,145,221,111,84,49,252,88,49,70,242,206,140,236,216,10,57,88,137,61,94,49,38,119,134,105,24,213,93,99,29,236,100,227,153,205,95,188,80,151,241,139,23,156,254,86,184,29,155,208,230,34,7,47,215,131,201,171,80,123,197,245,39,211,248,74,68,55,35,48,209,166,145,113,189,82,77,99,178,151,20,139,174,24,125,14,183,193,189,81,44,82,142,75,52,175,86,205,181,134,109,59,175,201,185,62,70,7,14,28,254,74,80,109,58,171,171,115,68,154,63,129,116,96,71,60,78,94,140,205,173,70,163,106,58,118,99,254,131,53,228,241,79,23,239,207,42,9,3,17,28,51,28,3,193,59,19,23,216,229,75,4,220,103,42,98,227,224,136,110,99,43,198,225,136,160,7,132,128,110,106,104,178,3,81,57,6,252,142,28,24,203,244,61,160,14,189,233,216,43,96,100,182,246,28,105,101,62,55,88,15,60,242,200,54,122,35,224,58,46,44,126,10,130,145,157,106,176,105,232,217,43,171,49,34,118,208,247,87,141,150,1,55,72,223,85,99,175,238,0,243,66,115,198,110,96,57,147,9,247,251,21,167,58,135,255,118,177,175,132,77,37,131,218,139,248,118,215,29,84,242,149,37,83,33,94,182,206,165,2,239,93,99,234,247,57,184,173,188,143,44,3,19,54,24,136,202,192,55,48,97,193,99,226,192,220,195,138,113,128,104,129,223,34,74,89,28,48,128,31,47,137,91,208,222,190,210,87,61,144,87,156,56,14,97,88,128,62,134,89,236,186,3,4,1,110,230,241,165,59,230,193,52,78,137,92,125,208,134,2,3,123,192,14,136,105,197,160,169,195,154,73,215,115,115,141,111,84,205,252,164,170,206,191,124,64,201,80,209,199,59,174,24,40,116,160,45,184,55,122,158,219,187,129,185,99,45,24,46,83,131,70,114,74,37,30,185,145,132,164,186,27,63,9,34,80,38,67,65,81,2,8,11,226,113,49,184,129,109,192,168,219,198,190,154,61,172,114,200,7,206,212,139,171,134,109,219,252,245,119,90,147,106,42,248,197,168,0,241,81,150,228,152,5,6,189,56,80,54,95,48,122,118,144,195,240,211,199,19,201,30,136,63,148,198,75,112,70,80,28,63,228,252,130,48,207,40,137,101,105,241,37,79,138,82,61,145,105,122,217,165,185,47,11,86,230,158,238,225,137,165,28,156,145,97,212,11,66,90,25,91,179,118,172,13,237,89,237,187,244,177,96,213,175,116,197,239,159,238,172,108,165,15,174,64,171,248,125,199,11,124,174,213,248,222,216,101,22,248,190,44,88,223,251,38,227,33,28,118,47,131,119,145,87,113,76,110,134,213,7,167,110,175,111,109,153,92,252,132,244,35,196,183,25,155,61,251,212,137,71,22,176,173,172,110,142,228,19,215,87,79,124,187,210,91,29,85,235,235,40,198,123,182,61,170,130,170,183,27,187,220,139,56,201,160,174,221,171,141,118,163,153,75,230,133,237,183,172,173,215,221,122,101,189,6,143,171,77,184,194,215,205,94,245,161,231,68,156,57,205,200,174,240,90,88,173,119,87,43,124,47,124,189,221,108,84,119,187,33,119,110,118,169,2,199,10,97,205,193,10,235,122,65,136,5,78,141,99,193,230,60,170,219,219,115,97,201,60,140,154,145,25,53,99,211,107,250,154,77,243,241,237,126,28,188,187,248,69,81,98,13,228,150,19,14,167,32,77,227,200,242,184,63,140,71,100,134,56,214,208,12,225,111,23,84,183,99,133,85,69,158,2,113,122,5,226,140,236,184,214,3,18,129,25,97,199,175,27,205,81,61,54,187,118,92,71,34,43,138,72,196,123,0,126,67,199,7,41,1,132,88,29,253,152,208,193,68,172,126,28,229,201,1,212,88,93,255,113,84,82,138,52,1,146,172,110,38,165,89,154,248,230,109,179,59,159,47,203,69,63,100,181,93,63,232,17,181,164,110,34,149,4,202,169,57,112,193,60,43,170,162,31,132,40,7,194,113,11,237,161,106,98,16,214,255,254,247,250,208,52,234,134,246,200,250,241,239,245,58,89,137,32,187,67,119,56,228,32,123,177,101,49,3,12,243,42,190,6,129,107,234,96,0,226,253,251,156,70,22,208,8,16,245,215,147,90,220,140,149,113,36,33,180,38,48,57,129,9,192,248,36,123,82,216,157,208,6,88,28,125,104,17,233,2,106,200,181,227,93,39,97,20,135,112,114,73,11,72,11,232,211,217,241,101,231,188,253,177,125,122,97,63,0,87,156,186,33,239,71,205,181,173,13,19,184,70,222,109,188,108,224,221,62,69,10,125,30,69,77,96,142,185,249,233,184,115,217,222,191,176,175,30,98,103,216,52,98,167,91,155,185,3,23,224,14,93,48,180,188,251,166,241,25,239,231,102,90,1,148,52,128,169,213,184,160,7,122,21,15,44,38,173,194,201,209,97,166,152,22,197,245,10,31,233,129,94,101,252,143,56,214,107,156,126,184,188,204,84,160,32,41,45,30,68,122,189,75,237,241,252,26,241,123,115,124,116,114,152,96,232,244,129,66,157,105,196,67,180,46,245,55,219,88,194,210,18,92,61,105,26,159,228,61,3,107,151,129,30,39,43,207,245,193,72,193,49,100,51,222,157,160,239,99,162,117,216,4,95,32,132,114,184,115,186,26,49,21,216,162,239,9,24,194,179,32,204,16,232,60,121,38,122,85,247,223,163,215,81,16,197,121,92,223,37,207,68,127,23,220,27,212,96,78,128,185,195,251,76,189,129,166,88,4,111,48,212,67,67,112,238,226,17,59,60,120,119,14,110,44,248,150,224,5,44,213,63,222,116,208,73,114,123,29,55,203,55,244,144,29,159,179,118,191,31,2,79,38,224,36,5,142,40,96,21,240,141,110,57,235,130,196,184,65,176,96,152,216,225,187,131,243,234,183,192,208,241,121,60,118,162,155,114,88,206,84,161,128,69,222,170,78,137,8,73,229,111,234,93,38,23,148,247,254,86,20,230,41,162,145,66,2,226,68,76,54,196,102,35,92,28,74,161,98,46,174,58,112,183,231,14,112,30,46,13,226,56,232,243,236,204,127,227,178,83,122,40,160,184,12,239,161,111,228,197,183,12,43,51,119,192,238,131,233,74,200,217,200,185,197,231,128,101,215,245,220,248,158,77,194,160,235,241,113,164,250,23,41,48,29,185,76,3,150,45,221,71,205,7,0,103,223,48,1,134,183,134,233,3,189,1,166,114,24,123,188,51,113,125,29,192,131,35,86,103,231,63,95,50,122,46,167,14,76,98,80,187,71,23,231,59,235,219,219,72,42,49,139,86,14,142,86,88,5,175,206,62,190,89,223,60,105,172,49,90,215,31,128,26,168,50,172,0,13,201,26,43,231,39,107,107,219,47,235,39,151,59,175,26,141,21,173,226,66,106,70,25,25,216,139,252,2,172,23,103,203,129,121,113,182,178,100,47,192,14,60,206,247,243,241,232,226,104,57,130,80,205,101,251,2,137,158,239,9,164,122,190,31,201,155,216,60,150,138,208,8,147,235,118,172,210,176,225,210,1,182,232,87,119,153,207,135,180,228,11,212,189,5,243,29,87,202,220,161,239,120,172,18,114,76,107,66,193,83,91,167,182,112,121,47,112,64,28,66,163,203,14,193,196,233,221,0,117,64,168,113,39,142,178,194,22,75,88,82,34,121,123,196,153,63,29,119,57,116,7,94,190,139,75,119,152,215,65,181,216,199,55,76,52,24,161,68,36,39,181,59,245,186,209,66,96,194,140,70,203,2,211,153,240,176,227,5,193,100,49,84,12,170,48,81,69,138,161,4,52,85,67,9,103,23,231,62,78,74,143,179,97,96,49,246,14,84,60,84,165,172,145,136,141,185,227,195,100,133,41,26,143,192,190,24,142,192,210,48,49,77,130,121,40,77,198,224,75,187,49,8,56,104,192,90,18,27,116,52,36,46,29,152,21,224,249,102,116,203,229,229,185,34,155,42,21,56,156,98,95,19,207,237,137,113,23,133,200,155,89,242,208,144,75,21,131,121,3,152,82,133,106,169,123,143,254,51,163,246,219,231,199,22,251,116,72,23,24,74,133,54,61,239,30,222,234,113,104,26,88,110,42,250,225,106,216,76,22,5,130,12,170,151,104,20,76,61,104,149,167,83,2,155,94,82,149,79,227,0,224,5,6,15,99,28,78,55,200,232,243,54,20,215,100,49,83,197,130,8,88,132,185,17,10,96,81,7,17,19,217,144,140,195,108,184,215,88,17,99,146,49,173,35,115,16,232,46,239,3,226,48,199,26,4,177,154,79,75,78,10,168,222,195,228,158,251,14,229,99,106,16,31,170,18,38,74,164,245,3,20,198,123,100,54,140,251,129,158,145,189,170,202,114,82,4,190,197,100,100,134,56,19,241,137,208,132,160,183,137,192,48,124,42,225,88,96,10,99,178,185,179,243,234,149,194,7,94,147,232,44,55,6,104,34,118,192,60,3,64,242,150,34,83,143,5,26,135,1,6,87,80,200,107,122,20,72,75,85,187,97,112,3,206,60,123,79,10,137,70,69,144,250,30,230,21,129,79,58,191,194,239,156,241,196,3,93,49,190,199,247,228,107,61,204,158,216,217,217,92,8,50,25,178,25,144,227,0,216,21,204,192,24,148,138,95,128,156,74,89,82,170,236,65,186,37,226,167,181,34,109,88,112,0,142,4,132,77,120,6,84,142,234,77,65,231,142,219,79,46,17,200,122,147,28,13,120,108,177,11,206,217,199,163,246,225,233,17,181,61,152,134,49,202,142,62,143,29,215,91,44,221,138,72,77,39,125,152,109,79,224,38,42,61,142,34,160,52,153,118,1,171,17,211,222,137,44,118,46,89,45,30,129,96,113,104,34,211,100,135,169,27,6,99,22,72,192,37,103,97,174,204,36,255,6,137,110,170,77,102,180,156,112,51,215,243,112,34,2,239,162,48,149,29,130,96,32,131,6,94,249,10,50,160,198,123,138,10,84,231,107,198,89,167,137,122,217,98,159,133,213,39,22,43,132,87,28,137,98,147,166,223,96,10,104,221,248,152,0,42,94,18,193,107,249,2,33,13,178,79,182,11,84,84,206,69,14,176,229,57,160,196,167,18,35,8,5,44,239,83,49,229,96,128,119,195,132,229,144,153,184,75,119,91,230,78,81,75,147,69,62,85,210,233,55,247,217,243,48,237,179,35,204,27,49,218,133,238,15,168,14,25,229,96,2,93,138,58,2,146,131,192,247,185,92,100,19,197,96,108,68,14,142,223,19,195,2,218,253,18,111,92,191,231,77,251,80,255,228,243,37,177,122,215,133,137,11,165,143,206,231,197,250,189,136,98,136,111,100,120,247,60,195,129,26,196,32,28,69,109,54,240,156,97,226,177,12,24,247,73,55,153,197,87,242,72,82,35,200,156,68,58,173,181,4,228,133,238,131,17,135,83,110,52,141,35,95,42,66,99,224,120,17,62,57,84,186,113,190,0,209,200,69,113,217,41,14,105,198,110,207,140,227,169,194,65,119,139,142,97,28,169,41,114,138,76,145,179,71,26,144,232,28,177,149,158,24,113,222,95,161,209,90,33,5,154,60,42,208,3,6,18,218,20,131,6,212,17,173,98,170,22,115,186,193,84,88,11,104,51,3,5,76,77,165,153,140,199,192,34,170,57,201,36,253,229,105,120,65,88,232,36,148,32,44,36,225,40,24,243,14,76,44,23,227,213,113,71,179,47,66,62,112,239,178,49,135,49,111,171,154,98,160,83,163,227,92,86,79,120,7,236,22,83,96,34,184,136,124,176,97,72,73,165,130,93,50,237,173,68,162,197,212,50,81,129,55,135,188,81,152,69,160,217,131,25,24,37,168,49,224,161,19,9,3,191,139,134,87,76,35,193,28,221,54,91,110,178,144,101,220,73,28,68,82,176,133,200,86,234,64,50,81,46,208,92,183,54,217,219,119,191,49,106,131,70,217,3,51,4,153,71,136,115,49,241,133,143,77,146,92,57,219,126,16,75,63,28,108,123,229,206,62,123,114,152,125,172,217,52,232,199,48,133,115,11,243,90,247,117,245,129,206,26,253,225,96,125,19,172,198,25,186,46,96,162,122,58,146,212,34,59,199,66,118,34,10,165,204,165,71,84,159,228,145,47,97,125,18,212,211,227,51,144,163,40,128,78,222,127,6,87,51,152,25,230,187,227,183,239,128,141,192,182,49,204,211,246,95,161,220,185,123,2,94,97,28,117,144,156,126,25,200,39,194,120,58,80,229,2,234,207,35,183,55,98,242,165,40,103,251,38,82,12,179,18,208,103,20,212,124,2,31,194,130,240,57,61,62,196,171,126,6,159,199,177,80,144,20,225,191,64,7,240,32,41,94,4,62,249,137,58,224,136,72,2,60,72,155,79,196,76,3,142,163,149,188,8,86,48,239,71,96,15,209,251,209,66,49,2,83,53,70,49,215,124,24,11,7,143,55,159,53,230,255,60,238,114,240,74,60,120,57,108,57,15,254,216,239,133,220,137,196,50,188,80,146,120,143,211,28,80,117,198,193,20,228,142,244,237,17,57,184,19,93,16,238,64,17,105,47,162,85,76,30,143,238,146,164,85,19,71,7,195,108,27,139,117,106,198,87,17,118,225,192,155,70,35,33,44,192,59,207,7,0,193,100,195,114,150,150,75,183,25,92,224,241,116,156,113,9,61,0,135,131,10,233,163,157,26,207,48,59,156,94,150,49,56,50,17,81,129,70,163,114,108,64,212,56,247,164,139,220,49,37,154,199,28,196,206,4,117,74,20,231,90,88,210,183,212,236,223,16,255,120,238,216,45,132,240,101,211,88,129,201,10,95,133,164,238,18,96,37,12,194,200,54,43,125,205,255,196,20,152,175,176,118,250,28,244,170,15,226,155,232,82,0,90,21,51,89,188,20,200,130,196,137,37,47,128,84,160,211,94,52,37,230,191,17,242,76,212,164,131,209,157,56,6,115,6,46,120,52,10,188,254,194,232,18,83,117,153,86,55,49,77,113,99,95,196,70,160,109,38,217,144,148,35,99,72,248,38,154,40,231,122,64,140,84,50,69,148,104,122,169,241,194,132,7,244,191,124,49,37,41,38,197,42,96,220,233,164,170,10,69,223,115,48,118,152,235,84,104,232,164,91,134,27,218,160,239,83,17,208,202,182,75,173,192,36,87,216,77,39,80,83,143,74,172,55,26,227,111,10,217,165,212,5,100,113,121,233,22,140,138,37,232,155,169,253,213,20,206,6,242,116,242,20,233,2,82,26,103,51,237,247,27,56,17,114,87,134,173,26,38,27,59,119,196,175,130,88,107,141,6,60,107,40,137,16,45,102,186,199,232,50,22,115,96,49,41,146,10,37,216,15,40,247,138,48,64,185,213,115,208,230,21,50,91,104,167,92,204,147,167,46,125,6,183,141,101,97,23,113,15,41,139,113,139,84,70,171,188,37,199,88,204,83,85,154,44,143,129,129,8,142,11,105,86,81,166,197,15,149,161,141,126,108,217,92,135,113,249,120,116,129,126,77,52,1,141,200,69,132,51,21,93,10,250,50,240,202,167,190,176,137,59,137,189,74,75,55,29,177,212,79,4,208,52,11,61,164,16,66,200,111,221,0,189,77,92,186,17,18,40,2,175,70,24,213,117,218,167,195,228,134,226,4,245,55,240,236,227,219,253,207,159,69,188,155,85,132,221,9,143,86,15,14,46,49,164,246,230,211,101,99,231,85,213,76,94,149,241,150,254,151,41,168,18,209,106,218,13,190,65,219,74,25,110,43,229,33,22,200,153,43,192,87,203,156,0,225,100,18,6,147,208,37,223,145,92,160,177,115,35,56,67,8,78,17,229,145,175,225,246,204,2,150,48,255,63,183,63,158,29,159,189,109,10,65,49,224,162,67,184,68,35,26,64,158,0,88,168,17,73,128,173,16,184,43,9,42,95,235,126,22,189,207,71,204,27,222,23,195,70,107,111,32,8,7,238,48,191,188,66,35,213,159,34,83,51,172,198,84,53,49,56,73,21,90,1,212,98,200,100,253,176,118,175,135,2,249,28,55,176,202,122,142,27,147,125,20,200,150,144,22,180,192,47,241,84,64,105,140,39,220,191,50,152,7,194,55,44,131,153,0,34,136,69,37,217,163,79,57,17,79,0,63,114,162,226,91,42,168,76,109,250,160,93,130,240,230,107,129,14,136,223,114,243,35,11,134,86,101,41,18,71,83,162,49,198,218,238,89,56,165,125,146,95,11,150,144,105,165,48,37,107,35,209,50,240,160,153,142,163,75,203,39,24,27,197,155,228,253,111,1,10,216,114,234,103,64,123,131,22,33,235,9,91,186,0,221,89,126,1,13,224,67,136,105,126,147,49,41,224,86,98,28,21,94,79,230,206,46,148,225,58,124,82,236,119,210,236,143,146,213,23,101,159,167,149,228,26,12,238,158,212,204,142,82,165,4,243,254,62,107,200,97,32,49,53,104,180,54,149,187,128,50,146,150,150,134,80,56,245,80,171,165,89,40,166,72,106,69,225,227,161,55,188,216,97,136,179,169,43,111,63,190,255,116,222,185,184,108,95,30,117,126,62,250,245,194,190,50,164,182,80,41,231,166,209,77,210,121,224,70,186,229,198,104,74,117,18,145,11,55,36,214,224,87,140,188,113,195,189,91,55,121,222,65,73,140,141,129,128,151,188,97,160,96,156,130,194,237,168,55,249,96,64,115,215,72,214,20,224,90,173,35,164,143,69,120,195,8,70,201,139,35,126,39,175,175,77,96,133,206,233,251,195,35,196,229,253,96,0,165,23,24,147,17,249,161,112,247,6,12,151,244,142,202,186,30,230,140,136,18,117,237,209,230,126,184,122,239,67,163,159,14,207,59,231,31,223,95,190,63,120,127,210,249,229,232,227,197,241,251,51,232,96,203,220,190,54,15,143,222,180,63,157,92,118,210,58,73,21,123,203,76,18,43,77,25,15,186,72,30,208,86,86,24,30,251,217,154,233,5,14,78,171,11,233,230,226,35,82,237,112,67,38,195,113,95,127,212,198,166,224,1,230,41,26,198,51,74,43,199,81,176,84,86,141,200,2,155,241,238,69,128,252,99,251,124,198,62,171,187,138,49,195,20,78,99,181,240,218,170,209,220,89,51,170,187,201,139,86,224,75,151,222,78,147,204,170,15,152,229,46,129,87,91,38,184,133,177,195,93,204,27,207,29,34,97,84,173,9,218,86,152,111,182,7,87,152,118,143,25,247,116,89,157,207,105,251,65,28,188,227,119,153,62,228,238,143,198,157,177,202,173,56,184,32,78,174,172,109,87,225,230,211,4,230,218,1,80,179,82,157,11,162,112,69,37,189,137,2,5,27,233,112,88,48,99,126,65,35,181,2,144,19,95,29,247,41,25,94,86,134,199,67,113,69,79,49,20,155,212,188,4,6,172,150,141,79,200,7,232,235,208,19,242,182,17,190,180,205,12,112,63,84,86,158,203,30,152,216,234,129,4,20,231,48,216,198,202,42,95,93,161,76,109,202,147,151,237,32,20,133,70,72,82,123,238,83,175,15,121,124,48,13,49,25,240,80,162,107,107,217,133,130,218,32,110,194,136,31,251,113,37,165,211,80,209,169,154,105,164,128,82,210,6,178,128,66,140,54,78,83,250,5,32,8,140,128,48,86,12,2,18,134,222,196,36,196,182,223,23,83,66,48,181,214,30,112,217,179,28,145,85,254,229,67,58,16,205,20,38,26,164,170,169,134,179,89,68,25,74,37,104,205,2,42,72,164,216,126,223,253,130,73,201,120,198,140,203,163,74,126,210,90,210,40,195,4,74,15,140,219,204,6,7,73,128,142,229,70,71,255,152,226,54,153,171,181,107,139,162,193,212,199,185,19,58,227,200,228,152,84,153,155,202,13,51,150,233,151,173,198,235,66,167,9,175,198,87,141,235,106,179,80,78,187,122,42,213,130,124,152,207,77,49,0,159,66,207,46,36,177,150,146,39,182,75,8,99,58,118,158,202,66,244,216,54,175,162,119,53,51,78,209,16,167,147,33,148,177,112,124,104,96,165,132,29,228,1,69,9,23,128,127,2,215,209,196,115,65,34,153,112,169,22,156,42,78,245,247,223,43,177,221,168,154,218,230,24,59,217,28,83,236,81,44,55,98,135,82,106,212,101,218,90,4,82,14,164,26,252,117,232,111,60,47,206,81,157,48,57,118,251,253,247,116,119,83,66,72,32,135,62,5,193,178,232,123,156,90,250,68,146,168,130,227,171,79,149,236,172,205,204,19,57,119,229,52,41,78,16,77,182,217,29,75,121,226,21,189,127,220,41,247,131,133,169,247,149,135,105,232,53,53,56,87,141,215,116,126,193,123,255,195,148,131,88,32,55,194,28,243,120,20,244,193,135,254,116,105,152,180,55,31,241,179,132,169,224,14,238,43,98,219,12,30,38,68,243,203,0,31,73,228,211,128,3,39,118,113,73,139,180,249,36,29,128,16,201,94,60,199,227,97,12,23,115,185,103,8,45,201,3,225,3,45,194,77,226,101,212,163,251,136,246,4,61,36,192,191,191,248,86,232,231,106,211,146,228,145,11,90,153,253,24,204,108,61,143,218,116,196,52,113,109,99,47,14,91,192,202,171,120,213,111,25,38,92,173,236,145,68,147,103,143,41,129,19,93,93,39,155,208,244,35,172,12,150,17,203,245,214,138,73,173,213,85,115,139,26,166,173,65,79,55,26,63,217,104,223,189,85,109,116,99,95,38,129,211,106,159,220,56,3,207,165,219,217,90,217,133,110,42,136,187,111,55,118,253,189,82,219,71,10,171,93,127,117,85,208,41,178,75,235,93,249,215,102,96,59,182,29,253,254,123,100,219,143,216,74,47,94,60,43,239,73,19,11,187,132,12,109,247,210,208,97,136,146,48,164,157,240,126,101,181,18,188,54,152,156,81,77,195,168,2,117,4,113,20,113,197,182,88,225,41,171,227,227,196,74,39,17,27,35,30,104,155,226,217,23,182,17,128,13,201,178,186,53,130,22,153,232,167,55,226,96,39,245,69,63,70,139,25,171,145,28,6,130,178,101,200,77,10,76,60,132,113,104,149,143,144,32,127,30,171,62,134,32,208,211,26,7,183,188,166,78,12,147,185,4,9,82,234,173,228,80,20,150,30,143,34,94,197,221,53,110,194,33,162,47,5,131,6,78,29,57,125,78,198,104,98,137,102,183,32,200,61,59,226,167,134,121,115,40,195,233,78,28,135,5,210,177,212,34,125,241,66,219,40,90,87,203,57,134,153,51,43,11,118,112,195,148,26,249,134,223,71,80,169,100,71,104,186,229,97,229,185,106,152,233,187,103,197,12,1,107,200,116,51,214,147,163,148,248,202,213,66,187,41,217,22,209,106,188,120,81,145,76,131,219,107,228,22,58,114,70,64,95,9,78,232,6,119,197,194,215,237,48,116,238,193,40,160,223,10,199,221,30,175,241,111,17,23,36,2,233,143,166,43,234,53,197,94,12,186,198,45,34,60,103,226,199,182,161,157,176,73,155,30,147,26,187,106,55,137,216,101,44,247,32,251,206,109,215,9,107,93,112,4,251,106,207,36,8,101,160,189,180,120,193,215,234,72,11,3,48,46,183,53,68,30,91,4,131,157,179,151,138,109,148,140,88,2,59,7,115,6,108,11,14,102,18,12,141,110,212,57,88,146,24,114,14,86,80,134,155,115,181,126,61,223,45,192,229,244,251,2,170,202,3,110,106,105,198,38,141,41,252,230,13,176,166,11,132,44,188,47,237,2,133,217,179,181,42,238,154,86,38,63,32,132,212,136,22,145,65,175,87,138,112,180,24,80,116,125,80,109,9,120,57,238,185,209,252,148,34,92,216,91,49,140,171,205,2,241,178,100,254,146,128,47,240,55,176,117,102,214,26,180,22,88,218,174,96,141,178,72,226,162,46,75,163,142,143,116,90,218,118,89,183,34,98,183,92,183,50,112,184,116,183,178,237,92,183,73,172,238,201,78,211,168,222,50,93,166,237,230,58,20,1,159,39,123,147,241,188,101,186,146,45,206,133,53,67,230,120,70,143,212,4,193,163,100,3,53,237,83,147,117,228,104,0,239,23,30,149,75,47,181,123,62,111,92,85,36,155,163,3,99,210,132,231,48,143,73,166,21,99,31,104,162,225,172,149,27,85,14,68,135,69,15,134,118,169,59,221,218,180,63,81,202,17,208,0,31,230,217,26,70,34,132,215,9,254,150,37,180,224,1,170,201,138,65,199,117,26,98,95,27,145,88,215,21,186,45,119,189,130,239,142,157,137,134,98,106,23,254,0,6,137,216,252,183,171,116,124,116,230,156,85,220,234,107,112,94,64,113,81,5,152,237,217,110,77,60,183,162,74,50,200,205,119,174,236,189,127,117,199,62,118,156,241,205,175,10,102,80,41,12,177,230,189,196,121,111,133,124,194,103,80,69,217,143,145,125,133,214,95,99,55,216,83,90,116,55,0,131,49,186,10,174,237,43,7,254,154,46,254,241,225,207,245,110,106,236,39,198,65,98,238,211,56,62,225,148,148,57,3,15,57,158,197,163,48,230,232,129,76,208,43,41,218,56,192,193,57,110,68,23,240,251,195,37,92,68,100,113,229,122,23,34,31,57,56,20,203,119,104,72,144,241,21,167,26,85,152,83,233,161,109,42,66,97,22,131,105,140,54,171,78,65,195,236,102,240,175,60,164,202,139,244,143,14,90,91,40,202,37,193,91,58,124,18,242,254,180,199,115,188,37,102,100,105,212,68,177,58,88,66,128,46,178,143,22,140,51,157,36,134,7,151,210,80,0,9,51,55,31,138,184,230,13,20,137,177,56,235,174,44,54,166,235,98,20,35,82,123,171,29,182,153,73,240,136,230,174,102,199,27,195,77,122,159,133,240,87,137,133,242,116,239,79,153,53,213,226,200,10,64,4,84,71,40,42,50,211,129,191,214,152,173,230,246,147,195,54,138,242,165,164,158,126,240,178,210,47,188,90,109,150,55,89,38,165,85,12,4,131,40,18,137,199,34,89,50,4,24,219,79,68,70,153,179,146,28,36,2,192,8,8,76,21,146,193,211,111,188,0,104,19,131,193,172,142,56,6,242,42,107,153,12,105,172,152,198,180,50,130,18,93,37,125,120,22,135,186,120,245,181,170,26,141,64,75,34,105,196,109,134,24,73,124,104,228,34,126,40,60,40,84,251,139,144,215,118,137,105,13,16,97,224,165,94,249,123,127,181,250,119,75,255,169,212,42,214,143,213,234,235,186,82,32,15,99,231,75,16,54,113,226,153,99,215,167,203,245,107,33,36,225,114,227,218,196,5,111,236,9,238,182,176,0,244,84,243,138,234,83,77,170,131,69,215,48,147,64,27,97,146,95,17,182,116,138,235,192,163,233,235,102,159,196,137,102,115,44,234,171,229,138,223,185,41,194,76,7,232,108,189,9,66,17,105,42,225,8,212,117,192,5,244,227,228,100,151,56,56,10,61,82,24,238,158,224,155,154,212,124,9,147,90,242,193,170,193,42,232,84,65,179,174,227,199,171,70,85,106,82,58,217,9,91,240,16,130,164,1,102,33,175,227,209,156,208,20,207,152,71,201,57,54,87,89,238,160,186,232,7,101,31,99,98,2,88,41,78,194,32,248,0,79,99,72,121,68,206,167,185,130,136,171,83,177,98,113,130,79,30,52,185,166,151,26,122,102,177,150,154,171,130,29,133,145,180,235,216,217,65,173,128,79,233,12,59,104,52,152,9,169,170,175,13,168,163,146,198,153,115,11,22,53,166,39,60,51,154,198,175,34,169,88,230,179,142,193,67,165,229,99,63,86,213,45,49,251,20,16,209,116,60,118,194,123,5,170,35,0,113,237,216,130,249,128,11,162,139,195,239,226,76,39,24,134,62,191,123,143,182,181,28,186,234,51,187,182,134,70,138,230,208,99,151,3,55,28,207,156,16,15,157,242,111,12,73,102,121,52,142,139,170,181,27,6,51,48,149,59,40,4,80,237,209,185,73,200,231,57,214,214,79,226,201,29,61,4,230,247,110,9,183,153,79,140,144,22,219,81,7,36,179,242,32,15,9,121,25,229,169,62,70,201,199,7,93,136,23,51,49,121,104,43,0,216,59,133,128,47,160,139,1,5,7,149,7,122,78,211,144,147,101,169,222,76,206,146,155,184,234,32,31,60,33,6,183,255,71,245,200,237,7,163,58,143,38,184,89,184,51,118,105,43,89,7,234,64,185,71,217,180,117,1,94,89,207,24,242,182,121,161,103,37,25,180,0,116,126,201,146,19,117,105,222,47,58,92,77,140,189,10,234,153,198,251,51,92,226,16,239,125,213,209,107,232,163,211,138,180,146,199,201,97,53,242,185,21,170,26,214,48,185,234,226,74,75,114,152,203,227,53,119,243,88,164,107,241,136,137,56,62,8,3,118,98,233,200,48,215,26,141,31,193,87,148,171,68,252,28,69,58,159,29,96,115,120,248,239,143,177,53,162,58,177,21,201,95,79,58,167,233,10,190,114,65,176,60,83,80,211,14,8,177,146,243,65,170,245,74,230,185,58,41,100,65,237,60,74,90,246,214,2,156,92,9,97,154,163,32,32,244,109,253,217,143,120,14,127,61,7,73,122,74,73,161,95,145,228,80,222,163,143,51,191,72,193,194,218,65,76,75,88,107,116,74,30,92,58,246,86,67,152,13,133,195,246,229,81,123,15,233,217,255,212,98,211,24,1,183,208,242,149,73,43,230,127,49,105,21,235,47,85,90,193,192,99,175,47,84,2,65,169,99,76,203,240,160,64,244,52,3,116,138,139,14,113,246,48,237,140,109,151,61,137,91,200,142,104,226,248,212,136,16,40,23,180,185,88,193,98,16,108,240,228,107,129,107,60,9,156,36,86,25,112,2,238,82,224,130,137,6,27,137,190,204,7,48,18,77,135,43,118,62,114,27,63,148,6,223,27,140,127,45,88,20,90,180,164,226,246,149,137,185,104,117,5,156,111,199,82,155,24,94,167,97,99,185,205,71,45,97,50,61,236,29,151,4,81,29,181,238,18,95,57,215,203,44,140,128,118,208,151,63,86,86,125,132,82,3,88,5,203,233,166,100,17,36,177,160,29,172,80,111,49,92,9,209,86,58,170,102,186,200,97,160,190,211,70,62,57,170,10,151,5,179,71,110,194,4,78,99,236,57,63,18,247,209,30,41,95,146,12,104,240,25,27,230,70,163,90,125,52,94,64,107,155,223,232,151,91,253,192,231,180,78,41,108,85,142,82,0,187,197,35,7,171,115,156,195,250,7,119,12,181,86,80,98,253,99,158,104,165,160,85,158,53,212,169,74,24,168,210,205,83,205,122,199,179,119,254,90,83,13,1,253,1,75,104,10,79,151,135,55,226,122,101,237,71,85,72,39,245,87,170,104,69,211,161,93,36,67,72,164,59,50,207,32,43,236,221,170,190,174,92,121,0,137,68,26,116,87,137,39,253,195,38,232,218,4,184,183,30,38,68,118,221,25,167,172,99,225,54,54,130,131,210,26,68,221,233,36,95,115,77,21,101,142,153,204,149,225,164,207,22,226,81,84,217,14,72,132,10,169,108,36,226,89,60,13,157,89,77,224,133,67,66,201,187,69,219,251,1,115,60,244,181,154,31,50,135,50,202,5,27,245,208,5,33,211,84,227,6,230,45,140,118,147,230,66,98,129,139,48,28,191,202,54,67,27,145,171,215,96,32,233,132,78,224,79,62,168,20,151,114,79,102,112,100,221,102,214,55,144,79,13,58,160,75,208,132,86,200,23,53,169,173,181,47,221,164,146,209,249,214,48,40,64,156,77,137,35,148,5,137,97,5,41,99,95,235,138,9,220,217,140,46,168,168,166,51,34,184,70,190,118,41,216,153,182,74,133,183,114,136,177,176,228,116,209,231,248,117,37,25,231,70,218,152,79,160,241,104,120,124,81,104,219,160,35,128,21,110,50,234,176,44,225,48,250,224,162,243,163,159,215,6,179,185,60,0,144,212,75,181,96,82,84,8,148,88,93,60,228,141,234,63,66,34,171,124,117,217,44,145,103,73,80,36,14,147,112,13,182,156,100,12,184,118,99,215,221,123,5,234,104,181,154,196,72,210,0,138,162,96,225,228,94,210,149,84,57,81,50,46,45,219,195,143,118,56,47,197,106,202,80,200,244,3,196,103,142,81,22,255,204,204,44,124,39,55,7,82,8,12,177,216,90,24,45,148,86,187,194,194,212,219,47,38,178,225,1,181,116,134,117,49,35,80,100,196,192,91,243,121,222,242,148,225,81,227,218,76,194,187,43,137,48,243,203,249,203,47,70,253,236,52,190,38,131,124,201,42,61,148,130,4,8,113,167,75,19,5,56,110,88,62,26,79,226,123,193,51,248,44,128,49,122,239,191,9,122,211,8,111,69,229,247,254,62,184,90,120,15,252,137,223,134,122,16,176,54,179,62,112,178,166,178,135,134,88,226,188,230,190,40,98,232,131,223,122,60,129,1,38,188,242,108,5,35,96,195,224,228,46,80,75,73,164,187,116,232,74,166,164,124,161,16,138,21,138,167,192,249,133,239,159,24,213,133,140,34,22,120,116,75,177,240,114,75,152,75,43,233,82,142,154,35,6,81,176,69,217,175,252,142,50,102,197,3,104,54,61,162,218,116,193,29,9,124,49,120,237,126,63,55,28,113,33,142,142,110,73,225,161,90,122,255,134,156,73,243,145,76,204,121,121,180,121,158,93,176,72,35,230,26,219,226,178,70,57,199,70,65,24,147,101,222,148,81,239,82,30,254,191,199,180,199,253,63,5,199,202,37,32,177,80,181,43,127,237,36,47,217,209,23,88,132,56,151,11,34,14,188,109,102,178,44,142,1,220,138,108,161,100,241,69,176,200,27,138,35,54,75,71,67,70,208,255,187,210,184,187,114,106,131,118,237,77,163,246,234,250,97,205,220,156,255,126,37,47,183,230,213,31,234,213,215,149,4,66,232,170,177,135,145,88,190,103,111,111,109,109,108,189,174,100,86,87,48,131,3,93,128,102,238,177,200,103,237,114,60,110,129,206,161,224,97,178,33,164,113,215,192,207,25,226,78,180,198,221,27,248,103,224,112,85,191,71,27,115,61,217,196,78,174,50,51,84,156,18,12,14,230,52,241,46,125,231,22,252,197,219,90,236,116,35,225,68,210,70,237,75,188,5,31,55,176,149,89,178,43,79,127,45,79,138,33,175,53,153,73,43,171,1,42,253,116,154,173,32,43,57,195,130,55,13,189,162,109,192,45,181,31,71,55,18,176,115,116,70,101,227,90,115,133,84,129,146,86,63,29,158,39,77,173,96,27,208,242,20,188,91,209,156,54,49,176,49,233,97,102,48,102,184,233,229,73,164,115,13,193,52,193,179,234,64,177,11,26,40,183,92,98,47,218,55,210,147,102,75,179,224,192,145,198,176,124,23,35,150,240,94,250,153,4,173,179,244,3,170,152,232,233,32,122,228,174,155,78,26,64,128,58,148,51,151,116,190,26,103,8,45,253,125,51,182,112,107,19,230,136,229,48,74,191,12,40,41,140,151,53,148,9,162,93,188,165,129,150,146,196,116,210,184,129,89,150,176,100,227,87,34,64,195,188,22,93,9,198,204,202,105,225,179,50,61,49,145,45,76,128,98,42,254,2,132,205,111,129,42,29,51,2,80,232,142,22,133,228,246,234,234,110,174,192,23,93,131,180,211,54,192,253,83,128,235,67,144,110,107,250,14,240,225,125,110,179,105,2,104,105,16,76,44,247,192,128,171,220,2,204,0,85,249,17,15,243,106,51,155,89,75,135,74,151,231,44,231,49,195,180,101,125,244,97,114,56,115,49,121,147,72,82,181,116,238,149,78,153,194,12,23,115,184,232,213,69,53,250,108,33,211,190,94,168,121,52,114,101,88,135,34,115,77,222,177,138,58,165,219,155,162,106,190,36,154,118,199,110,92,156,165,165,126,103,49,147,201,26,57,81,206,203,171,22,243,162,48,0,161,78,168,119,236,60,4,60,116,193,61,248,141,139,228,211,234,46,29,252,190,56,249,130,95,137,245,179,235,100,5,54,151,190,234,84,95,59,214,100,26,141,18,213,218,76,222,177,175,64,11,139,167,215,218,83,249,136,242,49,50,135,205,52,175,174,145,61,237,31,44,224,24,164,224,67,113,178,98,29,19,212,62,175,228,115,75,192,227,214,182,82,40,156,197,192,82,216,10,126,43,6,6,8,12,105,221,148,120,247,200,158,37,3,149,154,1,101,244,46,51,159,22,242,216,66,80,180,47,168,46,92,43,134,106,123,46,217,145,229,145,111,205,230,164,179,76,105,197,207,29,162,175,29,7,1,10,185,202,3,157,66,143,121,192,77,3,195,228,38,101,3,231,60,223,68,66,27,98,251,132,227,130,24,104,10,95,123,142,110,183,140,58,8,43,78,69,178,158,203,227,7,50,97,167,210,21,250,106,38,169,188,82,18,172,144,17,138,63,246,169,252,25,48,114,30,118,17,158,66,50,78,186,151,14,96,92,148,27,117,5,115,47,126,241,226,89,102,51,215,139,23,153,109,142,37,62,220,31,247,3,2,233,39,25,208,106,76,13,200,46,134,81,68,54,186,60,158,25,76,2,221,245,80,124,152,125,44,95,17,159,85,135,34,185,115,66,168,30,113,99,228,234,202,237,190,89,115,175,23,120,158,51,137,146,199,78,8,195,99,39,9,242,73,113,43,235,21,134,53,60,122,207,104,137,133,94,68,192,29,210,178,130,244,118,88,166,58,205,76,104,206,104,125,151,226,100,239,6,3,171,54,139,163,72,232,151,166,110,221,104,105,219,2,132,65,75,123,79,50,228,21,8,178,34,194,5,59,31,203,225,199,104,101,13,234,68,207,100,41,43,196,94,150,172,121,249,220,82,178,32,177,182,115,77,83,124,87,120,22,116,249,132,199,77,139,131,36,40,5,9,91,217,101,201,132,126,105,103,170,181,36,60,169,245,78,95,81,48,242,53,202,249,40,121,159,129,133,163,177,32,102,157,212,64,129,79,130,201,116,98,139,51,60,196,67,126,7,176,244,57,160,38,14,242,104,137,207,66,100,134,190,7,211,46,214,198,221,209,71,37,1,8,63,151,149,31,20,130,189,38,190,88,188,212,200,100,94,80,195,211,22,183,25,130,9,252,34,14,34,193,193,35,183,19,112,220,91,87,204,197,226,56,210,234,137,92,151,145,158,162,174,52,150,0,79,85,151,128,145,98,161,195,109,164,106,89,192,64,73,151,203,246,80,75,18,129,100,79,162,125,246,70,62,94,208,15,46,219,23,54,211,149,118,35,106,214,242,19,97,159,30,167,173,163,187,185,144,71,133,149,75,95,61,203,240,109,250,88,164,50,22,62,138,183,36,15,183,232,51,96,123,221,2,27,118,19,30,212,186,194,175,244,45,197,147,79,125,155,175,85,242,241,181,50,138,136,43,177,139,78,252,5,169,180,64,105,16,160,137,135,174,215,193,221,195,84,154,166,160,74,101,92,195,162,188,148,172,69,227,218,6,98,148,122,199,89,205,95,222,150,209,18,225,38,118,6,70,176,242,154,149,11,152,190,145,52,66,102,9,158,147,198,161,131,3,10,78,137,51,68,45,203,34,65,32,252,183,12,250,217,75,29,191,165,145,56,238,231,225,23,28,164,160,63,238,103,5,147,150,208,155,74,167,69,200,97,227,25,188,142,104,191,246,104,218,197,221,218,5,164,138,32,111,102,17,209,115,110,169,155,204,206,114,193,211,132,91,175,23,155,225,176,59,195,63,29,188,25,76,227,198,206,43,250,121,181,150,165,131,140,110,27,45,178,166,18,100,22,39,120,168,23,22,164,120,148,231,97,200,83,101,212,198,212,214,226,189,168,106,17,232,169,141,168,107,48,171,196,218,180,157,36,23,180,216,90,130,192,99,96,124,143,254,215,161,183,245,127,91,111,192,188,108,227,223,214,27,240,29,219,92,170,55,150,225,201,148,3,5,195,125,15,88,182,0,150,173,63,8,44,219,0,203,246,31,4,150,151,0,203,203,63,8,44,59,0,203,206,191,141,59,27,208,91,219,243,146,254,50,26,241,17,73,170,9,61,10,79,182,62,242,113,0,106,6,87,239,202,228,158,102,98,44,216,159,46,233,91,176,46,178,222,216,2,91,67,192,211,194,32,64,170,66,30,177,129,165,239,179,208,16,38,225,44,16,67,219,73,167,24,234,130,236,2,30,30,56,152,177,110,245,234,160,49,178,181,15,14,46,23,86,150,42,38,177,113,18,9,159,237,109,245,137,54,10,47,44,172,156,176,173,86,95,156,144,200,234,108,127,231,209,247,72,249,229,222,123,181,134,239,173,63,250,222,122,163,248,222,122,35,111,163,61,198,135,139,172,146,181,245,71,116,124,58,87,51,218,28,126,117,141,78,192,45,219,213,104,171,245,110,10,12,7,191,95,11,234,118,46,24,144,38,203,45,44,144,89,116,229,254,125,49,23,56,101,118,249,83,160,232,2,144,23,83,77,51,127,190,142,74,23,73,70,249,55,19,75,72,58,21,58,161,108,61,166,229,233,177,66,234,186,0,93,212,196,15,237,162,172,203,62,115,238,192,242,105,228,158,42,139,168,209,48,190,141,78,146,169,74,77,197,175,32,25,37,88,178,203,52,111,253,155,41,167,61,165,163,76,107,148,101,159,48,219,114,132,213,19,232,255,53,148,253,186,89,14,180,72,211,238,191,137,52,212,204,114,200,139,44,254,239,141,246,87,49,131,56,224,245,219,17,77,21,93,146,50,154,58,25,110,214,144,72,114,90,89,102,87,75,210,143,70,159,236,170,183,212,216,69,87,66,8,246,242,185,195,92,223,3,47,123,161,128,206,11,103,108,233,177,64,109,222,160,208,83,100,69,31,242,9,37,26,116,104,74,24,173,207,248,147,218,5,66,17,17,133,23,246,135,38,2,52,250,142,35,27,22,186,71,183,246,177,190,125,218,169,36,76,140,51,188,206,117,190,44,154,114,87,211,99,93,77,28,55,124,58,232,57,141,100,208,243,28,170,127,35,48,242,144,161,71,96,153,250,203,64,163,31,53,196,62,209,43,249,177,65,35,97,2,191,147,199,121,251,107,120,46,203,99,218,155,72,28,156,241,240,87,248,238,95,199,129,120,45,15,7,127,108,148,228,73,208,146,39,50,218,189,140,70,4,79,89,56,95,127,145,22,232,12,105,23,75,179,225,159,7,157,209,145,53,248,5,156,60,83,243,187,120,89,248,21,199,229,44,114,93,126,169,94,114,138,34,157,155,255,140,68,41,14,243,191,216,71,193,65,120,60,50,255,132,87,194,178,57,208,89,75,249,219,136,82,22,217,250,31,226,127,113,38,60,126,196,165,35,209,251,158,51,224,2,27,254,142,83,224,17,232,137,62,255,14,238,47,56,74,248,54,69,21,75,147,7,52,166,112,60,202,119,81,235,76,125,215,241,130,97,65,90,58,94,109,113,73,105,56,92,20,45,181,158,42,118,106,8,42,130,203,50,118,147,247,141,214,11,58,142,123,55,37,203,104,61,219,131,28,84,245,245,105,177,127,2,173,163,245,242,73,160,242,114,158,180,151,152,110,51,97,66,71,9,61,69,162,71,107,47,166,79,211,164,121,68,8,209,94,140,200,183,240,156,71,184,76,2,223,135,123,117,184,195,39,248,137,210,243,32,140,147,7,231,97,128,1,26,143,201,253,240,73,65,186,44,155,87,245,10,28,220,144,66,172,136,48,230,118,167,44,173,238,133,223,141,253,225,145,125,120,37,16,64,122,149,33,175,246,176,96,85,172,131,191,136,59,252,34,93,158,242,51,211,197,134,23,126,55,154,236,230,56,55,183,162,245,231,99,217,116,173,122,41,94,45,82,105,25,99,159,24,87,172,194,210,138,153,78,217,92,208,80,100,43,45,178,42,147,157,154,23,162,218,215,14,114,18,199,194,3,91,25,253,173,205,156,144,78,31,16,119,116,170,172,92,139,204,236,199,90,106,40,10,175,201,13,96,37,195,177,217,186,20,21,153,60,84,156,85,232,75,64,152,197,172,62,205,90,133,81,217,212,152,49,119,250,248,34,36,211,149,232,252,194,239,159,143,127,11,43,217,95,37,114,75,248,192,32,51,190,219,106,123,152,168,200,98,252,102,138,195,146,248,96,93,75,12,145,135,102,164,188,108,209,246,219,150,88,13,199,207,56,220,7,83,204,127,77,211,65,88,151,15,240,83,7,211,201,48,164,83,166,44,134,11,208,23,193,152,139,79,62,136,2,252,94,145,248,184,149,248,234,131,216,36,27,153,242,19,58,248,169,159,94,16,134,211,137,248,152,37,37,60,209,46,73,139,29,15,196,215,18,70,148,208,6,175,144,222,22,237,152,192,89,224,42,35,208,242,61,65,53,83,114,93,20,139,239,66,113,182,0,5,139,252,155,101,166,19,145,241,80,82,40,233,136,117,93,223,193,20,71,245,25,97,158,82,86,157,171,161,157,169,241,244,105,26,160,115,221,248,221,180,203,212,3,134,187,139,145,210,22,75,211,41,28,230,107,167,183,116,239,25,165,243,209,183,62,196,169,45,70,33,245,194,96,130,241,4,198,196,17,167,200,10,209,84,81,8,49,185,87,159,148,20,68,165,239,204,56,228,251,78,232,3,44,216,32,17,176,27,56,97,255,25,14,117,194,170,226,147,57,174,175,125,68,39,57,226,181,71,223,70,146,137,152,140,78,224,40,25,89,32,27,113,7,129,131,29,136,38,63,93,236,235,227,148,17,190,117,53,18,6,19,27,202,241,100,62,76,117,224,126,79,204,81,145,63,238,132,49,73,214,26,78,206,236,156,209,115,229,114,235,65,90,145,84,250,186,217,169,100,249,36,116,233,216,150,214,62,157,62,195,50,242,159,210,248,100,156,71,92,59,32,249,39,152,189,214,197,188,37,74,175,64,113,69,65,229,166,31,248,188,184,88,191,100,198,54,230,17,98,198,27,104,47,121,245,109,8,41,229,4,194,136,184,61,25,225,82,205,182,20,6,82,164,235,202,173,32,206,6,192,22,79,202,216,252,18,93,185,180,61,64,73,172,69,52,30,209,160,122,154,211,159,91,169,44,171,75,144,34,249,35,137,212,123,163,144,84,189,220,59,152,218,212,186,90,207,159,211,149,167,118,246,60,35,131,26,60,161,103,197,246,84,228,88,68,72,31,111,73,101,186,8,150,75,154,42,174,149,38,199,126,9,170,194,21,250,23,226,140,172,164,15,241,218,71,33,134,217,33,209,111,185,166,146,239,54,119,156,120,65,115,103,1,101,195,201,246,38,33,127,172,61,57,52,244,21,22,145,70,228,44,168,142,167,87,216,226,60,44,237,125,220,33,129,7,97,25,173,95,92,80,27,192,104,66,199,100,115,77,75,91,69,34,103,15,221,74,117,96,54,217,238,105,27,245,127,122,146,151,231,244,253,249,38,187,72,82,100,47,216,71,105,255,124,173,75,180,55,218,72,230,157,248,46,125,174,105,173,193,141,214,215,88,146,139,53,103,190,225,44,103,46,4,168,4,197,141,188,133,144,130,245,191,206,66,16,68,251,223,96,34,124,87,203,224,15,39,58,202,210,174,255,124,130,67,164,182,39,217,228,79,11,139,52,140,46,72,147,91,242,124,244,59,25,196,68,99,166,157,133,147,11,248,162,247,6,115,82,204,113,39,124,100,249,238,27,59,32,63,50,115,238,61,176,5,186,150,248,1,76,38,54,144,149,45,138,253,97,184,179,46,195,131,168,223,91,255,31,125,250,64,3,255,181,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 0000000..9d83ddf --- /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 0000000..281e680 --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,1057 @@ +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: + allOf: + - $ref: '#/components/schemas/TransitionData' + - $ref: '#/components/schemas/BulbId' + 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 + TransitionData: + allOf: + - $ref: '#/components/schemas/TransitionArgs' + - type: object + properties: + id: + readOnly: true + type: integer + last_sent: + readOnly: true + type: integer + description: Timestamp since last update was sent. + bulb: + readOnly: true + allOf: + - $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: + readOnly: true + allOf: + - $ref: '#/components/schemas/TransitionValue' + end_value: + readOnly: true + allOf: + - $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 + mqtt_debounce_delay: + type: integer + description: Controls how much time has to pass after the last status update was queued. + 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] + default_transition_period: + type: integer + description: | + Default number of milliseconds between transition packets. Set this value lower for more granular transitions, or higher if + you are having performance issues during transitions. + + 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 new file mode 100644 index 0000000..aebbaed --- /dev/null +++ b/lib/DataStructures/LinkedList.h @@ -0,0 +1,336 @@ +/* + ********* Adapted from: ********* + https://github.com/ivanseidel/LinkedList + Created by Ivan Seidel Gomes, March, 2013. + Released into the public domain. + ********************************* + + Changes: + - public access to ListNode (allows for splicing for LRU) + - doubly-linked + - remove caching stuff in favor of standard linked list iterating + - remove sorting +*/ + +#ifndef LinkedList_h +#define LinkedList_h + +#include + +template +struct ListNode { + T data; + ListNode *next; + ListNode *prev; +}; + +template +class LinkedList { + +protected: + size_t _size; + ListNode *root; + ListNode *last; + +public: + LinkedList(); + ~LinkedList(); + + /* + Returns current size of LinkedList + */ + virtual size_t size() const; + /* + Adds a T object in the specified index; + Unlink and link the LinkedList correcly; + Increment _size + */ + virtual bool add(int index, T); + /* + Adds a T object in the end of the LinkedList; + Increment _size; + */ + virtual bool add(T); + /* + Adds a T object in the start of the LinkedList; + Increment _size; + */ + virtual bool unshift(T); + /* + Set the object at index, with T; + Increment _size; + */ + virtual bool set(int index, T); + /* + Remove object at index; + If index is not reachable, returns false; + else, decrement _size + */ + virtual T remove(int index); + virtual void remove(ListNode* node); + /* + Remove last object; + */ + virtual T pop(); + /* + Remove first object; + */ + virtual T shift(); + /* + Get the index'th element on the list; + Return Element if accessible, + else, return false; + */ + virtual T get(int index); + + /* + Clear the entire array + */ + virtual void clear(); + + ListNode* getNode(int index); + virtual void spliceToFront(ListNode* node); + ListNode* getHead() { return root; } + T getLast() const { return last == NULL ? T() : last->data; } + +}; + + +template +void LinkedList::spliceToFront(ListNode* node) { + // Node is already root + if (node->prev == NULL) { + return; + } + + node->prev->next = node->next; + if (node->next != NULL) { + node->next->prev = node->prev; + } else { + last = node->prev; + } + + root->prev = node; + node->next = root; + node->prev = NULL; + root = node; +} + +// Initialize LinkedList with false values +template +LinkedList::LinkedList() +{ + root=NULL; + last=NULL; + _size=0; +} + +// Clear Nodes and free Memory +template +LinkedList::~LinkedList() +{ + ListNode* tmp; + while(root!=NULL) + { + tmp=root; + root=root->next; + delete tmp; + } + last = NULL; + _size=0; +} + +/* + Actualy "logic" coding +*/ + +template +ListNode* LinkedList::getNode(int index){ + + int _pos = 0; + ListNode* current = root; + + while(_pos < index && current){ + current = current->next; + + _pos++; + } + + return false; +} + +template +size_t LinkedList::size() const{ + return _size; +} + +template +bool LinkedList::add(int index, T _t){ + + if(index >= _size) + return add(_t); + + if(index == 0) + return unshift(_t); + + ListNode *tmp = new ListNode(), + *_prev = getNode(index-1); + tmp->data = _t; + tmp->next = _prev->next; + _prev->next = tmp; + + _size++; + + return true; +} + +template +bool LinkedList::add(T _t){ + + ListNode *tmp = new ListNode(); + tmp->data = _t; + tmp->next = NULL; + + if(root){ + // Already have elements inserted + last->next = tmp; + tmp->prev = last; + last = tmp; + }else{ + // First element being inserted + root = tmp; + last = tmp; + } + + _size++; + + return true; +} + +template +bool LinkedList::unshift(T _t){ + + if(_size == 0) + return add(_t); + + ListNode *tmp = new ListNode(); + tmp->next = root; + root->prev = tmp; + tmp->data = _t; + root = tmp; + + _size++; + + return true; +} + +template +bool LinkedList::set(int index, T _t){ + // Check if index position is in bounds + if(index < 0 || index >= _size) + return false; + + getNode(index)->data = _t; + return true; +} + +template +T LinkedList::pop(){ + if(_size <= 0) + return T(); + + if(_size >= 2){ + ListNode *tmp = last->prev; + T ret = tmp->next->data; + delete(tmp->next); + tmp->next = NULL; + last = tmp; + _size--; + return ret; + }else{ + // Only one element left on the list + T ret = root->data; + delete(root); + root = NULL; + last = NULL; + _size = 0; + return ret; + } +} + +template +T LinkedList::shift(){ + if(_size <= 0) + return T(); + + if(_size > 1){ + ListNode *_next = root->next; + T ret = root->data; + delete(root); + root = _next; + _size --; + + return ret; + }else{ + // Only one left, then pop() + return pop(); + } + +} + +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) + { + return T(); + } + + if(index == 0) + return shift(); + + if (index == _size-1) + { + return pop(); + } + + ListNode *tmp = getNode(index - 1); + ListNode *toDelete = tmp->next; + T ret = toDelete->data; + tmp->next = tmp->next->next; + delete(toDelete); + _size--; + return ret; +} + + +template +T LinkedList::get(int index){ + ListNode *tmp = getNode(index); + + return (tmp ? tmp->data : T()); +} + +template +void LinkedList::clear(){ + while(size() > 0) + shift(); +} +#endif diff --git a/lib/Helpers/IntParsing.h b/lib/Helpers/IntParsing.h new file mode 100644 index 0000000..bcc10ec --- /dev/null +++ b/lib/Helpers/IntParsing.h @@ -0,0 +1,73 @@ +#ifndef _INTPARSING_H +#define _INTPARSING_H + +#include + +template +const T strToHex(const char* s, size_t length) { + T value = 0; + T base = 1; + + for (int i = length-1; i >= 0; i--) { + const char c = s[i]; + + if (c >= '0' && c <= '9') { + value += ((c - '0') * base); + } else if (c >= 'a' && c <= 'f') { + value += ((c - 'a' + 10) * base); + } else if (c >= 'A' && c <= 'F') { + value += ((c - 'A' + 10) * base); + } else { + break; + } + + base <<= 4; + } + + return value; +} + +template +const T strToHex(const String& s) { + return strToHex(s.c_str(), s.length()); +} + +template +const T parseInt(const String& s) { + if (s.startsWith("0x")) { + return strToHex(s.substring(2)); + } else { + return s.toInt(); + } +} + +template +void hexStrToBytes(const char* s, const size_t sLen, T* buffer, size_t maxLen) { + int idx = 0; + + for (int i = 0; i < sLen && idx < maxLen; ) { + buffer[idx++] = strToHex(s+i, 2); + i+= 2; + + while (i < (sLen - 1) && s[i] == ' ') { + i++; + } + } +} + +class IntParsing { +public: + 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 && static_cast(p - buffer) < (maxLen - 3); i++) { + p += sprintf(p, "%02X", bytes[i]); + + if (i < (len - 1)) { + p += sprintf(p, " "); + } + } + } +}; + +#endif diff --git a/lib/Helpers/JsonHelpers.h b/lib/Helpers/JsonHelpers.h new file mode 100644 index 0000000..47ee6a7 --- /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/Size.h b/lib/Helpers/Size.h new file mode 100644 index 0000000..b7284b7 --- /dev/null +++ b/lib/Helpers/Size.h @@ -0,0 +1,11 @@ +#include + +#ifndef _SIZE_H +#define _SIZE_H + +template +size_t size(T(&)[sz]) { + return sz; +} + +#endif diff --git a/lib/Helpers/Units.h b/lib/Helpers/Units.h new file mode 100644 index 0000000..ea492cc --- /dev/null +++ b/lib/Helpers/Units.h @@ -0,0 +1,32 @@ +#include +#include + +#ifndef _UNITS_H +#define _UNITS_H + +// MiLight CCT bulbs range from 2700K-6500K, or ~370.3-153.8 mireds. +#define COLOR_TEMP_MAX_MIREDS 370 +#define COLOR_TEMP_MIN_MIREDS 153 + +class Units { +public: + template + static T rescale(T value, V newMax, float oldMax = 255.0) { + return round(value * (newMax / oldMax)); + } + + static uint8_t miredsToWhiteVal(uint16_t mireds, uint8_t maxValue = 255) { + return rescale( + constrain(mireds, COLOR_TEMP_MIN_MIREDS, COLOR_TEMP_MAX_MIREDS) - COLOR_TEMP_MIN_MIREDS, + maxValue, + (COLOR_TEMP_MAX_MIREDS - COLOR_TEMP_MIN_MIREDS) + ); + } + + static uint16_t whiteValToMireds(uint8_t value, uint8_t maxValue = 255) { + uint16_t scaled = rescale(value, (COLOR_TEMP_MAX_MIREDS - COLOR_TEMP_MIN_MIREDS), maxValue); + return COLOR_TEMP_MIN_MIREDS + scaled; + } +}; + +#endif diff --git a/lib/LEDStatus/LEDStatus.cpp b/lib/LEDStatus/LEDStatus.cpp new file mode 100644 index 0000000..3cd09bb --- /dev/null +++ b/lib/LEDStatus/LEDStatus.cpp @@ -0,0 +1,225 @@ +#include + +// constructor defines which pin the LED is attached to +LEDStatus::LEDStatus(int8_t ledPin) { + // if pin negative, reverse and set inverse on pin outputs + if (ledPin < 0) { + ledPin = -ledPin; + _inverse = true; + } else { + _inverse = false; + } + // set up the pin + _ledPin = ledPin; + pinMode(_ledPin, OUTPUT); + digitalWrite(_ledPin, _pinState(LOW)); + _timer = millis(); +} + +// change pin at runtime +void LEDStatus::changePin(int8_t ledPin) { + bool inverse; + // if pin negative, reverse and set inverse on pin outputs + if (ledPin < 0) { + ledPin = -ledPin; + inverse = true; + } else { + inverse = false; + } + + if ((ledPin != _ledPin) || (inverse != _inverse)) { + // make sure old pin is off + digitalWrite(_ledPin, _pinState(LOW)); + _ledPin = ledPin; + _inverse = inverse; + // and make sure new pin is also off + pinMode(_ledPin, OUTPUT); + digitalWrite(_ledPin, _pinState(LOW)); + } +} + + +// identify how to flash the LED by mode, continuously until changed +void LEDStatus::continuous(LEDStatus::LEDMode mode) { + uint16_t ledOffMs, ledOnMs; + _modeToTime(mode, ledOffMs, ledOnMs); + continuous(ledOffMs, ledOnMs); +} + +// identify how to flash the LED by on/off times (in ms), continuously until changed +void LEDStatus::continuous(uint16_t ledOffMs, uint16_t ledOnMs) { + _continuousOffMs = ledOffMs; + _continuousOnMs = ledOnMs; + _continuousCurrentlyOn = false; + // reset LED to off + if (_ledPin > 0) { + digitalWrite(_ledPin, _pinState(LOW)); + } + // restart timer + _timer = millis(); +} + +// identify a one-shot LED action (overrides continuous until done) by mode +void LEDStatus::oneshot(LEDStatus::LEDMode mode, uint8_t count) { + uint16_t ledOffMs, ledOnMs; + _modeToTime(mode, ledOffMs, ledOnMs); + oneshot(ledOffMs, ledOnMs, count); +} + +// identify a one-shot LED action (overrides continuous until done) by times (in ms) +void LEDStatus::oneshot(uint16_t ledOffMs, uint16_t ledOnMs, uint8_t count) { + _oneshotOffMs = ledOffMs; + _oneshotOnMs = ledOnMs; + _oneshotCountRemaining = count; + _oneshotCurrentlyOn = false; + // reset LED to off + if (_ledPin > 0) { + digitalWrite(_ledPin, _pinState(LOW)); + } + // restart timer + _timer = millis(); +} + +// 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? + if (_ledPin == 0) { + return; + } + + // are we currently running a one-shot? + if (_oneshotCountRemaining > 0) { + if (_oneshotCurrentlyOn) { + if ((_timer + _oneshotOnMs) < millis()) { + if (_oneshotOffMs > 0) { + digitalWrite(_ledPin, _pinState(LOW)); + } + _oneshotCurrentlyOn = false; + --_oneshotCountRemaining; + if (_oneshotCountRemaining == 0) { + _continuousCurrentlyOn = false; + } + _timer += _oneshotOnMs; + } + } else { + if ((_timer + _oneshotOffMs) < millis()) { + if (_oneshotOnMs > 0) { + digitalWrite(_ledPin, _pinState(HIGH)); + } + _oneshotCurrentlyOn = true; + _timer += _oneshotOffMs; + } + } + } else { + // operate using continuous + if (_continuousCurrentlyOn) { + if ((_timer + _continuousOnMs) < millis()) { + if (_continuousOffMs > 0) { + digitalWrite(_ledPin, _pinState(LOW)); + } + _continuousCurrentlyOn = false; + _timer += _continuousOnMs; + } + } else { + if ((_timer + _continuousOffMs) < millis()) { + if (_continuousOnMs > 0) { + digitalWrite(_ledPin, _pinState(HIGH)); + } + _continuousCurrentlyOn = true; + _timer += _continuousOffMs; + } + } + } +} + +// helper function to convert an LEDMode enum to a string +String LEDStatus::LEDModeToString(LEDStatus::LEDMode mode) { + switch (mode) { + case LEDStatus::LEDMode::Off: + return "Off"; + case LEDStatus::LEDMode::SlowToggle: + return "Slow toggle"; + case LEDStatus::LEDMode::FastToggle: + return "Fast toggle"; + case LEDStatus::LEDMode::SlowBlip: + return "Slow blip"; + case LEDStatus::LEDMode::FastBlip: + return "Fast blip"; + case LEDStatus::LEDMode::Flicker: + return "Flicker"; + case LEDStatus::LEDMode::On: + return "On"; + default: + return "Unknown"; + } +} + +// helper function to convert a string to an LEDMode enum (note, mismatch returns LedMode::Unknown) +LEDStatus::LEDMode LEDStatus::stringToLEDMode(String mode) { + if (mode == "Off") + return LEDStatus::LEDMode::Off; + if (mode == "Slow toggle") + return LEDStatus::LEDMode::SlowToggle; + if (mode == "Fast toggle") + return LEDStatus::LEDMode::FastToggle; + if (mode == "Slow blip") + return LEDStatus::LEDMode::SlowBlip; + if (mode == "Fast blip") + return LEDStatus::LEDMode::FastBlip; + if (mode == "Flicker") + return LEDStatus::LEDMode::Flicker; + if (mode == "On") + return LEDStatus::LEDMode::On; + // unable to match... + return LEDStatus::LEDMode::Unknown; +} + + +// private helper converts mode to on/off times in ms +void LEDStatus::_modeToTime(LEDStatus::LEDMode mode, uint16_t& ledOffMs, uint16_t& ledOnMs) { + switch (mode) { + case LEDMode::Off: + ledOffMs = 1000; + ledOnMs = 0; + break; + case LEDMode::SlowToggle: + ledOffMs = 1000; + ledOnMs = 1000; + break; + case LEDMode::FastToggle: + ledOffMs = 100; + ledOnMs = 100; + break; + case LEDMode::SlowBlip: + ledOffMs = 1500; + ledOnMs = 50; + break; + case LEDMode::FastBlip: + ledOffMs = 333; + ledOnMs = 50; + break; + case LEDMode::On: + ledOffMs = 0; + ledOnMs = 1000; + break; + case LEDMode::Flicker: + ledOffMs = 50; + ledOnMs = 30; + break; + default: + Serial.printf_P(PSTR("LEDStatus::_modeToTime: Uknown LED mode %d\n"), mode); + ledOffMs = 500; + ledOnMs = 2000; + break; + } +} + +// private helper to optionally inverse the LED +uint8_t LEDStatus::_pinState(uint8_t val) { + if (_inverse) { + return (val == LOW) ? HIGH : LOW; + } + return val; +} + diff --git a/lib/LEDStatus/LEDStatus.h b/lib/LEDStatus/LEDStatus.h new file mode 100644 index 0000000..a0f261c --- /dev/null +++ b/lib/LEDStatus/LEDStatus.h @@ -0,0 +1,49 @@ +#include +#include + +#ifndef _LED_STATUS_H +#define _LED_STATUS_H + +class LEDStatus { + public: + enum class LEDMode { + Off, + SlowToggle, + FastToggle, + SlowBlip, + FastBlip, + Flicker, + On, + Unknown + }; + LEDStatus(int8_t ledPin); + void changePin(int8_t ledPin); + void continuous(LEDMode mode); + void continuous(uint16_t ledOffMs, uint16_t ledOnMs); + void oneshot(LEDMode mode, uint8_t count = 1); + void oneshot(uint16_t ledOffMs, uint16_t ledOnMs, uint8_t count = 1); + + static String LEDModeToString(LEDMode mode); + static LEDMode stringToLEDMode(String mode); + + void handle(); + + private: + void _modeToTime(LEDMode mode, uint16_t& ledOffMs, uint16_t& ledOnMs); + uint8_t _pinState(uint8_t val); + uint8_t _ledPin; + bool _inverse; + + uint16_t _continuousOffMs = 1000; + uint16_t _continuousOnMs = 0; + bool _continuousCurrentlyOn = false; + + uint16_t _oneshotOffMs; + uint16_t _oneshotOnMs; + uint8_t _oneshotCountRemaining = 0; + bool _oneshotCurrentlyOn = false; + + unsigned long _timer = 0; +}; + +#endif \ No newline at end of file diff --git a/lib/MQTT/BulbStateUpdater.cpp b/lib/MQTT/BulbStateUpdater.cpp new file mode 100644 index 0000000..7bd8ce5 --- /dev/null +++ b/lib/MQTT/BulbStateUpdater.cpp @@ -0,0 +1,59 @@ +#include + +BulbStateUpdater::BulbStateUpdater(Settings& settings, MqttClient& mqttClient, GroupStateStore& stateStore) + : settings(settings), + mqttClient(mqttClient), + stateStore(stateStore), + lastFlush(0), + lastQueue(0), + enabled(true) +{ } + +void BulbStateUpdater::enable() { + this->enabled = true; +} + +void BulbStateUpdater::disable() { + this->enabled = false; +} + +void BulbStateUpdater::enqueueUpdate(BulbId bulbId, GroupState& groupState) { + staleGroups.push(bulbId); + //Remember time, when queue was added for debounce delay + lastQueue = millis(); + +} + +void BulbStateUpdater::loop() { + while (canFlush() && staleGroups.size() > 0) { + BulbId bulbId = staleGroups.shift(); + GroupState* groupState = stateStore.get(bulbId); + + if (groupState->isMqttDirty()) { + flushGroup(bulbId, *groupState); + groupState->clearMqttDirty(); + } + } +} + +inline void BulbStateUpdater::flushGroup(BulbId bulbId, GroupState& state) { + char buffer[200]; + StaticJsonDocument<200> json; + JsonObject message = json.to(); + + state.applyState(message, bulbId, settings.groupStateFields); + serializeJson(json, buffer); + + mqttClient.sendState( + *MiLightRemoteConfig::fromType(bulbId.deviceType), + bulbId.deviceId, + bulbId.groupId, + buffer + ); + + lastFlush = millis(); +} + +inline bool BulbStateUpdater::canFlush() const { + return enabled && (millis() > (lastFlush + settings.mqttStateRateLimit) && millis() > (lastQueue + settings.mqttDebounceDelay)); +} diff --git a/lib/MQTT/BulbStateUpdater.h b/lib/MQTT/BulbStateUpdater.h new file mode 100644 index 0000000..c89851c --- /dev/null +++ b/lib/MQTT/BulbStateUpdater.h @@ -0,0 +1,35 @@ +/** + * Enqueues updated bulb states and flushes them at the configured interval. + */ + +#include +#include +#include +#include + +#ifndef BULB_STATE_UPDATER +#define BULB_STATE_UPDATER + +class BulbStateUpdater { +public: + BulbStateUpdater(Settings& settings, MqttClient& mqttClient, GroupStateStore& stateStore); + + void enqueueUpdate(BulbId bulbId, GroupState& groupState); + void loop(); + void enable(); + void disable(); + +private: + Settings& settings; + MqttClient& mqttClient; + GroupStateStore& stateStore; + CircularBuffer staleGroups; + unsigned long lastFlush; + unsigned long lastQueue; + bool enabled; + + inline void flushGroup(BulbId bulbId, GroupState& state); + inline bool canFlush() const; +}; + +#endif diff --git a/lib/MQTT/HomeAssistantDiscoveryClient.cpp b/lib/MQTT/HomeAssistantDiscoveryClient.cpp new file mode 100644 index 0000000..5687a18 --- /dev/null +++ b/lib/MQTT/HomeAssistantDiscoveryClient.cpp @@ -0,0 +1,177 @@ +#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); + + char uniqidBuffer[30]; + sprintf_P(uniqidBuffer, PSTR("%X-%s"), ESP.getChipId(), alias); + + 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); + config[F("uniq_id")] = mqttClient->bindTopicString(uniqidBuffer, 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)); + } +} diff --git a/lib/MQTT/HomeAssistantDiscoveryClient.h b/lib/MQTT/HomeAssistantDiscoveryClient.h new file mode 100644 index 0000000..ee6fc79 --- /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 new file mode 100644 index 0000000..095d1eb --- /dev/null +++ b/lib/MQTT/MqttClient.cpp @@ -0,0 +1,320 @@ +#include +#include +#include +#include +#include +#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) + : mqttClient(tcpClient), + milightClient(milightClient), + settings(settings), + lastConnectAttempt(0), + connected(false) +{ + String strDomain = settings.mqttServer(); + this->domain = new char[strDomain.length() + 1]; + strcpy(this->domain, strDomain.c_str()); +} + +MqttClient::~MqttClient() { + 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( + PSTR("MqttClient - Connecting to: %s\nparsed:%s:%u\n"), + settings._mqttServer.c_str(), + settings.mqttServer().c_str(), + settings.mqttPort() + ); +#endif + + mqttClient.setServer(this->domain, settings.mqttPort()); + mqttClient.setCallback( + [this](char* topic, byte* payload, int length) { + this->publishCallback(topic, payload, length); + } + ); + reconnect(); +} + +bool MqttClient::connect() { + char nameBuffer[30]; + sprintf_P(nameBuffer, PSTR("milight-hub-%u"), ESP.getChipId()); + +#ifdef MQTT_DEBUG + Serial.println(F("MqttClient - connecting using name")); + Serial.println(nameBuffer); +#endif + + 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); + } +} + +void MqttClient::sendBirthMessage() { + if (settings.mqttClientStatusTopic.length() > 0) { + String aboutStr = generateConnectionStatusMessage(STATUS_CONNECTED); + mqttClient.publish(settings.mqttClientStatusTopic.c_str(), aboutStr.c_str(), true); + } +} + +void MqttClient::reconnect() { + if (lastConnectAttempt > 0 && (millis() - lastConnectAttempt) < MQTT_CONNECTION_ATTEMPT_FREQUENCY) { + return; + } + + if (! mqttClient.connected()) { + if (connect()) { + subscribe(); + sendBirthMessage(); + +#ifdef MQTT_DEBUG + Serial.println(F("MqttClient - Successfully connected to MQTT server")); +#endif + } else { + Serial.print(F("ERROR: Failed to connect to MQTT server rc=")); + Serial.println(mqttClient.state()); + } + } + + lastConnectAttempt = millis(); +} + +void MqttClient::handleClient() { + reconnect(); + 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) { + publish(settings.mqttUpdateTopicPattern, remoteConfig, deviceId, groupId, update, false); +} + +void MqttClient::sendState(const MiLightRemoteConfig& remoteConfig, uint16_t deviceId, uint16_t groupId, const char* update) { + publish(settings.mqttStateTopicPattern, remoteConfig, deviceId, groupId, update, true); +} + +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()); +} + +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( + const String& _topic, + const MiLightRemoteConfig &remoteConfig, + uint16_t deviceId, + uint16_t groupId, + const char* message, + const bool _retain +) { + if (_topic.length() == 0) { + return; + } + + BulbId bulbId(deviceId, groupId, remoteConfig.type); + String topic = bindTopicString(_topic, bulbId); + const bool retain = _retain && this->settings.mqttRetain; + +#ifdef MQTT_DEBUG + printf("MqttClient - publishing update to %s\n", topic.c_str()); +#endif + + send(topic.c_str(), message, retain); +} + +void MqttClient::publishCallback(char* topic, byte* payload, int length) { + uint16_t deviceId = 0; + uint8_t groupId = 0; + const MiLightRemoteConfig* config = &FUT092Config; + char cstrPayload[length + 1]; + cstrPayload[length] = 0; + memcpy(cstrPayload, payload, sizeof(byte)*length); + +#ifdef MQTT_DEBUG + printf("MqttClient - Got message on topic: %s\n%s\n", topic, cstrPayload); +#endif + + char topicPattern[settings.mqttTopicPattern.length()]; + strcpy(topicPattern, settings.mqttTopicPattern.c_str()); + + TokenIterator patternIterator(topicPattern, settings.mqttTopicPattern.length(), '/'); + TokenIterator topicIterator(topic, strlen(topic), '/'); + UrlTokenBindings tokenBindings(patternIterator, topicIterator); + + if (tokenBindings.hasBinding("device_alias")) { + String alias = tokenBindings.get("device_alias"); + auto itr = settings.groupIdAliases.find(alias); + + 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 { + 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; + } + + StaticJsonDocument<400> buffer; + deserializeJson(buffer, cstrPayload); + JsonObject obj = buffer.as(); + +#ifdef MQTT_DEBUG + printf("MqttClient - device %04X, group %u\n", deviceId, groupId); +#endif + + milightClient->prepare(config, deviceId, groupId); + milightClient->update(obj); +} + +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 new file mode 100644 index 0000000..dc601c4 --- /dev/null +++ b/lib/MQTT/MqttClient.h @@ -0,0 +1,61 @@ +#include +#include +#include +#include +#include + +#ifndef MQTT_CONNECTION_ATTEMPT_FREQUENCY +#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(); + + void begin(); + void handleClient(); + 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; + 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); + void publish( + const String& topic, + const MiLightRemoteConfig& remoteConfig, + uint16_t deviceId, + uint16_t groupId, + const char* update, + const bool retain = false + ); + + String generateConnectionStatusMessage(const char* status); +}; + +#endif diff --git a/lib/MiLight/CctPacketFormatter.cpp b/lib/MiLight/CctPacketFormatter.cpp new file mode 100644 index 0000000..519841a --- /dev/null +++ b/lib/MiLight/CctPacketFormatter.cpp @@ -0,0 +1,225 @@ +#include +#include + +static const uint8_t CCT_PROTOCOL_ID = 0x5A; + +bool CctPacketFormatter::canHandle(const uint8_t *packet, const size_t len) { + return len == packetLength && packet[0] == CCT_PROTOCOL_ID; +} + +void CctPacketFormatter::initializePacket(uint8_t* packet) { + size_t packetPtr = 0; + + // Byte 0: Packet length = 7 bytes + + // Byte 1: CCT protocol + packet[packetPtr++] = CCT_PROTOCOL_ID; + + // Byte 2 and 3: Device ID + packet[packetPtr++] = deviceId >> 8; + packet[packetPtr++] = deviceId & 0xFF; + + // Byte 4: Zone + packet[packetPtr++] = groupId; + + // Byte 5: Bulb command, filled in later + packet[packetPtr++] = 0; + + // Byte 6: Packet sequence number 0..255 + packet[packetPtr++] = sequenceNum++; + + // Byte 7: Checksum over previous bytes, including packet length = 7 + // The checksum will be calculated when setting the command field + packet[packetPtr++] = 0; + + // Byte 8: CRC LSB + // Byte 9: CRC MSB +} + +void CctPacketFormatter::finalizePacket(uint8_t* packet) { + uint8_t checksum; + + // Calculate checksum over packet length .. sequenceNum + checksum = 7; // Packet length is not part of packet + for (uint8_t i = 0; i < 6; i++) { + checksum += currentPacket[i]; + } + // Store the checksum in the sixth byte + currentPacket[6] = checksum; +} + +void CctPacketFormatter::updateBrightness(uint8_t value) { + const GroupState* state = this->stateStore->get(deviceId, groupId, MiLightRemoteType::REMOTE_TYPE_CCT); + int8_t knownValue = (state != NULL && state->isSetBrightness()) ? state->getBrightness() / CCT_INTERVALS : -1; + + valueByStepFunction( + &PacketFormatter::increaseBrightness, + &PacketFormatter::decreaseBrightness, + CCT_INTERVALS, + value / CCT_INTERVALS, + knownValue + ); +} + +void CctPacketFormatter::updateTemperature(uint8_t value) { + const GroupState* state = this->stateStore->get(deviceId, groupId, MiLightRemoteType::REMOTE_TYPE_CCT); + int8_t knownValue = (state != NULL && state->isSetKelvin()) ? state->getKelvin() / CCT_INTERVALS : -1; + + valueByStepFunction( + &PacketFormatter::increaseTemperature, + &PacketFormatter::decreaseTemperature, + CCT_INTERVALS, + value / CCT_INTERVALS, + knownValue + ); +} + +void CctPacketFormatter::command(uint8_t command, uint8_t arg) { + pushPacket(); + if (held) { + command |= 0x80; + } + currentPacket[CCT_COMMAND_INDEX] = command; +} + +void CctPacketFormatter::updateStatus(MiLightStatus status, uint8_t groupId) { + command(getCctStatusButton(groupId, status), 0); +} + +void CctPacketFormatter::increaseTemperature() { + command(CCT_TEMPERATURE_UP, 0); +} + +void CctPacketFormatter::decreaseTemperature() { + command(CCT_TEMPERATURE_DOWN, 0); +} + +void CctPacketFormatter::increaseBrightness() { + command(CCT_BRIGHTNESS_UP, 0); +} + +void CctPacketFormatter::decreaseBrightness() { + command(CCT_BRIGHTNESS_DOWN, 0); +} + +void CctPacketFormatter::enableNightMode() { + command(getCctStatusButton(groupId, OFF) | 0x10, 0); +} + +uint8_t CctPacketFormatter::getCctStatusButton(uint8_t groupId, MiLightStatus status) { + uint8_t button = 0; + + if (status == ON) { + switch(groupId) { + case 0: + button = CCT_ALL_ON; + break; + case 1: + button = CCT_GROUP_1_ON; + break; + case 2: + button = CCT_GROUP_2_ON; + break; + case 3: + button = CCT_GROUP_3_ON; + break; + case 4: + button = CCT_GROUP_4_ON; + break; + } + } else { + switch(groupId) { + case 0: + button = CCT_ALL_OFF; + break; + case 1: + button = CCT_GROUP_1_OFF; + break; + case 2: + button = CCT_GROUP_2_OFF; + break; + case 3: + button = CCT_GROUP_3_OFF; + break; + case 4: + button = CCT_GROUP_4_OFF; + break; + } + } + + return button; +} + +uint8_t CctPacketFormatter::cctCommandIdToGroup(uint8_t command) { + switch (command & 0xF) { + case CCT_GROUP_1_ON: + case CCT_GROUP_1_OFF: + return 1; + case CCT_GROUP_2_ON: + case CCT_GROUP_2_OFF: + return 2; + case CCT_GROUP_3_ON: + case CCT_GROUP_3_OFF: + return 3; + case CCT_GROUP_4_ON: + case CCT_GROUP_4_OFF: + return 4; + case CCT_ALL_ON: + case CCT_ALL_OFF: + return 0; + } + + return 255; +} + +MiLightStatus CctPacketFormatter::cctCommandToStatus(uint8_t command) { + switch (command & 0xF) { + case CCT_GROUP_1_ON: + case CCT_GROUP_2_ON: + case CCT_GROUP_3_ON: + case CCT_GROUP_4_ON: + case CCT_ALL_ON: + return ON; + case CCT_GROUP_1_OFF: + case CCT_GROUP_2_OFF: + 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) { + uint8_t command = packet[CCT_COMMAND_INDEX] & 0x7F; + + uint8_t onOffGroupId = cctCommandIdToGroup(command); + BulbId bulbId( + (packet[1] << 8) | packet[2], + onOffGroupId < 255 ? onOffGroupId : packet[3], + REMOTE_TYPE_CCT + ); + + // 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[GroupStateFieldNames::COMMAND] = "brightness_down"; + } else if (command == CCT_BRIGHTNESS_UP) { + result[GroupStateFieldNames::COMMAND] = "brightness_up"; + } else if (command == CCT_TEMPERATURE_DOWN) { + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::TEMPERATURE_DOWN; + } else if (command == CCT_TEMPERATURE_UP) { + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::TEMPERATURE_UP; + } else { + result["button_id"] = command; + } + + return bulbId; +} + +void CctPacketFormatter::format(uint8_t const* packet, char* buffer) { + PacketFormatter::formatV1Packet(packet, buffer); +} diff --git a/lib/MiLight/CctPacketFormatter.h b/lib/MiLight/CctPacketFormatter.h new file mode 100644 index 0000000..c0ac96a --- /dev/null +++ b/lib/MiLight/CctPacketFormatter.h @@ -0,0 +1,56 @@ +#include + +#ifndef _CCT_PACKET_FORMATTER_H +#define _CCT_PACKET_FORMATTER_H + +#define CCT_COMMAND_INDEX 4 +#define CCT_INTERVALS 10 + +enum MiLightCctButton { + CCT_ALL_ON = 0x05, + CCT_ALL_OFF = 0x09, + CCT_GROUP_1_ON = 0x08, + CCT_GROUP_1_OFF = 0x0B, + CCT_GROUP_2_ON = 0x0D, + CCT_GROUP_2_OFF = 0x03, + CCT_GROUP_3_ON = 0x07, + CCT_GROUP_3_OFF = 0x0A, + CCT_GROUP_4_ON = 0x02, + CCT_GROUP_4_OFF = 0x06, + CCT_BRIGHTNESS_DOWN = 0x04, + CCT_BRIGHTNESS_UP = 0x0C, + CCT_TEMPERATURE_UP = 0x0E, + CCT_TEMPERATURE_DOWN = 0x0F +}; + +class CctPacketFormatter : public PacketFormatter { +public: + CctPacketFormatter() + : PacketFormatter(REMOTE_TYPE_CCT, 7, 20) + { } + + virtual bool canHandle(const uint8_t* packet, const size_t len); + + virtual void updateStatus(MiLightStatus status, uint8_t groupId); + virtual void command(uint8_t command, uint8_t arg); + + virtual void updateTemperature(uint8_t value); + virtual void increaseTemperature(); + virtual void decreaseTemperature(); + + virtual void updateBrightness(uint8_t value); + virtual void increaseBrightness(); + virtual void decreaseBrightness(); + virtual void enableNightMode(); + + 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); + + static uint8_t getCctStatusButton(uint8_t groupId, MiLightStatus status); + static uint8_t cctCommandIdToGroup(uint8_t command); + static MiLightStatus cctCommandToStatus(uint8_t command); +}; + +#endif diff --git a/lib/MiLight/FUT020PacketFormatter.cpp b/lib/MiLight/FUT020PacketFormatter.cpp new file mode 100644 index 0000000..bca30b2 --- /dev/null +++ b/lib/MiLight/FUT020PacketFormatter.cpp @@ -0,0 +1,88 @@ +#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() / FUT02xPacketFormatter::NUM_BRIGHTNESS_INTERVALS + : -1; + + valueByStepFunction( + &PacketFormatter::increaseBrightness, + &PacketFormatter::decreaseBrightness, + FUT02xPacketFormatter::NUM_BRIGHTNESS_INTERVALS, + value / FUT02xPacketFormatter::NUM_BRIGHTNESS_INTERVALS, + knownValue + ); +} + +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 0000000..11f8eea --- /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 0000000..a810630 --- /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 0000000..42b5c5e --- /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 = 10; + + 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 new file mode 100644 index 0000000..c9fecc6 --- /dev/null +++ b/lib/MiLight/FUT089PacketFormatter.cpp @@ -0,0 +1,156 @@ +#include +#include +#include +#include + +void FUT089PacketFormatter::modeSpeedDown() { + command(FUT089_ON, FUT089_MODE_SPEED_DOWN); +} + +void FUT089PacketFormatter::modeSpeedUp() { + command(FUT089_ON, FUT089_MODE_SPEED_UP); +} + +void FUT089PacketFormatter::updateMode(uint8_t mode) { + command(FUT089_MODE, mode); +} + +void FUT089PacketFormatter::updateBrightness(uint8_t brightness) { + command(FUT089_BRIGHTNESS, brightness); +} + +// change the hue (which may also change to color mode). +void FUT089PacketFormatter::updateHue(uint16_t value) { + uint8_t remapped = Units::rescale(value, 255, 360); + updateColorRaw(remapped); +} + +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 +// 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 + const GroupState* ourState = this->stateStore->get(this->deviceId, this->groupId, REMOTE_TYPE_FUT089); + BulbMode originalBulbMode; + + if (ourState != NULL) { + originalBulbMode = ourState->getBulbMode(); + + // are we already in white? If not, change to white + if (originalBulbMode != BulbMode::BULB_MODE_WHITE) { + updateColorWhite(); + } + } + + // now make the temperature change + command(FUT089_KELVIN, 100 - value); + + // and return to our original mode + if (ourState != NULL && (settings->enableAutomaticModeSwitching) && (originalBulbMode != BulbMode::BULB_MODE_WHITE)) { + switchMode(*ourState, originalBulbMode); + } +} + +// 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 + const GroupState* ourState = this->stateStore->get(this->deviceId, this->groupId, REMOTE_TYPE_FUT089); + BulbMode originalBulbMode = BulbMode::BULB_MODE_WHITE; + + if (ourState != NULL) { + originalBulbMode = ourState->getBulbMode(); + } + + // are we already in color? If not, we need to flip modes + if (ourState != NULL && (settings->enableAutomaticModeSwitching) && (originalBulbMode != BulbMode::BULB_MODE_COLOR)) { + updateHue(ourState->getHue()); + } + + // now make the saturation change + command(FUT089_SATURATION, 100 - value); + + // and revert back if necessary + if (ourState != NULL && (settings->enableAutomaticModeSwitching) && (originalBulbMode != BulbMode::BULB_MODE_COLOR)) { + switchMode(*ourState, originalBulbMode); + } +} + +void FUT089PacketFormatter::updateColorWhite() { + command(FUT089_ON, FUT089_WHITE_MODE); +} + +void FUT089PacketFormatter::enableNightMode() { + uint8_t arg = groupCommandArg(OFF, groupId); + command(FUT089_ON | 0x80, arg); +} + +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); + return fakeId; + } + + uint8_t packetCopy[V2_PACKET_LEN]; + memcpy(packetCopy, packet, V2_PACKET_LEN); + V2RFEncoding::decodeV2Packet(packetCopy); + + BulbId bulbId( + (packetCopy[2] << 8) | packetCopy[3], + packetCopy[7], + REMOTE_TYPE_FUT089 + ); + + uint8_t command = (packetCopy[V2_COMMAND_INDEX] & 0x7F); + uint8_t arg = packetCopy[V2_ARGUMENT_INDEX]; + + if (command == FUT089_ON) { + if ((packetCopy[V2_COMMAND_INDEX] & 0x80) == 0x80) { + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::NIGHT_MODE; + } else if (arg == FUT089_MODE_SPEED_DOWN) { + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::MODE_SPEED_DOWN; + } else if (arg == FUT089_MODE_SPEED_UP) { + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::MODE_SPEED_UP; + } else if (arg == FUT089_WHITE_MODE) { + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::SET_WHITE; + } else if (arg <= 8) { // Group is not reliably encoded in group byte. Extract from arg byte + result[GroupStateFieldNames::STATE] = "ON"; + bulbId.groupId = arg; + } else if (arg >= 9 && arg <= 17) { + 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[GroupStateFieldNames::HUE] = hue; + } else if (command == FUT089_BRIGHTNESS) { + uint8_t level = constrain(arg, 0, 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[GroupStateFieldNames::SATURATION] = 100 - constrain(arg, 0, 100); + } else { + result[GroupStateFieldNames::COLOR_TEMP] = Units::whiteValToMireds(100 - arg, 100); + } + } else if (command == FUT089_MODE) { + result[GroupStateFieldNames::MODE] = arg; + } else { + result["button_id"] = command; + result["argument"] = arg; + } + + return bulbId; +} diff --git a/lib/MiLight/FUT089PacketFormatter.h b/lib/MiLight/FUT089PacketFormatter.h new file mode 100644 index 0000000..71d7583 --- /dev/null +++ b/lib/MiLight/FUT089PacketFormatter.h @@ -0,0 +1,45 @@ +#include + +#ifndef _FUT089_PACKET_FORMATTER_H +#define _FUT089_PACKET_FORMATTER_H + +#define FUT089_COLOR_OFFSET 0 + +enum MiLightFUT089Command { + FUT089_ON = 0x01, + FUT089_OFF = 0x01, + FUT089_COLOR = 0x02, + FUT089_BRIGHTNESS = 0x05, + FUT089_MODE = 0x06, + FUT089_KELVIN = 0x07, // Controls Kelvin when in White mode + FUT089_SATURATION = 0x07 // Controls Saturation when in Color mode +}; + +enum MiLightFUT089Arguments { + FUT089_MODE_SPEED_UP = 0x12, + FUT089_MODE_SPEED_DOWN = 0x13, + FUT089_WHITE_MODE = 0x14 +}; + +class FUT089PacketFormatter : public V2PacketFormatter { +public: + FUT089PacketFormatter() + : V2PacketFormatter(REMOTE_TYPE_FUT089, 0x25, 8) // protocol is 0x25, and there are 8 groups + { } + + virtual void updateBrightness(uint8_t value); + virtual void updateHue(uint16_t value); + virtual void updateColorRaw(uint8_t value); + virtual void updateColorWhite(); + virtual void updateTemperature(uint8_t value); + virtual void updateSaturation(uint8_t value); + virtual void enableNightMode(); + + virtual void modeSpeedDown(); + virtual void modeSpeedUp(); + virtual void updateMode(uint8_t mode); + + virtual BulbId parsePacket(const uint8_t* packet, JsonObject result); +}; + +#endif diff --git a/lib/MiLight/FUT091PacketFormatter.cpp b/lib/MiLight/FUT091PacketFormatter.cpp new file mode 100644 index 0000000..816e377 --- /dev/null +++ b/lib/MiLight/FUT091PacketFormatter.cpp @@ -0,0 +1,58 @@ +#include +#include +#include +#include + +static const uint8_t BRIGHTNESS_SCALE_MAX = 0x97; +static const uint8_t KELVIN_SCALE_MAX = 0xC5; + +void FUT091PacketFormatter::updateBrightness(uint8_t value) { + command(static_cast(FUT091Command::BRIGHTNESS), V2PacketFormatter::tov2scale(value, BRIGHTNESS_SCALE_MAX, 2)); +} + +void FUT091PacketFormatter::updateTemperature(uint8_t value) { + command(static_cast(FUT091Command::KELVIN), V2PacketFormatter::tov2scale(value, KELVIN_SCALE_MAX, 2, false)); +} + +void FUT091PacketFormatter::enableNightMode() { + uint8_t arg = groupCommandArg(OFF, groupId); + command(static_cast(FUT091Command::ON_OFF) | 0x80, arg); +} + +BulbId FUT091PacketFormatter::parsePacket(const uint8_t *packet, JsonObject result) { + uint8_t packetCopy[V2_PACKET_LEN]; + memcpy(packetCopy, packet, V2_PACKET_LEN); + V2RFEncoding::decodeV2Packet(packetCopy); + + BulbId bulbId( + (packetCopy[2] << 8) | packetCopy[3], + packetCopy[7], + REMOTE_TYPE_FUT091 + ); + + uint8_t command = (packetCopy[V2_COMMAND_INDEX] & 0x7F); + uint8_t arg = packetCopy[V2_ARGUMENT_INDEX]; + + if (command == (uint8_t)FUT091Command::ON_OFF) { + if ((packetCopy[V2_COMMAND_INDEX] & 0x80) == 0x80) { + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::NIGHT_MODE; + } else if (arg < 5) { // Group is not reliably encoded in group byte. Extract from arg byte + result[GroupStateFieldNames::STATE] = "ON"; + bulbId.groupId = arg; + } else { + 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[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[GroupStateFieldNames::COLOR_TEMP] = Units::whiteValToMireds(kelvin, 100); + } else { + result["button_id"] = command; + result["argument"] = arg; + } + + return bulbId; +} diff --git a/lib/MiLight/FUT091PacketFormatter.h b/lib/MiLight/FUT091PacketFormatter.h new file mode 100644 index 0000000..0a472dd --- /dev/null +++ b/lib/MiLight/FUT091PacketFormatter.h @@ -0,0 +1,25 @@ +#include + +#ifndef _FUT091_PACKET_FORMATTER_H +#define _FUT091_PACKET_FORMATTER_H + +enum class FUT091Command { + ON_OFF = 0x01, + BRIGHTNESS = 0x2, + KELVIN = 0x03 +}; + +class FUT091PacketFormatter : public V2PacketFormatter { +public: + FUT091PacketFormatter() + : 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); +}; + +#endif diff --git a/lib/MiLight/MiLightClient.cpp b/lib/MiLight/MiLightClient.cpp new file mode 100644 index 0000000..527db42 --- /dev/null +++ b/lib/MiLight/MiLightClient.cpp @@ -0,0 +1,669 @@ +#include +#include +#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( + RadioSwitchboard& radioSwitchboard, + PacketSender& packetSender, + GroupStateStore* stateStore, + 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); +} + +void MiLightClient::prepare( + const MiLightRemoteConfig* config, + const uint16_t deviceId, + const uint8_t groupId +) { + this->currentRemote = config; + + if (deviceId >= 0 && groupId >= 0) { + currentRemote->packetFormatter->prepare(deviceId, groupId); + } + + this->currentState = stateStore->get(deviceId, groupId, config->type); +} + +void MiLightClient::prepare( + const MiLightRemoteType type, + const uint16_t deviceId, + const uint8_t groupId +) { + prepare(MiLightRemoteConfig::fromType(type), deviceId, groupId); +} + +void MiLightClient::updateColorRaw(const uint8_t color) { +#ifdef DEBUG_CLIENT_COMMANDS + Serial.printf_P(PSTR("MiLightClient::updateColorRaw: Change color to %d\n"), color); +#endif + currentRemote->packetFormatter->updateColorRaw(color); + flushPacket(); +} + +void MiLightClient::updateHue(const uint16_t hue) { +#ifdef DEBUG_CLIENT_COMMANDS + Serial.printf_P(PSTR("MiLightClient::updateHue: Change hue to %d\n"), hue); +#endif + currentRemote->packetFormatter->updateHue(hue); + flushPacket(); +} + +void MiLightClient::updateBrightness(const uint8_t brightness) { +#ifdef DEBUG_CLIENT_COMMANDS + Serial.printf_P(PSTR("MiLightClient::updateBrightness: Change brightness to %d\n"), brightness); +#endif + currentRemote->packetFormatter->updateBrightness(brightness); + flushPacket(); +} + +void MiLightClient::updateMode(uint8_t mode) { +#ifdef DEBUG_CLIENT_COMMANDS + Serial.printf_P(PSTR("MiLightClient::updateMode: Change mode to %d\n"), mode); +#endif + currentRemote->packetFormatter->updateMode(mode); + flushPacket(); +} + +void MiLightClient::nextMode() { +#ifdef DEBUG_CLIENT_COMMANDS + Serial.println(F("MiLightClient::nextMode: Switch to next mode")); +#endif + currentRemote->packetFormatter->nextMode(); + flushPacket(); +} + +void MiLightClient::previousMode() { +#ifdef DEBUG_CLIENT_COMMANDS + Serial.println(F("MiLightClient::previousMode: Switch to previous mode")); +#endif + currentRemote->packetFormatter->previousMode(); + flushPacket(); +} + +void MiLightClient::modeSpeedDown() { +#ifdef DEBUG_CLIENT_COMMANDS + Serial.println(F("MiLightClient::modeSpeedDown: Speed down\n")); +#endif + currentRemote->packetFormatter->modeSpeedDown(); + flushPacket(); +} +void MiLightClient::modeSpeedUp() { +#ifdef DEBUG_CLIENT_COMMANDS + Serial.println(F("MiLightClient::modeSpeedUp: Speed up")); +#endif + currentRemote->packetFormatter->modeSpeedUp(); + flushPacket(); +} + +void MiLightClient::updateStatus(MiLightStatus status, uint8_t groupId) { +#ifdef DEBUG_CLIENT_COMMANDS + Serial.printf_P(PSTR("MiLightClient::updateStatus: Status %s, groupId %d\n"), status == MiLightStatus::OFF ? "OFF" : "ON", groupId); +#endif + currentRemote->packetFormatter->updateStatus(status, groupId); + flushPacket(); +} + +void MiLightClient::updateStatus(MiLightStatus status) { +#ifdef DEBUG_CLIENT_COMMANDS + Serial.printf_P(PSTR("MiLightClient::updateStatus: Status %s\n"), status == MiLightStatus::OFF ? "OFF" : "ON"); +#endif + currentRemote->packetFormatter->updateStatus(status); + flushPacket(); +} + +void MiLightClient::updateSaturation(const uint8_t value) { +#ifdef DEBUG_CLIENT_COMMANDS + Serial.printf_P(PSTR("MiLightClient::updateSaturation: Saturation %d\n"), value); +#endif + currentRemote->packetFormatter->updateSaturation(value); + flushPacket(); +} + +void MiLightClient::updateColorWhite() { +#ifdef DEBUG_CLIENT_COMMANDS + Serial.println(F("MiLightClient::updateColorWhite: Color white")); +#endif + currentRemote->packetFormatter->updateColorWhite(); + flushPacket(); +} + +void MiLightClient::enableNightMode() { +#ifdef DEBUG_CLIENT_COMMANDS + Serial.println(F("MiLightClient::enableNightMode: Night mode")); +#endif + currentRemote->packetFormatter->enableNightMode(); + flushPacket(); +} + +void MiLightClient::pair() { +#ifdef DEBUG_CLIENT_COMMANDS + Serial.println(F("MiLightClient::pair: Pair")); +#endif + currentRemote->packetFormatter->pair(); + flushPacket(); +} + +void MiLightClient::unpair() { +#ifdef DEBUG_CLIENT_COMMANDS + Serial.println(F("MiLightClient::unpair: Unpair")); +#endif + currentRemote->packetFormatter->unpair(); + flushPacket(); +} + +void MiLightClient::increaseBrightness() { +#ifdef DEBUG_CLIENT_COMMANDS + Serial.println(F("MiLightClient::increaseBrightness: Increase brightness")); +#endif + currentRemote->packetFormatter->increaseBrightness(); + flushPacket(); +} + +void MiLightClient::decreaseBrightness() { +#ifdef DEBUG_CLIENT_COMMANDS + Serial.println(F("MiLightClient::decreaseBrightness: Decrease brightness")); +#endif + currentRemote->packetFormatter->decreaseBrightness(); + flushPacket(); +} + +void MiLightClient::increaseTemperature() { +#ifdef DEBUG_CLIENT_COMMANDS + Serial.println(F("MiLightClient::increaseTemperature: Increase temperature")); +#endif + currentRemote->packetFormatter->increaseTemperature(); + flushPacket(); +} + +void MiLightClient::decreaseTemperature() { +#ifdef DEBUG_CLIENT_COMMANDS + Serial.println(F("MiLightClient::decreaseTemperature: Decrease temperature")); +#endif + currentRemote->packetFormatter->decreaseTemperature(); + flushPacket(); +} + +void MiLightClient::updateTemperature(const uint8_t temperature) { +#ifdef DEBUG_CLIENT_COMMANDS + Serial.printf_P(PSTR("MiLightClient::updateTemperature: Set temperature to %d\n"), temperature); +#endif + currentRemote->packetFormatter->updateTemperature(temperature); + flushPacket(); +} + +void MiLightClient::command(uint8_t command, uint8_t arg) { +#ifdef DEBUG_CLIENT_COMMANDS + Serial.printf_P(PSTR("MiLightClient::command: Execute command %d, argument %d\n"), command, arg); +#endif + currentRemote->packetFormatter->command(command, arg); + flushPacket(); +} + +void MiLightClient::toggleStatus() { +#ifdef DEBUG_CLIENT_COMMANDS + Serial.printf_P(PSTR("MiLightClient::toggleStatus")); +#endif + currentRemote->packetFormatter->toggleStatus(); + flushPacket(); +} + +void MiLightClient::updateColor(JsonVariant json) { + ParsedColor color = ParsedColor::fromJson(json); + + if (!color.success) { + Serial.println(F("Error parsing color field, unrecognized format")); + return; + } + + // 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); + } +} + +void MiLightClient::update(JsonObject request) { + if (this->updateBeginHandler) { + this->updateBeginHandler(); + } + + const JsonVariant status = this->extractStatus(request); + const uint8_t parsedStatus = this->parseStatus(status); + const JsonVariant jsonTransition = request[RequestKeys::TRANSITION]; + float transition = 0; + + if (!jsonTransition.isNull()) { + if (jsonTransition.is()) { + transition = jsonTransition.as(); + } else if (jsonTransition.is()) { + transition = jsonTransition.as(); + } else { + Serial.println(F("MiLightClient - WARN: unsupported transition type. Must be float or int.")); + } + } + + JsonVariant brightness = request[GroupStateFieldNames::BRIGHTNESS]; + JsonVariant level = request[GroupStateFieldNames::LEVEL]; + const bool isBrightnessDefined = !brightness.isUndefined() || !level.isUndefined(); + + // Always turn on first + if (parsedStatus == ON) { + if (transition == 0) { + this->updateStatus(ON); + } + // Don't do an "On" transition if the bulb is already on. The reasons for this are: + // * Ambiguous what the behavior should be. Should it ramp to full brightness? + // * HomeAssistant is only capable of sending transitions via the `light.turn_on` + // service call, which ends up sending `{"status":"ON"}`. So transitions which + // have nothing to do with the status will include an "ON" command. + // If the user wants to transition brightness, they can just specify a brightness in + // the same command. This avoids the need to make arbitrary calls on what the + // behavior should be. + else if (!currentState->isSetState() || !currentState->isOn()) { + // If a brightness is defined, we'll want to transition to that. Status + // transitions only ramp up/down to the max/min. Otherwise, just turn the bulb on + // and let field transitions handle the rest. + if (!isBrightnessDefined) { + handleTransition(GroupStateField::STATUS, status, transition, 0); + } else { + this->updateStatus(ON); + + if (! brightness.isUndefined()) { + handleTransition(GroupStateField::BRIGHTNESS, brightness, transition, 0); + } else if (! level.isUndefined()) { + handleTransition(GroupStateField::LEVEL, level, transition, 0); + } + } + } + } + + 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 { + 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 + || currentState->isOn() // or if bulb was already on + ) { + handleTransition(field, value, transition); + } + } + } + } + } + + // Raw packet command/args + if (request.containsKey("button_id") && request.containsKey("argument")) { + this->command(request["button_id"], request["argument"]); + } + + // Always turn off last + if (parsedStatus == OFF) { + if (transition == 0) { + this->updateStatus(OFF); + } else { + handleTransition(GroupStateField::STATUS, status, transition); + } + } + + if (this->updateEndHandler) { + this->updateEndHandler(); + } +} + +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 (cmdName == MiLightCommandNames::PAIR) { + this->pair(); + } else if (cmdName == MiLightCommandNames::SET_WHITE) { + this->updateColorWhite(); + } else if (cmdName == MiLightCommandNames::NIGHT_MODE) { + this->enableNightMode(); + } else if (cmdName == MiLightCommandNames::LEVEL_UP) { + this->increaseBrightness(); + } else if (cmdName == MiLightCommandNames::LEVEL_DOWN) { + this->decreaseBrightness(); + } else if (cmdName == "brightness_up") { + this->increaseBrightness(); + } else if (cmdName == "brightness_down") { + this->decreaseBrightness(); + } else if (cmdName == MiLightCommandNames::TEMPERATURE_UP) { + this->increaseTemperature(); + } else if (cmdName == MiLightCommandNames::TEMPERATURE_DOWN) { + this->decreaseTemperature(); + } else if (cmdName == MiLightCommandNames::NEXT_MODE) { + this->nextMode(); + } else if (cmdName == MiLightCommandNames::PREVIOUS_MODE) { + this->previousMode(); + } else if (cmdName == MiLightCommandNames::MODE_SPEED_DOWN) { + this->modeSpeedDown(); + } 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(); + 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 || currentState->isOn()) { + startLevel = currentState->getBrightness(); + } else { + startLevel = startValue; + } + + transitionBuilder = transitions.buildStatusTransition(bulbId, status, startLevel); + } else { + uint16_t currentValue; + uint16_t endValue = value; + + if (startValue == FETCH_VALUE_FROM_STATE || currentState->isOn()) { + 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)]; + 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() + ? currentState->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() + ? currentState->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 (currentState->isSetBrightness()) { + startLevel = currentState->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)]); + } + + transitions.addTransition(transitionBuilder->build()); + return true; +} + +void MiLightClient::handleEffect(const String& effect) { + if (effect == MiLightCommandNames::NIGHT_MODE) { + this->enableNightMode(); + } else if (effect == "white" || effect == "white_mode") { + this->updateColorWhite(); + } else { // assume we're trying to set mode + this->updateMode(effect.toInt()); + } +} + +JsonVariant MiLightClient::extractStatus(JsonObject object) { + JsonVariant status; + + if (object.containsKey(FS(GroupStateFieldNames::STATUS))) { + return object[FS(GroupStateFieldNames::STATUS)]; + } else { + return object[FS(GroupStateFieldNames::STATE)]; + } +} + +uint8_t MiLightClient::parseStatus(JsonVariant val) { + if (val.isUndefined()) { + return STATUS_UNDEFINED; + } + + return parseMilightStatus(val); +} + +void MiLightClient::setRepeatsOverride(size_t repeats) { + this->repeatsOverride = repeats; +} + +void MiLightClient::clearRepeatsOverride() { + this->repeatsOverride = PacketSender::DEFAULT_PACKET_SENDS_VALUE; +} + +void MiLightClient::flushPacket() { + PacketStream& stream = currentRemote->packetFormatter->buildPackets(); + + while (stream.hasNext()) { + packetSender.enqueue(stream.next(), currentRemote, repeatsOverride); + } + + currentRemote->packetFormatter->reset(); +} + +void MiLightClient::onUpdateBegin(EventHandler handler) { + this->updateBeginHandler = handler; +} + +void MiLightClient::onUpdateEnd(EventHandler handler) { + this->updateEndHandler = handler; +} diff --git a/lib/MiLight/MiLightClient.h b/lib/MiLight/MiLightClient.h new file mode 100644 index 0000000..f810284 --- /dev/null +++ b/lib/MiLight/MiLightClient.h @@ -0,0 +1,147 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef _MILIGHTCLIENT_H +#define _MILIGHTCLIENT_H + +//#define DEBUG_PRINTF +//#define DEBUG_CLIENT_COMMANDS // enable to show each individual change command (like hue, brightness, etc) + +#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"; +} + +// 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( + RadioSwitchboard& radioSwitchboard, + PacketSender& packetSender, + GroupStateStore* stateStore, + Settings& settings, + TransitionController& transitions + ); + + ~MiLightClient() { } + + typedef std::function EventHandler; + + 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); + + void setResendCount(const unsigned int resendCount); + bool available(); + size_t read(uint8_t packet[]); + void write(uint8_t packet[]); + + void setHeld(bool held); + + // Common methods + void updateStatus(MiLightStatus status); + void updateStatus(MiLightStatus status, uint8_t groupId); + void pair(); + void unpair(); + void command(uint8_t command, uint8_t arg); + void updateMode(uint8_t mode); + void nextMode(); + void previousMode(); + void modeSpeedDown(); + void modeSpeedUp(); + void toggleStatus(); + + // RGBW methods + void updateHue(const uint16_t hue); + void updateBrightness(const uint8_t brightness); + void updateColorWhite(); + void updateColorRaw(const uint8_t color); + void enableNightMode(); + void updateColor(JsonVariant json); + + // CCT methods + void updateTemperature(const uint8_t colorTemperature); + void decreaseTemperature(); + void increaseTemperature(); + void increaseBrightness(); + void decreaseBrightness(); + + void updateSaturation(const uint8_t saturation); + + 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 onUpdateBegin(EventHandler handler); + void onUpdateEnd(EventHandler handler); + + size_t getNumRadios() const; + std::shared_ptr switchRadio(size_t radioIx); + std::shared_ptr switchRadio(const MiLightRemoteConfig* remoteConfig); + MiLightRemoteConfig& currentRemoteConfig() const; + + // 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(); + + 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; + + EventHandler updateBeginHandler; + EventHandler updateEndHandler; + + GroupStateStore* stateStore; + const GroupState* currentState; + Settings& settings; + PacketSender& packetSender; + TransitionController& transitions; + + // If set, override the number of packet repeats used. + size_t repeatsOverride; + + void flushPacket(); +}; + +#endif diff --git a/lib/MiLight/MiLightRemoteConfig.cpp b/lib/MiLight/MiLightRemoteConfig.cpp new file mode 100644 index 0000000..7db763b --- /dev/null +++ b/lib/MiLight/MiLightRemoteConfig.cpp @@ -0,0 +1,108 @@ +#include +#include + +/** + * IMPORTANT NOTE: These should be in the same order as MiLightRemoteType. + */ +const MiLightRemoteConfig* MiLightRemoteConfig::ALL_REMOTES[] = { + &FUT096Config, // rgbw + &FUT007Config, // cct + &FUT092Config, // rgb+cct + &FUT098Config, // rgb + &FUT089Config, // 8-group rgb+cct (b8, fut089) + &FUT091Config, + &FUT020Config +}; + +const size_t MiLightRemoteConfig::NUM_REMOTES = size(ALL_REMOTES); + +const MiLightRemoteConfig* MiLightRemoteConfig::fromType(const String& type) { + return fromType(MiLightRemoteTypeHelpers::remoteTypeFromString(type)); +} + +const MiLightRemoteConfig* MiLightRemoteConfig::fromType(MiLightRemoteType type) { + if (type == REMOTE_TYPE_UNKNOWN || type >= size(ALL_REMOTES)) { + Serial.print(F("MiLightRemoteConfig::fromType: ERROR - tried to fetch remote config for unknown type: ")); + Serial.println(type); + return NULL; + } + + return ALL_REMOTES[type]; +} + +const MiLightRemoteConfig* MiLightRemoteConfig::fromReceivedPacket( + const MiLightRadioConfig& radioConfig, + const uint8_t* packet, + const size_t len +) { + for (size_t i = 0; i < MiLightRemoteConfig::NUM_REMOTES; i++) { + const MiLightRemoteConfig* config = MiLightRemoteConfig::ALL_REMOTES[i]; + if (&config->radioConfig == &radioConfig + && config->packetFormatter->canHandle(packet, len)) { + return config; + } + } + + // This can happen under normal circumstances, so not an error condition +#ifdef DEBUG_PRINTF + Serial.println(F("MiLightRemoteConfig::fromReceivedPacket: ERROR - tried to fetch remote config for unknown packet")); +#endif + + return NULL; +} + +const MiLightRemoteConfig FUT096Config( //rgbw + new RgbwPacketFormatter(), + MiLightRadioConfig::ALL_CONFIGS[0], + REMOTE_TYPE_RGBW, + "rgbw", + 4 +); + +const MiLightRemoteConfig FUT007Config( //cct + new CctPacketFormatter(), + MiLightRadioConfig::ALL_CONFIGS[1], + REMOTE_TYPE_CCT, + "cct", + 4 +); + +const MiLightRemoteConfig FUT091Config( //v2 cct + new FUT091PacketFormatter(), + MiLightRadioConfig::ALL_CONFIGS[2], + REMOTE_TYPE_FUT091, + "fut091", + 4 +); + +const MiLightRemoteConfig FUT092Config( //rgb+cct + new RgbCctPacketFormatter(), + MiLightRadioConfig::ALL_CONFIGS[2], + REMOTE_TYPE_RGB_CCT, + "rgb_cct", + 4 +); + +const MiLightRemoteConfig FUT089Config( //rgb+cct B8 / FUT089 + new FUT089PacketFormatter(), + MiLightRadioConfig::ALL_CONFIGS[2], + REMOTE_TYPE_FUT089, + "fut089", + 8 +); + +const MiLightRemoteConfig FUT098Config( //rgb + new RgbPacketFormatter(), + MiLightRadioConfig::ALL_CONFIGS[3], + REMOTE_TYPE_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 new file mode 100644 index 0000000..966bf16 --- /dev/null +++ b/lib/MiLight/MiLightRemoteConfig.h @@ -0,0 +1,53 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef _MILIGHT_REMOTE_CONFIG_H +#define _MILIGHT_REMOTE_CONFIG_H + +class MiLightRemoteConfig { +public: + MiLightRemoteConfig( + PacketFormatter* packetFormatter, + MiLightRadioConfig& radioConfig, + const MiLightRemoteType type, + const String name, + const size_t numGroups + ) : packetFormatter(packetFormatter), + radioConfig(radioConfig), + type(type), + name(name), + numGroups(numGroups) + { } + + PacketFormatter* const packetFormatter; + const MiLightRadioConfig& radioConfig; + const MiLightRemoteType type; + const String name; + const size_t numGroups; + + static const MiLightRemoteConfig* fromType(MiLightRemoteType type); + 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; + static const MiLightRemoteConfig* ALL_REMOTES[]; +}; + +extern const MiLightRemoteConfig FUT096Config; //rgbw +extern const MiLightRemoteConfig FUT007Config; //cct +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 new file mode 100644 index 0000000..0b370d2 --- /dev/null +++ b/lib/MiLight/PacketFormatter.cpp @@ -0,0 +1,188 @@ +#include + +static uint8_t* PACKET_BUFFER = new uint8_t[PACKET_FORMATTER_BUFFER_SIZE]; + +PacketStream::PacketStream() + : packetStream(PACKET_BUFFER), + numPackets(0), + packetLength(0), + currentPacket(0) +{ } + +bool PacketStream::hasNext() { + return currentPacket < numPackets; +} + +uint8_t* PacketStream::next() { + uint8_t* packet = packetStream + (currentPacket * packetLength); + currentPacket++; + return packet; +} + +PacketFormatter::PacketFormatter(const MiLightRemoteType deviceType, const size_t packetLength, const size_t maxPackets) + : deviceType(deviceType), + packetLength(packetLength), + numPackets(0), + currentPacket(NULL), + held(false) +{ + packetStream.packetLength = packetLength; +} + +void PacketFormatter::initialize(GroupStateStore* stateStore, const Settings* settings) { + this->stateStore = stateStore; + this->settings = settings; +} + +bool PacketFormatter::canHandle(const uint8_t *packet, const size_t len) { + return len == packetLength; +} + +void PacketFormatter::finalizePacket(uint8_t* packet) { } + +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; +} + +void PacketFormatter::updateStatus(MiLightStatus status, uint8_t groupId) { } +void PacketFormatter::updateBrightness(uint8_t value) { } +void PacketFormatter::updateMode(uint8_t value) { } +void PacketFormatter::modeSpeedDown() { } +void PacketFormatter::modeSpeedUp() { } +void PacketFormatter::nextMode() { } +void PacketFormatter::previousMode() { } +void PacketFormatter::command(uint8_t command, uint8_t arg) { } + +void PacketFormatter::updateHue(uint16_t value) { } +void PacketFormatter::updateColorRaw(uint8_t value) { } +void PacketFormatter::updateColorWhite() { } + +void PacketFormatter::increaseTemperature() { } +void PacketFormatter::decreaseTemperature() { } +void PacketFormatter::increaseBrightness() { } +void PacketFormatter::decreaseBrightness() { } +void PacketFormatter::enableNightMode() { } + +void PacketFormatter::updateTemperature(uint8_t value) { } +void PacketFormatter::updateSaturation(uint8_t value) { } + +BulbId PacketFormatter::parsePacket(const uint8_t *packet, JsonObject result) { + return DEFAULT_BULB_ID; +} + +void PacketFormatter::pair() { + for (size_t i = 0; i < 5; i++) { + updateStatus(ON); + } +} + +void PacketFormatter::unpair() { + pair(); +} + +PacketStream& PacketFormatter::buildPackets() { + if (numPackets > 0) { + finalizePacket(currentPacket); + } + + packetStream.numPackets = numPackets; + packetStream.currentPacket = 0; + + return packetStream; +} + +void PacketFormatter::valueByStepFunction(StepFunction increase, StepFunction decrease, uint8_t numSteps, uint8_t targetValue, int8_t knownValue) { + StepFunction fn; + size_t numCommands = 0; + + // If current value is not known, drive down to minimum value. Then we can assume that we + // know the state (it'll be 0). + if (knownValue == -1) { + for (size_t i = 0; i < numSteps; i++) { + (this->*decrease)(); + } + + fn = increase; + numCommands = targetValue; + } else if (targetValue < knownValue) { + fn = decrease; + numCommands = (knownValue - targetValue); + } else if (targetValue > knownValue) { + fn = increase; + numCommands = (targetValue - knownValue); + } else { + return; + } + + // Get to the desired value + for (size_t i = 0; i < numCommands; i++) { + (this->*fn)(); + } +} + +void PacketFormatter::prepare(uint16_t deviceId, uint8_t groupId) { + this->deviceId = deviceId; + this->groupId = groupId; + reset(); +} + +void PacketFormatter::reset() { + this->numPackets = 0; + this->currentPacket = PACKET_BUFFER; + this->held = false; +} + +void PacketFormatter::pushPacket() { + if (numPackets > 0) { + finalizePacket(currentPacket); + } + + // Make sure there's enough buffer to add another packet. + if ((currentPacket + packetLength) >= PACKET_BUFFER + PACKET_FORMATTER_BUFFER_SIZE) { + Serial.println(F("ERROR: packet buffer full! Cannot buffer a new packet. THIS IS A BUG!")); + return; + } + + currentPacket = PACKET_BUFFER + (numPackets * packetLength); + numPackets++; + initializePacket(currentPacket); +} + +void PacketFormatter::format(uint8_t const* packet, char* buffer) { + for (size_t i = 0; i < packetLength; i++) { + sprintf_P(buffer, "%02X ", packet[i]); + buffer += 3; + } + sprintf_P(buffer, "\n\n"); +} + +void PacketFormatter::formatV1Packet(uint8_t const* packet, char* buffer) { + 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 new file mode 100644 index 0000000..9a4348e --- /dev/null +++ b/lib/MiLight/PacketFormatter.h @@ -0,0 +1,120 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef _PACKET_FORMATTER_H +#define _PACKET_FORMATTER_H + +// Most packets sent is for CCT bulbs, which always includes 10 down commands +// and can include up to 10 up commands. CCT packets are 7 bytes. +// (10 * 7) + (10 * 7) = 140 +#define PACKET_FORMATTER_BUFFER_SIZE 140 + +struct PacketStream { + PacketStream(); + + uint8_t* next(); + bool hasNext(); + + uint8_t* packetStream; + size_t numPackets; + size_t packetLength; + size_t currentPacket; +}; + +class PacketFormatter { +public: + 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 + // available at construction time. + // + // For now, just rely on the user calling this method. + void initialize(GroupStateStore* stateStore, const Settings* settings); + + typedef void (PacketFormatter::*StepFunction)(); + + 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); + + virtual void setHeld(bool held); + + // Mode + virtual void updateMode(uint8_t value); + virtual void modeSpeedDown(); + virtual void modeSpeedUp(); + virtual void nextMode(); + virtual void previousMode(); + + virtual void pair(); + virtual void unpair(); + + // Color + virtual void updateHue(uint16_t value); + virtual void updateColorRaw(uint8_t value); + virtual void updateColorWhite(); + + // White temperature + virtual void increaseTemperature(); + virtual void decreaseTemperature(); + virtual void updateTemperature(uint8_t value); + + // Brightness + virtual void updateBrightness(uint8_t value); + virtual void increaseBrightness(); + virtual void decreaseBrightness(); + virtual void enableNightMode(); + + virtual void updateSaturation(uint8_t value); + + virtual void reset(); + + virtual PacketStream& buildPackets(); + 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 currentBulbId() const; + + static void formatV1Packet(uint8_t const* packet, char* buffer); + + size_t getPacketLength() const; + +protected: + const MiLightRemoteType deviceType; + size_t packetLength; + size_t numPackets; + uint8_t* currentPacket; + bool held; + uint16_t deviceId; + uint8_t groupId; + uint8_t sequenceNum; + PacketStream packetStream; + GroupStateStore* stateStore = NULL; + const Settings* settings = NULL; + + void pushPacket(); + + // Get field into a desired state using only increment/decrement commands. Do this by: + // 1. Driving it down to its minimum value + // 2. Applying the appropriate number of increase commands to get it to the desired + // value. + // If the current state is already known, take that into account and apply the exact + // number of rpeeats for the appropriate command. + void valueByStepFunction(StepFunction increase, StepFunction decrease, uint8_t numSteps, uint8_t targetValue, int8_t knownValue = -1); + + virtual void initializePacket(uint8_t* packetStart) = 0; + virtual void finalizePacket(uint8_t* packet); +}; + +#endif diff --git a/lib/MiLight/PacketQueue.cpp b/lib/MiLight/PacketQueue.cpp new file mode 100644 index 0000000..9b0749e --- /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 0000000..b802498 --- /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 0000000..8b31a87 --- /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 0000000..916149b --- /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 0000000..a79f8a1 --- /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 0000000..00fe2d8 --- /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 new file mode 100644 index 0000000..5fe4c2f --- /dev/null +++ b/lib/MiLight/RgbCctPacketFormatter.cpp @@ -0,0 +1,159 @@ +#include +#include +#include +#include + +void RgbCctPacketFormatter::modeSpeedDown() { + command(RGB_CCT_ON, RGB_CCT_MODE_SPEED_DOWN); +} + +void RgbCctPacketFormatter::modeSpeedUp() { + command(RGB_CCT_ON, RGB_CCT_MODE_SPEED_UP); +} + +void RgbCctPacketFormatter::updateMode(uint8_t mode) { + lastMode = mode; + command(RGB_CCT_MODE, mode); +} + +void RgbCctPacketFormatter::nextMode() { + updateMode((lastMode+1)%RGB_CCT_NUM_MODES); +} + +void RgbCctPacketFormatter::previousMode() { + updateMode((lastMode-1)%RGB_CCT_NUM_MODES); +} + +void RgbCctPacketFormatter::updateBrightness(uint8_t brightness) { + command(RGB_CCT_BRIGHTNESS, RGB_CCT_BRIGHTNESS_OFFSET + brightness); +} + +// change the hue (which may also change to color mode). +void RgbCctPacketFormatter::updateHue(uint16_t value) { + uint8_t remapped = Units::rescale(value, 255, 360); + updateColorRaw(remapped); +} + +void RgbCctPacketFormatter::updateColorRaw(uint8_t value) { + command(RGB_CCT_COLOR, RGB_CCT_COLOR_OFFSET + value); +} + +void RgbCctPacketFormatter::updateTemperature(uint8_t value) { + // Packet scale is [0x94, 0x92, .. 0, .., 0xCE, 0xCC]. Increments of 2. + // From coolest to warmest. + uint8_t cmdValue = V2PacketFormatter::tov2scale(value, RGB_CCT_KELVIN_REMOTE_END, 2); + + // when updating temperature, the bulb switches to white. If we are not already + // in white mode, that makes changing temperature annoying because the current hue/mode + // is lost. So lookup our current bulb mode, and if needed, reset the hue/mode after + // changing the temperature + const GroupState* ourState = this->stateStore->get(this->deviceId, this->groupId, REMOTE_TYPE_RGB_CCT); + + // now make the temperature change + command(RGB_CCT_KELVIN, cmdValue); + + // and return to our original mode + if (ourState != NULL) { + BulbMode originalBulbMode = ourState->getBulbMode(); + + if ((settings->enableAutomaticModeSwitching) && (originalBulbMode != BulbMode::BULB_MODE_WHITE)) { + switchMode(*ourState, originalBulbMode); + } + } +} + +// 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 + const GroupState* ourState = this->stateStore->get(this->deviceId, this->groupId, REMOTE_TYPE_RGB_CCT); + BulbMode originalBulbMode = BulbMode::BULB_MODE_WHITE; + + if (ourState != NULL) { + originalBulbMode = ourState->getBulbMode(); + + // are we already in white? If not, change to white + if ((settings->enableAutomaticModeSwitching) && (originalBulbMode != BulbMode::BULB_MODE_COLOR)) { + updateHue(ourState->getHue()); + } + } + + // now make the saturation change + uint8_t remapped = value + RGB_CCT_SATURATION_OFFSET; + command(RGB_CCT_SATURATION, remapped); + + if (ourState != NULL) { + if ((settings->enableAutomaticModeSwitching) && (originalBulbMode != BulbMode::BULB_MODE_COLOR)) { + switchMode(*ourState, originalBulbMode); + } + } +} + +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 + const GroupState* ourState = this->stateStore->get(this->deviceId, this->groupId, REMOTE_TYPE_RGB_CCT); + 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 + command(RGB_CCT_KELVIN, value); +} + +void RgbCctPacketFormatter::enableNightMode() { + uint8_t arg = groupCommandArg(OFF, groupId); + command(RGB_CCT_ON | 0x80, arg); +} + +BulbId RgbCctPacketFormatter::parsePacket(const uint8_t *packet, JsonObject result) { + uint8_t packetCopy[V2_PACKET_LEN]; + memcpy(packetCopy, packet, V2_PACKET_LEN); + V2RFEncoding::decodeV2Packet(packetCopy); + + BulbId bulbId( + (packetCopy[2] << 8) | packetCopy[3], + packetCopy[7], + REMOTE_TYPE_RGB_CCT + ); + + uint8_t command = (packetCopy[V2_COMMAND_INDEX] & 0x7F); + uint8_t arg = packetCopy[V2_ARGUMENT_INDEX]; + + if (command == RGB_CCT_ON) { + if ((packetCopy[V2_COMMAND_INDEX] & 0x80) == 0x80) { + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::NIGHT_MODE; + } else if (arg == RGB_CCT_MODE_SPEED_DOWN) { + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::MODE_SPEED_DOWN; + } else if (arg == RGB_CCT_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[GroupStateFieldNames::STATE] = "ON"; + bulbId.groupId = arg; + } else { + 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[GroupStateFieldNames::HUE] = hue; + } else if (command == RGB_CCT_KELVIN) { + uint8_t temperature = V2PacketFormatter::fromv2scale(arg, RGB_CCT_KELVIN_REMOTE_END, 2); + 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[GroupStateFieldNames::BRIGHTNESS] = Units::rescale(level, 255, 100); + } else if (command == RGB_CCT_SATURATION) { + result[GroupStateFieldNames::SATURATION] = constrain(arg - RGB_CCT_SATURATION_OFFSET, 0, 100); + } else if (command == RGB_CCT_MODE) { + result[GroupStateFieldNames::MODE] = arg; + } else { + result["button_id"] = command; + result["argument"] = arg; + } + + return bulbId; +} diff --git a/lib/MiLight/RgbCctPacketFormatter.h b/lib/MiLight/RgbCctPacketFormatter.h new file mode 100644 index 0000000..02f1d41 --- /dev/null +++ b/lib/MiLight/RgbCctPacketFormatter.h @@ -0,0 +1,60 @@ +#include + +#ifndef _RGB_CCT_PACKET_FORMATTER_H +#define _RGB_CCT_PACKET_FORMATTER_H + +#define RGB_CCT_NUM_MODES 9 + +#define RGB_CCT_COLOR_OFFSET 0x5F +#define RGB_CCT_BRIGHTNESS_OFFSET 0x8F +#define RGB_CCT_SATURATION_OFFSET 0xD +#define RGB_CCT_KELVIN_OFFSET 0x94 + +// Remotes have a larger range +#define RGB_CCT_KELVIN_REMOTE_START 0x94 +#define RGB_CCT_KELVIN_REMOTE_END 0xCC + +enum MiLightRgbCctCommand { + RGB_CCT_ON = 0x01, + RGB_CCT_OFF = 0x01, + RGB_CCT_COLOR = 0x02, + RGB_CCT_KELVIN = 0x03, + RGB_CCT_BRIGHTNESS = 0x04, + RGB_CCT_SATURATION = 0x04, + RGB_CCT_MODE = 0x05 +}; + +enum MiLightRgbCctArguments { + RGB_CCT_MODE_SPEED_UP = 0x0A, + RGB_CCT_MODE_SPEED_DOWN = 0x0B +}; + +class RgbCctPacketFormatter : public V2PacketFormatter { +public: + RgbCctPacketFormatter() + : V2PacketFormatter(REMOTE_TYPE_RGB_CCT, 0x20, 4), + lastMode(0) + { } + + virtual void updateBrightness(uint8_t value); + virtual void updateHue(uint16_t value); + virtual void updateColorRaw(uint8_t value); + virtual void updateColorWhite(); + virtual void updateTemperature(uint8_t value); + virtual void updateSaturation(uint8_t value); + virtual void enableNightMode(); + + virtual void modeSpeedDown(); + virtual void modeSpeedUp(); + virtual void updateMode(uint8_t mode); + virtual void nextMode(); + virtual void previousMode(); + + virtual BulbId parsePacket(const uint8_t* packet, JsonObject result); + +protected: + + uint8_t lastMode; +}; + +#endif diff --git a/lib/MiLight/RgbPacketFormatter.cpp b/lib/MiLight/RgbPacketFormatter.cpp new file mode 100644 index 0000000..c05ed8b --- /dev/null +++ b/lib/MiLight/RgbPacketFormatter.cpp @@ -0,0 +1,129 @@ +#include +#include +#include + +void RgbPacketFormatter::initializePacket(uint8_t *packet) { + size_t packetPtr = 0; + + packet[packetPtr++] = 0xA4; + packet[packetPtr++] = deviceId >> 8; + packet[packetPtr++] = deviceId & 0xFF; + packet[packetPtr++] = 0; + packet[packetPtr++] = 0; + packet[packetPtr++] = sequenceNum++; +} + +void RgbPacketFormatter::pair() { + for (size_t i = 0; i < 5; i++) { + command(RGB_SPEED_UP, 0); + } +} + +void RgbPacketFormatter::unpair() { + for (size_t i = 0; i < 5; i++) { + command(RGB_SPEED_UP | 0x10, 0); + } +} + +void RgbPacketFormatter::updateStatus(MiLightStatus status, uint8_t groupId) { + command(status == ON ? RGB_ON : RGB_OFF, 0); +} + +void RgbPacketFormatter::command(uint8_t command, uint8_t arg) { + pushPacket(); + if (held) { + command |= 0x80; + } + currentPacket[RGB_COMMAND_INDEX] = command; +} + +void RgbPacketFormatter::updateHue(uint16_t value) { + const int16_t remappedColor = (value + 40) % 360; + updateColorRaw(Units::rescale(remappedColor, 255, 360)); +} + +void RgbPacketFormatter::updateColorRaw(uint8_t value) { + command(0, 0); + currentPacket[RGB_COLOR_INDEX] = value; +} + +void RgbPacketFormatter::updateBrightness(uint8_t value) { + const GroupState* state = this->stateStore->get(deviceId, groupId, MiLightRemoteType::REMOTE_TYPE_RGB); + int8_t knownValue = (state != NULL && state->isSetBrightness()) ? state->getBrightness() / RGB_INTERVALS : -1; + + valueByStepFunction( + &PacketFormatter::increaseBrightness, + &PacketFormatter::decreaseBrightness, + RGB_INTERVALS, + value / RGB_INTERVALS, + knownValue + ); +} + +void RgbPacketFormatter::increaseBrightness() { + command(RGB_BRIGHTNESS_UP, 0); +} + +void RgbPacketFormatter::decreaseBrightness() { + command(RGB_BRIGHTNESS_DOWN, 0); +} + +void RgbPacketFormatter::modeSpeedDown() { + command(RGB_SPEED_DOWN, 0); +} + +void RgbPacketFormatter::modeSpeedUp() { + command(RGB_SPEED_UP, 0); +} + +void RgbPacketFormatter::nextMode() { + command(RGB_MODE_UP, 0); +} + +void RgbPacketFormatter::previousMode() { + command(RGB_MODE_DOWN, 0); +} + +BulbId RgbPacketFormatter::parsePacket(const uint8_t* packet, JsonObject result) { + uint8_t command = packet[RGB_COMMAND_INDEX] & 0x7F; + + BulbId bulbId( + (packet[1] << 8) | packet[2], + 0, + REMOTE_TYPE_RGB + ); + + if (command == RGB_ON) { + result[GroupStateFieldNames::STATE] = "ON"; + } else if (command == RGB_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[GroupStateFieldNames::HUE] = remappedColor; + } else if (command == RGB_MODE_DOWN) { + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::PREVIOUS_MODE; + } else if (command == RGB_MODE_UP) { + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::NEXT_MODE; + } else if (command == RGB_SPEED_DOWN) { + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::MODE_SPEED_DOWN; + } else if (command == RGB_SPEED_UP) { + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::MODE_SPEED_UP; + } else if (command == RGB_BRIGHTNESS_DOWN) { + result[GroupStateFieldNames::COMMAND] = "brightness_down"; + } else if (command == RGB_BRIGHTNESS_UP) { + result[GroupStateFieldNames::COMMAND] = "brightness_up"; + } else { + result["button_id"] = command; + } + + return bulbId; +} + +void RgbPacketFormatter::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("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 new file mode 100644 index 0000000..e0cd9e8 --- /dev/null +++ b/lib/MiLight/RgbPacketFormatter.h @@ -0,0 +1,47 @@ +#include + +#ifndef _RGB_PACKET_FORMATTER_H +#define _RGB_PACKET_FORMATTER_H + +#define RGB_COMMAND_INDEX 4 +#define RGB_COLOR_INDEX 3 +#define RGB_INTERVALS 10 + +enum MiLightRgbButton { + RGB_OFF = 0x01, + RGB_ON = 0x02, + RGB_BRIGHTNESS_UP = 0x03, + RGB_BRIGHTNESS_DOWN = 0x04, + RGB_SPEED_UP = 0x05, + RGB_SPEED_DOWN = 0x06, + RGB_MODE_UP = 0x07, + RGB_MODE_DOWN = 0x08, + RGB_PAIR = RGB_SPEED_UP +}; + +class RgbPacketFormatter : public PacketFormatter { +public: + RgbPacketFormatter() + : PacketFormatter(REMOTE_TYPE_RGB, 6, 20) + { } + + virtual void updateStatus(MiLightStatus status, uint8_t groupId); + virtual void updateBrightness(uint8_t value); + virtual void increaseBrightness(); + virtual void decreaseBrightness(); + virtual void command(uint8_t command, uint8_t arg); + virtual void updateHue(uint16_t value); + virtual void updateColorRaw(uint8_t value); + virtual void format(uint8_t const* packet, char* buffer); + virtual void pair(); + virtual void unpair(); + virtual void modeSpeedDown(); + virtual void modeSpeedUp(); + virtual void nextMode(); + virtual void previousMode(); + virtual BulbId parsePacket(const uint8_t* packet, JsonObject result); + + virtual void initializePacket(uint8_t* packet); +}; + +#endif diff --git a/lib/MiLight/RgbwPacketFormatter.cpp b/lib/MiLight/RgbwPacketFormatter.cpp new file mode 100644 index 0000000..7031f11 --- /dev/null +++ b/lib/MiLight/RgbwPacketFormatter.cpp @@ -0,0 +1,160 @@ +#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 ) +#define STATUS_FOR_COMMAND(buttonId) ( ((buttonId) % 2) == 0 ? OFF : ON ) + +bool RgbwPacketFormatter::canHandle(const uint8_t *packet, const size_t len) { + return len == packetLength && (packet[0] & 0xF0) == RGBW_PROTOCOL_ID_BYTE; +} + +void RgbwPacketFormatter::initializePacket(uint8_t* packet) { + size_t packetPtr = 0; + + packet[packetPtr++] = RGBW_PROTOCOL_ID_BYTE; + packet[packetPtr++] = deviceId >> 8; + packet[packetPtr++] = deviceId & 0xFF; + packet[packetPtr++] = 0; + packet[packetPtr++] = (groupId & 0x07); + packet[packetPtr++] = 0; + packet[packetPtr++] = sequenceNum++; +} + +void RgbwPacketFormatter::unpair() { + PacketFormatter::updateStatus(ON); + updateColorWhite(); +} + +void RgbwPacketFormatter::modeSpeedDown() { + command(RGBW_SPEED_DOWN, 0); +} + +void RgbwPacketFormatter::modeSpeedUp() { + command(RGBW_SPEED_UP, 0); +} + +void RgbwPacketFormatter::nextMode() { + updateMode((currentMode() + 1) % RGBW_NUM_MODES); +} + +void RgbwPacketFormatter::previousMode() { + updateMode((currentMode() + RGBW_NUM_MODES - 1) % RGBW_NUM_MODES); +} + +uint8_t RgbwPacketFormatter::currentMode() { + const GroupState* state = stateStore->get(deviceId, groupId, REMOTE_TYPE_RGBW); + return state != NULL ? state->getMode() : 0; +} + +void RgbwPacketFormatter::updateMode(uint8_t mode) { + command(RGBW_DISCO_MODE, 0); + currentPacket[0] = RGBW_PROTOCOL_ID_BYTE | mode; +} + +void RgbwPacketFormatter::updateStatus(MiLightStatus status, uint8_t groupId) { + command(STATUS_COMMAND(status, groupId), 0); +} + +void RgbwPacketFormatter::updateBrightness(uint8_t value) { + // Expect an input value in [0, 100]. Map it down to [0, 25]. + const uint8_t adjustedBrightness = Units::rescale(value, 25, 100); + + // The actual protocol uses a bizarre range where min is 16, max is 23: + // [16, 15, ..., 0, 31, ..., 23] + const uint8_t packetBrightnessValue = ( + ((31 - adjustedBrightness) + 17) % 32 + ); + + command(RGBW_BRIGHTNESS, 0); + currentPacket[RGBW_BRIGHTNESS_GROUP_INDEX] |= (packetBrightnessValue << 3); +} + +void RgbwPacketFormatter::command(uint8_t command, uint8_t arg) { + pushPacket(); + if (held) { + command |= 0x80; + } + currentPacket[RGBW_COMMAND_INDEX] = command; +} + +void RgbwPacketFormatter::updateHue(uint16_t value) { + const int16_t remappedColor = (value + 40) % 360; + updateColorRaw(Units::rescale(remappedColor, 255, 360)); +} + +void RgbwPacketFormatter::updateColorRaw(uint8_t value) { + command(RGBW_COLOR, 0); + currentPacket[RGBW_COLOR_INDEX] = value; +} + +void RgbwPacketFormatter::updateColorWhite() { + uint8_t button = RGBW_GROUP_1_MAX_LEVEL + ((groupId - 1)*2); + command(button, 0); +} + +void RgbwPacketFormatter::enableNightMode() { + uint8_t button = STATUS_COMMAND(OFF, groupId); + + // 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) { + uint8_t command = packet[RGBW_COMMAND_INDEX] & 0x7F; + + BulbId bulbId( + (packet[1] << 8) | packet[2], + packet[RGBW_BRIGHTNESS_GROUP_INDEX] & 0x7, + REMOTE_TYPE_RGBW + ); + + if (command >= RGBW_ALL_ON && command <= RGBW_GROUP_4_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 & 0x10) { + if ((command % 2) == 0) { + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::NIGHT_MODE; + } else { + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::SET_WHITE; + } + bulbId.groupId = GROUP_FOR_STATUS_COMMAND(command & 0xF); + } else if (command == RGBW_BRIGHTNESS) { + uint8_t brightness = 31; + brightness -= packet[RGBW_BRIGHTNESS_GROUP_INDEX] >> 3; + brightness += 17; + brightness %= 32; + 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[GroupStateFieldNames::HUE] = remappedColor; + } else if (command == RGBW_SPEED_DOWN) { + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::MODE_SPEED_DOWN; + } else if (command == RGBW_SPEED_UP) { + result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::MODE_SPEED_UP; + } else if (command == RGBW_DISCO_MODE) { + result[GroupStateFieldNames::MODE] = packet[0] & ~RGBW_PROTOCOL_ID_BYTE; + } else { + result["button_id"] = command; + } + + return bulbId; +} + +void RgbwPacketFormatter::format(uint8_t const* packet, char* buffer) { + PacketFormatter::formatV1Packet(packet, buffer); +} diff --git a/lib/MiLight/RgbwPacketFormatter.h b/lib/MiLight/RgbwPacketFormatter.h new file mode 100644 index 0000000..93e7b59 --- /dev/null +++ b/lib/MiLight/RgbwPacketFormatter.h @@ -0,0 +1,82 @@ +#include + +#ifndef _RGBW_PACKET_FORMATTER_H +#define _RGBW_PACKET_FORMATTER_H + +#define RGBW_PROTOCOL_ID_BYTE 0xB0 + +enum MiLightRgbwButton { + RGBW_ALL_ON = 0x01, + RGBW_ALL_OFF = 0x02, + RGBW_GROUP_1_ON = 0x03, + RGBW_GROUP_1_OFF = 0x04, + RGBW_GROUP_2_ON = 0x05, + RGBW_GROUP_2_OFF = 0x06, + RGBW_GROUP_3_ON = 0x07, + RGBW_GROUP_3_OFF = 0x08, + RGBW_GROUP_4_ON = 0x09, + RGBW_GROUP_4_OFF = 0x0A, + RGBW_SPEED_UP = 0x0B, + RGBW_SPEED_DOWN = 0x0C, + RGBW_DISCO_MODE = 0x0D, + RGBW_BRIGHTNESS = 0x0E, + RGBW_COLOR = 0x0F, + RGBW_ALL_MAX_LEVEL = 0x11, + RGBW_ALL_MIN_LEVEL = 0x12, + + // These are the only mechanism (that I know of) to disable RGB and set the + // color to white. + RGBW_GROUP_1_MAX_LEVEL = 0x13, + RGBW_GROUP_1_MIN_LEVEL = 0x14, + RGBW_GROUP_2_MAX_LEVEL = 0x15, + RGBW_GROUP_2_MIN_LEVEL = 0x16, + RGBW_GROUP_3_MAX_LEVEL = 0x17, + RGBW_GROUP_3_MIN_LEVEL = 0x18, + RGBW_GROUP_4_MAX_LEVEL = 0x19, + RGBW_GROUP_4_MIN_LEVEL = 0x1A, + + // Button codes for night mode. A long press on the corresponding OFF button + // Not actually needed/used. + RGBW_ALL_NIGHT = 0x12, + RGBW_GROUP_1_NIGHT = 0x14, + RGBW_GROUP_2_NIGHT = 0x16, + RGBW_GROUP_3_NIGHT = 0x18, + RGBW_GROUP_4_NIGHT = 0x1A, +}; + +#define RGBW_COMMAND_INDEX 5 +#define RGBW_BRIGHTNESS_GROUP_INDEX 4 +#define RGBW_COLOR_INDEX 3 +#define RGBW_NUM_MODES 9 + +class RgbwPacketFormatter : public PacketFormatter { +public: + RgbwPacketFormatter() + : PacketFormatter(REMOTE_TYPE_RGBW, 7) + { } + + virtual bool canHandle(const uint8_t* packet, const size_t len); + virtual void updateStatus(MiLightStatus status, uint8_t groupId); + virtual void updateBrightness(uint8_t value); + virtual void command(uint8_t command, uint8_t arg); + virtual void updateHue(uint16_t value); + virtual void updateColorRaw(uint8_t value); + virtual void updateColorWhite(); + virtual void format(uint8_t const* packet, char* buffer); + virtual void unpair(); + virtual void modeSpeedDown(); + virtual void modeSpeedUp(); + virtual void nextMode(); + virtual void previousMode(); + virtual void updateMode(uint8_t mode); + virtual void enableNightMode(); + virtual BulbId parsePacket(const uint8_t* packet, JsonObject result); + + virtual void initializePacket(uint8_t* packet); + +protected: + static bool isStatusCommand(const uint8_t command); + uint8_t currentMode(); +}; + +#endif diff --git a/lib/MiLight/V2PacketFormatter.cpp b/lib/MiLight/V2PacketFormatter.cpp new file mode 100644 index 0000000..633ac24 --- /dev/null +++ b/lib/MiLight/V2PacketFormatter.cpp @@ -0,0 +1,144 @@ +#include +#include + +#define GROUP_COMMAND_ARG(status, groupId, numGroups) ( groupId + (status == OFF ? (numGroups + 1) : 0) ) + +V2PacketFormatter::V2PacketFormatter(const MiLightRemoteType deviceType, uint8_t protocolId, uint8_t numGroups) + : PacketFormatter(deviceType, 9), + protocolId(protocolId), + numGroups(numGroups) +{ } + +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; +} + +void V2PacketFormatter::initializePacket(uint8_t* packet) { + size_t packetPtr = 0; + + // Always encode with 0x00 key. No utility in varying it. + packet[packetPtr++] = 0x00; + + packet[packetPtr++] = protocolId; + packet[packetPtr++] = deviceId >> 8; + packet[packetPtr++] = deviceId & 0xFF; + packet[packetPtr++] = 0; + packet[packetPtr++] = 0; + packet[packetPtr++] = sequenceNum++; + packet[packetPtr++] = groupId; + packet[packetPtr++] = 0; +} + +void V2PacketFormatter::command(uint8_t command, uint8_t arg) { + pushPacket(); + if (held) { + command |= 0x80; + } + currentPacket[V2_COMMAND_INDEX] = command; + currentPacket[V2_ARGUMENT_INDEX] = arg; +} + +void V2PacketFormatter::updateStatus(MiLightStatus status, uint8_t groupId) { + command(0x01, GROUP_COMMAND_ARG(status, groupId, numGroups)); +} + +void V2PacketFormatter::unpair() { + for (size_t i = 0; i < 5; i++) { + updateStatus(ON, 0); + } +} + +void V2PacketFormatter::finalizePacket(uint8_t* packet) { + V2RFEncoding::encodeV2Packet(packet); +} + +void V2PacketFormatter::format(uint8_t const* packet, char* buffer) { + buffer += sprintf_P(buffer, PSTR("Raw packet: ")); + for (size_t i = 0; i < packetLength; i++) { + buffer += sprintf_P(buffer, PSTR("%02X "), packet[i]); + } + + uint8_t decodedPacket[packetLength]; + memcpy(decodedPacket, packet, packetLength); + + V2RFEncoding::decodeV2Packet(decodedPacket); + + buffer += sprintf_P(buffer, PSTR("\n\nDecoded:\n")); + buffer += sprintf_P(buffer, PSTR("Key : %02X\n"), decodedPacket[0]); + buffer += sprintf_P(buffer, PSTR("b1 : %02X\n"), decodedPacket[1]); + buffer += sprintf_P(buffer, PSTR("ID : %02X%02X\n"), decodedPacket[2], decodedPacket[3]); + buffer += sprintf_P(buffer, PSTR("Command : %02X\n"), decodedPacket[4]); + buffer += sprintf_P(buffer, PSTR("Argument : %02X\n"), decodedPacket[5]); + buffer += sprintf_P(buffer, PSTR("Sequence : %02X\n"), decodedPacket[6]); + buffer += sprintf_P(buffer, PSTR("Group : %02X\n"), decodedPacket[7]); + buffer += sprintf_P(buffer, PSTR("Checksum : %02X"), decodedPacket[8]); +} + +uint8_t V2PacketFormatter::groupCommandArg(MiLightStatus status, uint8_t groupId) { + return GROUP_COMMAND_ARG(status, groupId, numGroups); +} + +// helper method to return a bulb to the prior state +void V2PacketFormatter::switchMode(const GroupState& currentState, BulbMode desiredMode) { + // revert back to the prior mode + switch (desiredMode) { + case BulbMode::BULB_MODE_COLOR: + updateHue(currentState.getHue()); + break; + case BulbMode::BULB_MODE_NIGHT: + enableNightMode(); + break; + case BulbMode::BULB_MODE_SCENE: + updateMode(currentState.getMode()); + break; + case BulbMode::BULB_MODE_WHITE: + updateColorWhite(); + break; + default: + 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) { + if (reverse) { + value = 100 - value; + } + + return (value * interval) + endValue; +} + +uint8_t V2PacketFormatter::fromv2scale(uint8_t value, uint8_t endValue, uint8_t interval, bool reverse, uint8_t buffer) { + 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)) { + value = 100; + // underflow (value is unsigned) + } else { + value = 0; + } + } + return value; +} \ No newline at end of file diff --git a/lib/MiLight/V2PacketFormatter.h b/lib/MiLight/V2PacketFormatter.h new file mode 100644 index 0000000..284416e --- /dev/null +++ b/lib/MiLight/V2PacketFormatter.h @@ -0,0 +1,55 @@ +#include +#include + +#ifndef _V2_PACKET_FORMATTER +#define _V2_PACKET_FORMATTER + +#define V2_PACKET_LEN 9 + +#define V2_PROTOCOL_ID_INDEX 1 +#define V2_COMMAND_INDEX 4 +#define V2_ARGUMENT_INDEX 5 + +// Default number of values to allow before and after strictly defined range for V2 scales +#define V2_DEFAULT_RANGE_BUFFER 0x13 + +class V2PacketFormatter : public PacketFormatter { +public: + 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); + + virtual void updateStatus(MiLightStatus status, uint8_t group); + virtual void command(uint8_t command, uint8_t arg); + virtual void format(uint8_t const* packet, char* buffer); + virtual void unpair(); + + virtual void finalizePacket(uint8_t* packet); + + uint8_t groupCommandArg(MiLightStatus status, uint8_t groupId); + + /* + * Some protocols have scales which have the following characteristics: + * Start at some value X, goes down to 0, then up to Y. + * eg: + * 0x8F, 0x8D, ..., 0, 0x2, ..., 0x20 + * This is a parameterized method to convert from [0, 100] TO this scale + */ + static uint8_t tov2scale(uint8_t value, uint8_t endValue, uint8_t interval, bool reverse = true); + + /* + * Method to convert FROM the scale described above to [0, 100]. + * + * An extra parameter is exposed: `buffer`, which allows for a range of values before/after the + * max that will be mapped to 0 and 100, respectively. + */ + static uint8_t fromv2scale(uint8_t value, uint8_t endValue, uint8_t interval, bool reverse = true, uint8_t buffer = V2_DEFAULT_RANGE_BUFFER); + +protected: + const uint8_t protocolId; + const uint8_t numGroups; + void switchMode(const GroupState& currentState, BulbMode desiredMode); +}; + +#endif diff --git a/lib/MiLight/V2RFEncoding.cpp b/lib/MiLight/V2RFEncoding.cpp new file mode 100644 index 0000000..52e0ecd --- /dev/null +++ b/lib/MiLight/V2RFEncoding.cpp @@ -0,0 +1,66 @@ +#include + +#define V2_OFFSET(byte, key, jumpStart) ( \ + V2_OFFSETS[byte-1][key%4] \ + + \ + ((jumpStart > 0 && key >= jumpStart && key < jumpStart+0x80) ? 0x80 : 0) \ +) + +uint8_t const V2RFEncoding::V2_OFFSETS[][4] = { + { 0x45, 0x1F, 0x14, 0x5C }, // request type + { 0x2B, 0xC9, 0xE3, 0x11 }, // id 1 + { 0x6D, 0x5F, 0x8A, 0x2B }, // id 2 + { 0xAF, 0x03, 0x1D, 0xF3 }, // command + { 0x1A, 0xE2, 0xF0, 0xD1 }, // argument + { 0x04, 0xD8, 0x71, 0x42 }, // sequence + { 0xAF, 0x04, 0xDD, 0x07 }, // group + { 0x61, 0x13, 0x38, 0x64 } // checksum +}; + +uint8_t V2RFEncoding::xorKey(uint8_t key) { + // Generate most significant nibble + const uint8_t shift = (key & 0x0F) < 0x04 ? 0 : 1; + const uint8_t x = (((key & 0xF0) >> 4) + shift + 6) % 8; + const uint8_t msn = (((4 + x) ^ 1) & 0x0F) << 4; + + // Generate least significant nibble + const uint8_t lsn = ((((key & 0xF) + 4)^2) & 0x0F); + + return ( msn | lsn ); +} + +uint8_t V2RFEncoding::decodeByte(uint8_t byte, uint8_t s1, uint8_t xorKey, uint8_t s2) { + uint8_t value = byte - s2; + value = value ^ xorKey; + value = value - s1; + + return value; +} + +uint8_t V2RFEncoding::encodeByte(uint8_t byte, uint8_t s1, uint8_t xorKey, uint8_t s2) { + uint8_t value = byte + s1; + value = value ^ xorKey; + value = value + s2; + + return value; +} + +void V2RFEncoding::decodeV2Packet(uint8_t *packet) { + uint8_t key = xorKey(packet[0]); + + for (size_t i = 1; i <= 8; i++) { + packet[i] = decodeByte(packet[i], 0, key, V2_OFFSET(i, packet[0], V2_OFFSET_JUMP_START)); + } +} + +void V2RFEncoding::encodeV2Packet(uint8_t *packet) { + uint8_t key = xorKey(packet[0]); + uint8_t sum = key; + + for (size_t i = 1; i <= 7; i++) { + sum += packet[i]; + packet[i] = encodeByte(packet[i], 0, key, V2_OFFSET(i, packet[0], V2_OFFSET_JUMP_START)); + } + + packet[8] = encodeByte(sum, 2, key, V2_OFFSET(8, packet[0], 0)); +} diff --git a/lib/MiLight/V2RFEncoding.h b/lib/MiLight/V2RFEncoding.h new file mode 100644 index 0000000..a7fb433 --- /dev/null +++ b/lib/MiLight/V2RFEncoding.h @@ -0,0 +1,21 @@ +#include +#include + +#ifndef _V2_RF_ENCODING_H +#define _V2_RF_ENCODING_H + +#define V2_OFFSET_JUMP_START 0x54 + +class V2RFEncoding { +public: + static void encodeV2Packet(uint8_t* packet); + static void decodeV2Packet(uint8_t* packet); + static uint8_t xorKey(uint8_t key); + static uint8_t encodeByte(uint8_t byte, uint8_t s1, uint8_t xorKey, uint8_t s2); + static uint8_t decodeByte(uint8_t byte, uint8_t s1, uint8_t xorKey, uint8_t s2); + +private: + static uint8_t const V2_OFFSETS[][4]; +}; + +#endif diff --git a/lib/MiLightState/GroupState.cpp b/lib/MiLightState/GroupState.cpp new file mode 100644 index 0000000..cd7a65f --- /dev/null +++ b/lib/MiLightState/GroupState.cpp @@ -0,0 +1,1024 @@ +#include +#include +#include +#include +#include +#include + +static const char* BULB_MODE_NAMES[] = { + "white", + "color", + "scene", + "night" +}; + +const BulbId DEFAULT_BULB_ID; + +const GroupStateField GroupState::ALL_PHYSICAL_FIELDS[] = { + GroupStateField::BULB_MODE, + GroupStateField::HUE, + GroupStateField::KELVIN, + GroupStateField::MODE, + GroupStateField::SATURATION, + 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; + +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: + return DEFAULT_RGB_ONLY_STATE; + break; + case REMOTE_TYPE_CCT: + case REMOTE_TYPE_FUT091: + return DEFAULT_WHITE_ONLY_STATE; + break; + + default: + // No modifications needed + break; + } + + return DEFAULT_STATE; +} + +void GroupState::initFields() { + state.fields._state = 0; + state.fields._brightness = 0; + state.fields._brightnessColor = 0; + state.fields._brightnessMode = 0; + state.fields._hue = 0; + state.fields._saturation = 0; + state.fields._mode = 0; + state.fields._bulbMode = 0; + state.fields._kelvin = 0; + state.fields._isSetState = 0; + state.fields._isSetHue = 0; + state.fields._isSetBrightness = 0; + state.fields._isSetBrightnessColor = 0; + state.fields._isSetBrightnessMode = 0; + state.fields._isSetSaturation = 0; + state.fields._isSetMode = 0; + state.fields._isSetKelvin = 0; + state.fields._isSetBulbMode = 0; + state.fields._dirty = 1; + state.fields._mqttDirty = 0; + state.fields._isSetNightMode = 0; + state.fields._isNightMode = 0; + + scratchpad.fields._isSetBrightnessScratch = 0; + scratchpad.fields._brightnessScratch = 0; + scratchpad.fields._isSetKelvinScratch = 0; + scratchpad.fields._kelvinScratch = 0; +} + +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() + : 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; +} + +bool GroupState::isEqualIgnoreDirty(const GroupState& other) const { + GroupState meCopy = *this; + GroupState otherCopy = other; + + meCopy.clearDirty(); + meCopy.clearMqttDirty(); + otherCopy.clearDirty(); + otherCopy.clearMqttDirty(); + + return meCopy == otherCopy; +} + +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: + // Always set -- either send RGB color or white + return true; + case GroupStateField::DEVICE_ID: + case GroupStateField::GROUP_ID: + case GroupStateField::DEVICE_TYPE: + // These are always defined + return true; + case GroupStateField::STATE: + case GroupStateField::STATUS: + return isSetState(); + case GroupStateField::BRIGHTNESS: + case GroupStateField::LEVEL: + return isSetBrightness(); + case GroupStateField::COLOR: + case GroupStateField::HUE: + case GroupStateField::OH_COLOR: + case GroupStateField::HEX_COLOR: + return isSetHue(); + case GroupStateField::SATURATION: + return isSetSaturation(); + case GroupStateField::MODE: + return isSetMode(); + case GroupStateField::EFFECT: + return isSetEffect(); + case GroupStateField::KELVIN: + case GroupStateField::COLOR_TEMP: + 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; + } + + return false; +} + +bool GroupState::isSetScratchField(GroupStateField field) const { + switch (field) { + case GroupStateField::BRIGHTNESS: + 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; + } + + return false; +} + +uint16_t GroupState::getFieldValue(GroupStateField field) const { + switch (field) { + case GroupStateField::STATE: + case GroupStateField::STATUS: + return getState(); + case GroupStateField::BRIGHTNESS: + return getBrightness(); + case GroupStateField::HUE: + return getHue(); + case GroupStateField::SATURATION: + return getSaturation(); + case GroupStateField::MODE: + return getMode(); + case GroupStateField::KELVIN: + 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; + } + + 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; + } + + return 0; +} + +void GroupState::setFieldValue(GroupStateField field, uint16_t value) { + switch (field) { + case GroupStateField::STATE: + case GroupStateField::STATUS: + setState(static_cast(value)); + break; + case GroupStateField::BRIGHTNESS: + setBrightness(value); + break; + case GroupStateField::HUE: + setHue(value); + break; + case GroupStateField::SATURATION: + setSaturation(value); + break; + case GroupStateField::MODE: + setMode(value); + break; + case GroupStateField::KELVIN: + setKelvin(value); + break; + case GroupStateField::BULB_MODE: + setBulbMode(static_cast(value)); + break; + default: + Serial.print(F("WARNING: tried to set value for unknown field: ")); + Serial.println(static_cast(field)); + break; + } +} + +void GroupState::setScratchFieldValue(GroupStateField field, uint16_t value) { + switch (field) { + case GroupStateField::BRIGHTNESS: + scratchpad.fields._isSetBrightnessScratch = 1; + scratchpad.fields._brightnessScratch = value; + break; + case GroupStateField::KELVIN: + scratchpad.fields._isSetKelvinScratch = 1; + scratchpad.fields._kelvinScratch = value; + break; + default: + Serial.print(F("WARNING: tried to set value for unknown scratch field: ")); + Serial.println(static_cast(field)); + break; + } +} + +bool GroupState::isSetState() const { return state.fields._isSetState; } +MiLightStatus GroupState::getState() const { return state.fields._state ? ON : OFF; } +bool GroupState::isOn() const { + return !isNightMode() && (!isSetState() || getState() == MiLightStatus::ON); +} +bool GroupState::setState(const MiLightStatus status) { + if (!isNightMode() && isSetState() && getState() == status) { + return false; + } + + setDirty(); + state.fields._isSetState = 1; + state.fields._state = status == ON ? 1 : 0; + + // Changing status will clear night mode + setNightMode(false); + + return true; +} + +bool GroupState::isSetBrightness() const { + // 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; + } + + switch (state.fields._bulbMode) { + case BULB_MODE_WHITE: + return state.fields._isSetBrightness; + case BULB_MODE_COLOR: + return state.fields._isSetBrightnessColor; + case BULB_MODE_SCENE: + return state.fields._isSetBrightnessMode; + } + + 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: + return state.fields._brightness; + case BULB_MODE_COLOR: + return state.fields._brightnessColor; + case BULB_MODE_SCENE: + return state.fields._brightnessMode; + } + + return 0; +} +bool GroupState::setBrightness(uint8_t brightness) { + if (isSetBrightness() && getBrightness() == brightness) { + return false; + } + + setDirty(); + + uint8_t bulbMode = state.fields._bulbMode; + if (! state.fields._isSetBulbMode) { + bulbMode = BULB_MODE_WHITE; + } + + switch (bulbMode) { + case BULB_MODE_WHITE: + state.fields._isSetBrightness = 1; + state.fields._brightness = brightness; + break; + case BULB_MODE_COLOR: + state.fields._isSetBrightnessColor = 1; + state.fields._brightnessColor = brightness; + break; + case BULB_MODE_SCENE: + state.fields._isSetBrightnessMode = 1; + state.fields._brightnessMode = brightness; + default: + return false; + } + + return true; +} + +bool GroupState::isSetHue() const { return state.fields._isSetHue; } +uint16_t GroupState::getHue() const { + return Units::rescale(state.fields._hue, 360, 255); +} +bool GroupState::setHue(uint16_t hue) { + if (isSetHue() && getHue() == hue) { + return false; + } + + setDirty(); + state.fields._isSetHue = 1; + state.fields._hue = Units::rescale(hue, 255, 360); + + return true; +} + +bool GroupState::isSetSaturation() const { return state.fields._isSetSaturation; } +uint8_t GroupState::getSaturation() const { return state.fields._saturation; } +bool GroupState::setSaturation(uint8_t saturation) { + if (isSetSaturation() && getSaturation() == saturation) { + return false; + } + + setDirty(); + state.fields._isSetSaturation = 1; + state.fields._saturation = saturation; + + return true; +} + +bool GroupState::isSetMode() const { return state.fields._isSetMode; } +bool GroupState::isSetEffect() const { + // only BULB_MODE_COLOR does not have an effect. + return isSetBulbMode() && getBulbMode() != BULB_MODE_COLOR; +} +uint8_t GroupState::getMode() const { return state.fields._mode; } +bool GroupState::setMode(uint8_t mode) { + if (isSetMode() && getMode() == mode) { + return false; + } + + setDirty(); + state.fields._isSetMode = 1; + state.fields._mode = mode; + + return true; +} + +bool GroupState::isSetKelvin() const { return state.fields._isSetKelvin; } +uint8_t GroupState::getKelvin() const { return state.fields._kelvin; } +uint16_t GroupState::getMireds() const { + return Units::whiteValToMireds(getKelvin(), 100); +} +bool GroupState::setKelvin(uint8_t kelvin) { + if (isSetKelvin() && getKelvin() == kelvin) { + return false; + } + + setDirty(); + state.fields._isSetKelvin = 1; + state.fields._kelvin = kelvin; + + return true; +} +bool GroupState::setMireds(uint16_t mireds) { + return setKelvin(Units::miredsToWhiteVal(mireds, 100)); +} + +bool GroupState::isSetBulbMode() const { + return (isSetNightMode() && isNightMode()) || state.fields._isSetBulbMode; +} +BulbMode GroupState::getBulbMode() const { + // 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. + if (isSetNightMode() && isNightMode()) { + return BULB_MODE_NIGHT; + } else { + return static_cast(state.fields._bulbMode); + } +} +bool GroupState::setBulbMode(BulbMode bulbMode) { + if (isSetBulbMode() && getBulbMode() == bulbMode) { + return false; + } + + setDirty(); + + // As mentioned in isSetBulbMode, NIGHT_MODE is stored separately. + if (bulbMode == BULB_MODE_NIGHT) { + setNightMode(true); + } else { + state.fields._isSetBulbMode = 1; + state.fields._bulbMode = bulbMode; + } + + return true; +} + +bool GroupState::isSetNightMode() const { return state.fields._isSetNightMode; } +bool GroupState::isNightMode() const { return state.fields._isNightMode; } +bool GroupState::setNightMode(bool nightMode) { + if (isSetNightMode() && isNightMode() == nightMode) { + return false; + } + + setDirty(); + state.fields._isSetNightMode = 1; + state.fields._isNightMode = nightMode; + + return true; +} + +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::isMqttDirty() const { return state.fields._mqttDirty; } +bool GroupState::clearMqttDirty() { + state.fields._mqttDirty = 0; + return true; +} + +void GroupState::load(Stream& stream) { + for (size_t i = 0; i < DATA_LONGS; i++) { + stream.readBytes(reinterpret_cast(&state.rawData[i]), 4); + } + clearDirty(); +} + +void GroupState::dump(Stream& stream) const { + for (size_t i = 0; i < DATA_LONGS; i++) { + stream.write(reinterpret_cast(&state.rawData[i]), 4); + } +} + +bool GroupState::applyIncrementCommand(GroupStateField field, IncrementDirection dir) { + if (field != GroupStateField::KELVIN && field != GroupStateField::BRIGHTNESS) { + Serial.print(F("WARNING: tried to apply increment for unsupported field: ")); + Serial.println(static_cast(field)); + return false; + } + + int8_t dirValue = static_cast(dir); + + // If there's already a known value, update it + 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 + 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 { + if (isSetScratchField(field)) { + int8_t newValue = static_cast(getScratchFieldValue(field)) + dirValue; + + if (newValue == 0 || newValue == 10) { + setFieldValue(field, newValue * INCREMENT_COMMAND_VALUE); + return true; + } else { + setScratchFieldValue(field, newValue); + } + } else if (dir == IncrementDirection::DECREASE) { + setScratchFieldValue(field, 9); + } else { + setScratchFieldValue(field, 1); + } + +#ifdef STATE_DEBUG + Serial.print(F("Updated scratch field: ")); + Serial.print(static_cast(field)); + Serial.print(F(" to: ")); + Serial.println(getScratchFieldValue(field)); +#endif + } + + return false; +} + +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]; + + // 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)); + } + } +} + +/* + Update group state to reflect a packet state + + Called both when a packet is sent locally, and when an intercepted packet is read + (see main.cpp onPacketSentHandler) + + Returns true if the packet changes affects a state change +*/ +bool GroupState::patch(JsonObject state) { + bool changes = false; + +#ifdef STATE_DEBUG + Serial.print(F("Patching existing state with: ")); + serializeJson(state, Serial); + Serial.println(); +#endif + + 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(GroupStateFieldNames::BRIGHTNESS)) { + bool stateChange = setBrightness(Units::rescale(state[GroupStateFieldNames::BRIGHTNESS].as(), 100, 255)); + changes |= stateChange; + } + if (isOn() && state.containsKey(GroupStateFieldNames::HUE)) { + changes |= setHue(state[GroupStateFieldNames::HUE]); + changes |= setBulbMode(BULB_MODE_COLOR); + } + if (isOn() && state.containsKey(GroupStateFieldNames::SATURATION)) { + changes |= setSaturation(state[GroupStateFieldNames::SATURATION]); + } + if (isOn() && state.containsKey(GroupStateFieldNames::MODE)) { + changes |= setMode(state[GroupStateFieldNames::MODE]); + changes |= setBulbMode(BULB_MODE_SCENE); + } + if (isOn() && state.containsKey(GroupStateFieldNames::COLOR_TEMP)) { + changes |= setMireds(state[GroupStateFieldNames::COLOR_TEMP]); + changes |= setBulbMode(BULB_MODE_WHITE); + } + + if (state.containsKey(GroupStateFieldNames::COMMAND)) { + const String& command = state[GroupStateFieldNames::COMMAND]; + + if (isOn() && command == MiLightCommandNames::SET_WHITE) { + changes |= setBulbMode(BULB_MODE_WHITE); + } 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 == MiLightCommandNames::TEMPERATURE_UP) { + changes |= applyIncrementCommand(GroupStateField::KELVIN, IncrementDirection::INCREASE); + changes |= setBulbMode(BULB_MODE_WHITE); + } else if (isOn() && command == MiLightCommandNames::TEMPERATURE_DOWN) { + changes |= applyIncrementCommand(GroupStateField::KELVIN, IncrementDirection::DECREASE); + changes |= setBulbMode(BULB_MODE_WHITE); + } + } + + if (changes) { + debugState("GroupState::patch: State changed"); + } + else { + debugState("GroupState::patch: State not changed"); + } + + return changes; +} + +void GroupState::applyColor(JsonObject state) const { + ParsedColor color = getColor(); + applyColor(state, color.r, color.g, color.b); +} + +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) const { + if (isSetField(field)) { + switch (field) { + case GroupStateField::STATE: + case GroupStateField::STATUS: + partialState[GroupStateFieldHelpers::getFieldName(field)] = getState() == ON ? "ON" : "OFF"; + break; + + case GroupStateField::BRIGHTNESS: + partialState[GroupStateFieldNames::BRIGHTNESS] = Units::rescale(getBrightness(), 255, 100); + break; + + case GroupStateField::LEVEL: + partialState[GroupStateFieldNames::LEVEL] = getBrightness(); + break; + + case GroupStateField::BULB_MODE: + partialState[GroupStateFieldNames::BULB_MODE] = BULB_MODE_NAMES[getBulbMode()]; + break; + + case GroupStateField::COLOR: + if (getBulbMode() == BULB_MODE_COLOR) { + applyColor(partialState); + } + 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); + } else { + applyColor(partialState, 255, 255, 255); + } + break; + + case GroupStateField::HUE: + if (getBulbMode() == BULB_MODE_COLOR) { + partialState[GroupStateFieldNames::HUE] = getHue(); + } + break; + + case GroupStateField::SATURATION: + if (getBulbMode() == BULB_MODE_COLOR) { + partialState[GroupStateFieldNames::SATURATION] = getSaturation(); + } + break; + + case GroupStateField::MODE: + if (getBulbMode() == BULB_MODE_SCENE) { + partialState[GroupStateFieldNames::MODE] = getMode(); + } + break; + + case GroupStateField::EFFECT: + if (getBulbMode() == BULB_MODE_SCENE) { + partialState[GroupStateFieldNames::EFFECT] = String(getMode()); + } else if (isSetBulbMode() && getBulbMode() == BULB_MODE_WHITE) { + partialState[GroupStateFieldNames::EFFECT] = "white_mode"; + } else if (getBulbMode() == BULB_MODE_NIGHT) { + partialState[GroupStateFieldNames::EFFECT] = MiLightCommandNames::NIGHT_MODE; + } + break; + + case GroupStateField::COLOR_TEMP: + if (isSetBulbMode() && getBulbMode() == BULB_MODE_WHITE) { + partialState[GroupStateFieldNames::COLOR_TEMP] = getMireds(); + } + break; + + case GroupStateField::KELVIN: + if (isSetBulbMode() && getBulbMode() == BULB_MODE_WHITE) { + partialState[GroupStateFieldNames::KELVIN] = getKelvin(); + } + break; + + case GroupStateField::DEVICE_ID: + partialState[GroupStateFieldNames::DEVICE_ID] = bulbId.deviceId; + break; + + case GroupStateField::GROUP_ID: + partialState[GroupStateFieldNames::GROUP_ID] = bulbId.groupId; + break; + + case GroupStateField::DEVICE_TYPE: + { + 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) const { +#ifdef STATE_DEBUG + // using static to keep large buffers off the call stack + StaticJsonDocument<500> jsonDoc; + JsonObject jsonState = jsonDoc.to(); + + // define fields to show (if count changes, make sure to update count to applyState below) + std::vector fields({ + GroupStateField::LEVEL, + GroupStateField::BULB_MODE, + GroupStateField::COLOR_TEMP, + GroupStateField::EFFECT, + GroupStateField::HUE, + GroupStateField::KELVIN, + GroupStateField::MODE, + GroupStateField::SATURATION, + GroupStateField::STATE + }); + + // Fake id + BulbId id; + + // use applyState to build JSON of all fields (from above) + applyState(jsonState, id, fields); + // convert to string and print + Serial.printf("%s: ", debugMessage); + 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, 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 new file mode 100644 index 0000000..f4dead4 --- /dev/null +++ b/lib/MiLightState/GroupState.h @@ -0,0 +1,225 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef _GROUP_STATE_H +#define _GROUP_STATE_H + +// enable to add debugging on state +// #define DEBUG_STATE + +enum BulbMode { + BULB_MODE_WHITE, + BULB_MODE_COLOR, + BULB_MODE_SCENE, + BULB_MODE_NIGHT +}; + +enum class IncrementDirection : unsigned { + INCREASE = 1, + DECREASE = -1U +}; + +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; + void setScratchFieldValue(GroupStateField field, uint16_t value); + + // 1 bit + bool isSetState() const; + MiLightStatus getState() const; + bool setState(const MiLightStatus on); + // Return true if status is ON or if the field is unset (i.e., defaults to ON) + bool isOn() const; + + // 7 bits + bool isSetBrightness() const; + uint8_t getBrightness() const; + bool setBrightness(uint8_t brightness); + bool clearBrightness(); + + // 8 bits + bool isSetHue() const; + uint16_t getHue() const; + bool setHue(uint16_t hue); + + // 7 bits + bool isSetSaturation() const; + uint8_t getSaturation() const; + bool setSaturation(uint8_t saturation); + + // 5 bits + bool isSetMode() const; + bool isSetEffect() const; + uint8_t getMode() const; + bool setMode(uint8_t mode); + + // 7 bits + bool isSetKelvin() const; + uint8_t getKelvin() const; + uint16_t getMireds() const; + bool setKelvin(uint8_t kelvin); + bool setMireds(uint16_t mireds); + + // 3 bits + bool isSetBulbMode() const; + BulbMode getBulbMode() const; + bool setBulbMode(BulbMode mode); + + // 1 bit + bool isSetNightMode() const; + bool isNightMode() const; + bool setNightMode(bool nightMode); + + bool isDirty() const; + inline bool setDirty(); + bool clearDirty(); + + bool isMqttDirty() const; + inline bool setMqttDirty(); + bool clearMqttDirty(); + + // 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 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(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) 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 + // 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, + // 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. + // 3. When scratch reaches a known extreme (either min or max), set the + // 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) 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; + union StateData { + uint32_t rawData[DATA_LONGS]; + struct Fields { + uint32_t + _state : 1, + _brightness : 7, + _hue : 8, + _saturation : 7, + _mode : 4, + _bulbMode : 3, + _isSetState : 1, + _isSetHue : 1; + uint32_t + _kelvin : 7, + _isSetBrightness : 1, + _isSetSaturation : 1, + _isSetMode : 1, + _isSetKelvin : 1, + _isSetBulbMode : 1, + _brightnessColor : 7, + _brightnessMode : 7, + _isSetBrightnessColor : 1, + _isSetBrightnessMode : 1, + _dirty : 1, + _mqttDirty : 1, + _isSetNightMode : 1, + _isNightMode : 1; + } fields; + }; + + // Transient scratchpad that is never persisted. Used to track and compute state for + // protocols that only have increment commands (like CCT). + union TransientData { + uint16_t rawData; + struct Fields { + uint16_t + _isSetKelvinScratch : 1, + _kelvinScratch : 7, + _isSetBrightnessScratch : 1, + _brightnessScratch : 8; + } fields; + }; + + StateData state; + TransientData scratchpad; + + // 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; + +#endif diff --git a/lib/MiLightState/GroupStateCache.cpp b/lib/MiLightState/GroupStateCache.cpp new file mode 100644 index 0000000..ea5924f --- /dev/null +++ b/lib/MiLightState/GroupStateCache.cpp @@ -0,0 +1,72 @@ +#include + +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); +} + +GroupState* GroupStateCache::set(const BulbId& id, const GroupState& state) { + GroupCacheNode* pushedNode = NULL; + if (cache.size() >= maxSize) { + pushedNode = cache.pop(); + } + + GroupState* cachedState = getInternal(id); + + if (cachedState == NULL) { + if (pushedNode == NULL) { + GroupCacheNode* newNode = new GroupCacheNode(id, state); + cachedState = &newNode->state; + cache.unshift(newNode); + } else { + pushedNode->id = id; + pushedNode->state = state; + cachedState = &pushedNode->state; + cache.unshift(pushedNode); + } + } else { + *cachedState = state; + } + + return cachedState; +} + +BulbId GroupStateCache::getLru() { + GroupCacheNode* node = cache.getLast(); + return node->id; +} + +bool GroupStateCache::isFull() const { + return cache.size() >= maxSize; +} + +ListNode* GroupStateCache::getHead() { + return cache.getHead(); +} + +GroupState* GroupStateCache::getInternal(const BulbId& id) { + ListNode* cur = cache.getHead(); + + while (cur != NULL) { + if (cur->data->id == id) { + GroupState* result = &cur->data->state; + cache.spliceToFront(cur); + return result; + } + cur = cur->next; + } + + return NULL; +} diff --git a/lib/MiLightState/GroupStateCache.h b/lib/MiLightState/GroupStateCache.h new file mode 100644 index 0000000..6ca94af --- /dev/null +++ b/lib/MiLightState/GroupStateCache.h @@ -0,0 +1,34 @@ +#include +#include + +#ifndef _GROUP_STATE_CACHE_H +#define _GROUP_STATE_CACHE_H + +struct GroupCacheNode { + GroupCacheNode() {} + GroupCacheNode(const BulbId& id, const GroupState& state) + : id(id), state(state) { } + + BulbId id; + GroupState state; +}; + +class GroupStateCache { +public: + GroupStateCache(const size_t maxSize); + ~GroupStateCache(); + + GroupState* get(const BulbId& id); + GroupState* set(const BulbId& id, const GroupState& state); + BulbId getLru(); + bool isFull() const; + ListNode* getHead(); + +private: + LinkedList cache; + const size_t maxSize; + + GroupState* getInternal(const BulbId& id); +}; + +#endif diff --git a/lib/MiLightState/GroupStatePersistence.cpp b/lib/MiLightState/GroupStatePersistence.cpp new file mode 100644 index 0000000..14a1f71 --- /dev/null +++ b/lib/MiLightState/GroupStatePersistence.cpp @@ -0,0 +1,40 @@ +#include +#include + +static const char FILE_PREFIX[] = "group_states/"; + +void GroupStatePersistence::get(const BulbId &id, GroupState& state) { + char path[30]; + memset(path, 0, 30); + buildFilename(id, path); + + if (SPIFFS.exists(path)) { + File f = SPIFFS.open(path, "r"); + state.load(f); + f.close(); + } +} + +void GroupStatePersistence::set(const BulbId &id, const GroupState& state) { + char path[30]; + memset(path, 0, 30); + buildFilename(id, path); + + File f = SPIFFS.open(path, "w"); + state.dump(f); + f.close(); +} + +void GroupStatePersistence::clear(const BulbId &id) { + char path[30]; + buildFilename(id, path); + + if (SPIFFS.exists(path)) { + SPIFFS.remove(path); + } +} + +char* GroupStatePersistence::buildFilename(const BulbId &id, char *buffer) { + uint32_t compactId = id.getCompactId(); + return buffer + sprintf(buffer, "%s%x", FILE_PREFIX, compactId); +} diff --git a/lib/MiLightState/GroupStatePersistence.h b/lib/MiLightState/GroupStatePersistence.h new file mode 100644 index 0000000..c974462 --- /dev/null +++ b/lib/MiLightState/GroupStatePersistence.h @@ -0,0 +1,18 @@ +#include + +#ifndef _GROUP_STATE_PERSISTENCE_H +#define _GROUP_STATE_PERSISTENCE_H + +class GroupStatePersistence { +public: + void get(const BulbId& id, GroupState& state); + void set(const BulbId& id, const GroupState& state); + + void clear(const BulbId& id); + +private: + + static char* buildFilename(const BulbId& id, char* buffer); +}; + +#endif diff --git a/lib/MiLightState/GroupStateStore.cpp b/lib/MiLightState/GroupStateStore.cpp new file mode 100644 index 0000000..28da160 --- /dev/null +++ b/lib/MiLightState/GroupStateStore.cpp @@ -0,0 +1,150 @@ +#include +#include + +GroupStateStore::GroupStateStore(const size_t maxSize, const size_t flushRate) + : cache(GroupStateCache(maxSize)), + flushRate(flushRate), + lastFlush(0) +{ } + +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); + + const MiLightRemoteConfig* remoteConfig = MiLightRemoteConfig::fromType(id.deviceType); + + if (remoteConfig == NULL) { + return NULL; + } + + persistence.get(id, loadedState); + state = cache.set(id, loadedState); + } + + return state; +} + +GroupState* GroupStateStore::get(const uint16_t deviceId, const uint8_t groupId, const MiLightRemoteType deviceType) { + BulbId bulbId(deviceId, groupId, deviceType); + return get(bulbId); +} + +// 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->patch(state); + + if (id.groupId == 0) { + const MiLightRemoteConfig* remote = MiLightRemoteConfig::fromType(id.deviceType); + +#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++) { + otherId.groupId = i; + + GroupState* individualState = get(otherId); + individualState->patch(state); + } + } else { + otherId.groupId = 0; + GroupState* group0State = get(otherId); + + group0State->clearNonMatchingFields(state); + } + + return storedState; +} + +GroupState* GroupStateStore::set(const uint16_t deviceId, const uint8_t groupId, const MiLightRemoteType deviceType, const GroupState& state) { + BulbId bulbId(deviceId, groupId, deviceType); + 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 + } +} + +bool GroupStateStore::flush() { + ListNode* curr = cache.getHead(); + bool anythingFlushed = false; + + while (curr != NULL && curr->data->state.isDirty() && !anythingFlushed) { + persistence.set(curr->data->id, curr->data->state); + curr->data->state.clearDirty(); + +#ifdef STATE_DEBUG + BulbId bulbId = curr->data->id; + printf( + "Flushing dirty state for 0x%04X / %d / %s\n", + bulbId.deviceId, + bulbId.groupId, + MiLightRemoteConfig::fromType(bulbId.deviceType)->name.c_str() + ); +#endif + + curr = curr->next; + anythingFlushed = true; + } + + while (evictedIds.size() > 0 && !anythingFlushed) { + persistence.clear(evictedIds.shift()); + anythingFlushed = true; + } + + return anythingFlushed; +} + +void GroupStateStore::limitedFlush() { + unsigned long now = millis(); + + if ((lastFlush + flushRate) < now) { + if (flush()) { + lastFlush = now; + } + } +} diff --git a/lib/MiLightState/GroupStateStore.h b/lib/MiLightState/GroupStateStore.h new file mode 100644 index 0000000..30c0872 --- /dev/null +++ b/lib/MiLightState/GroupStateStore.h @@ -0,0 +1,53 @@ +#include +#include +#include + +#ifndef _GROUP_STATE_STORE_H +#define _GROUP_STATE_STORE_H + +class GroupStateStore { +public: + GroupStateStore(const size_t maxSize, const size_t flushRate); + + /* + * 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 + * default. + * + * Otherwise, we return NULL. + */ + GroupState* get(const BulbId& id); + GroupState* get(const uint16_t deviceId, const uint8_t groupId, const MiLightRemoteType deviceType); + + /* + * Sets the state for the given BulbId. State will be marked as dirty and + * flushed to persistent storage. + */ + 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. + */ + bool flush(); + + /* + * Flushes at most one dirty state to persistent storage. Rate limit + * specified by Settings. + */ + void limitedFlush(); + +private: + GroupStateCache cache; + GroupStatePersistence persistence; + LinkedList evictedIds; + const size_t flushRate; + unsigned long lastFlush; + + void trackEviction(); +}; + +#endif diff --git a/lib/Radio/LT8900MiLightRadio.cpp b/lib/Radio/LT8900MiLightRadio.cpp new file mode 100644 index 0000000..5e6e06f --- /dev/null +++ b/lib/Radio/LT8900MiLightRadio.cpp @@ -0,0 +1,451 @@ +/* + * MiLightRadioPL1167_LT89000.cpp + * + * Created on: 31 March 2017 + * Author: WoodsterDK + * + * Very inspired by: + * https://github.com/pmoscetta/authometion-milight/tree/master/Authometion-MiLight + * https://bitbucket.org/robvanderveer/lt8900lib + */ + +#include "LT8900MiLightRadio.h" +#include + +/**************************************************************************/ +// Constructor +/**************************************************************************/ +LT8900MiLightRadio::LT8900MiLightRadio(byte byCSPin, byte byResetPin, byte byPktFlag, const MiLightRadioConfig& config) + : _config(config), + _channel(0), + _currentPacketLen(0), + _currentPacketPos(0) +{ + _csPin = byCSPin; + _pin_pktflag = byPktFlag; + + pinMode(_pin_pktflag, INPUT); + + if (byResetPin > 0) // If zero then bypass hardware reset + { + pinMode(byResetPin, OUTPUT); + digitalWrite(byResetPin, LOW); + delay(200); + digitalWrite(byResetPin, HIGH); + delay(200); + } + + pinMode(_csPin, OUTPUT); + digitalWrite(_csPin, HIGH); + + SPI.begin(); + + SPI.setDataMode(SPI_MODE1); + // The following speed settings depends upon the wiring and PCB + //SPI.setFrequency(8000000); + SPI.setFrequency(4000000); + SPI.setBitOrder(MSBFIRST); + + //Initialize transceiver with correct settings + vInitRadioModule(); + delay(50); + + // Check if HW is connected + _bConnected = bCheckRadioConnection(); + + //Reset SPI MODE to default + SPI.setDataMode(SPI_MODE0); + _waiting = false; +} + + + + +/**************************************************************************/ +// Checks the connection to the radio module by verifying a register setting +/**************************************************************************/ +bool LT8900MiLightRadio::bCheckRadioConnection(void) +{ + bool bRetValue = false; + uint16_t value_0 = uiReadRegister(0); + uint16_t value_1 = uiReadRegister(1); + + if ((value_0 == 0x6fe0) && (value_1 == 0x5681)) + { + #ifdef DEBUG_PRINTF + Serial.println(F("Radio module running correctly...")); + #endif + bRetValue = true; + } + else + { + #ifdef DEBUG_PRINTF + Serial.println(F("Failed initializing the radio module...")); + #endif + } + + return bRetValue; +} + +/**************************************************************************/ +// Initialize radio module +/**************************************************************************/ +void LT8900MiLightRadio::vInitRadioModule() { + bool bWriteDefaultDefault = true; // Is it okay to use the default power up values, without setting them + + regWrite16(0x00, 0x6F, 0xE0, 7); // Recommended value by PMmicro + regWrite16(0x02, 0x66, 0x17, 7); // Recommended value by PMmicro + regWrite16(0x04, 0x9C, 0xC9, 7); // Recommended value by PMmicro + + regWrite16(0x05, 0x66, 0x37, 7); // Recommended value by PMmicro + regWrite16(0x07, 0x00, 0x4C, 7); // PL1167's TX/RX Enable and Channel Register, Default channel 76 + regWrite16(0x08, 0x6C, 0x90, 7); // Recommended value by PMmicro + regWrite16(0x09, 0x48, 0x00, 7); // PA Control register + + regWrite16(0x0B, 0x00, 0x08, 7); // Recommended value by PMmicro + regWrite16(0x0D, 0x48, 0xBD, 7); // Recommended value by PMmicro + regWrite16(0x16, 0x00, 0xFF, 7); // Recommended value by PMmicro + regWrite16(0x18, 0x00, 0x67, 7); // Recommended value by PMmicro + + regWrite16(0x1A, 0x19, 0xE0, 7); // Recommended value by PMmicro + regWrite16(0x1B, 0x13, 0x00, 7); // Recommended value by PMmicro + + regWrite16(0x20, 0x48, 0x00, 7); // Recommended value by PMmicro + regWrite16(0x21, 0x3F, 0xC7, 7); // Recommended value by PMmicro + regWrite16(0x22, 0x20, 0x00, 7); // Recommended value by PMmicro + regWrite16(0x23, 0x03, 0x00, 7); // Recommended value by PMmicro + + regWrite16(0x24, 0x72, 0x36, 7); // Sync R0 + regWrite16(0x27, 0x18, 0x09, 7); // Sync R3 + regWrite16(0x28, 0x44, 0x02, 7); // Recommended value by PMmicro + regWrite16(0x29, 0xB0, 0x00, 7); // Recommended value by PMmicro + regWrite16(0x2A, 0xFD, 0xB0, 7); // Recommended value by PMmicro + + if (bWriteDefaultDefault == true) { + regWrite16(0x01, 0x56, 0x81, 7); // Recommended value by PMmicro + regWrite16(0x0A, 0x7F, 0xFD, 7); // Recommended value by PMmicro + regWrite16(0x0C, 0x00, 0x00, 7); // Recommended value by PMmicro + regWrite16(0x17, 0x80, 0x05, 7); // Recommended value by PMmicro + regWrite16(0x19, 0x16, 0x59, 7); // Recommended value by PMmicro + regWrite16(0x1C, 0x18, 0x00, 7); // Recommended value by PMmicro + + regWrite16(0x25, 0x00, 0x00, 7); // Recommended value by PMmicro + regWrite16(0x26, 0x00, 0x00, 7); // Recommended value by PMmicro + regWrite16(0x2B, 0x00, 0x0F, 7); // Recommended value by PMmicro + } +} + +/**************************************************************************/ +// Set sync word +/**************************************************************************/ +void LT8900MiLightRadio::vSetSyncWord(uint16_t syncWord3, uint16_t syncWord2, uint16_t syncWord1, uint16_t syncWord0) +{ + uiWriteRegister(R_SYNCWORD1, syncWord0); + uiWriteRegister(R_SYNCWORD2, syncWord1); + uiWriteRegister(R_SYNCWORD3, syncWord1); + uiWriteRegister(R_SYNCWORD4, syncWord3); +} + +/**************************************************************************/ +// Low level register write with delay +/**************************************************************************/ +void LT8900MiLightRadio::regWrite16(byte ADDR, byte V1, byte V2, byte WAIT) +{ + digitalWrite(_csPin, LOW); + SPI.transfer(ADDR); + SPI.transfer(V1); + SPI.transfer(V2); + digitalWrite(_csPin, HIGH); + delayMicroseconds(WAIT); +} + + +/**************************************************************************/ +// Low level register read +/**************************************************************************/ +uint16_t LT8900MiLightRadio::uiReadRegister(uint8_t reg) +{ + SPI.setDataMode(SPI_MODE1); + digitalWrite(_csPin, LOW); + SPI.transfer(REGISTER_READ | (REGISTER_MASK & reg)); + uint8_t high = SPI.transfer(0x00); + uint8_t low = SPI.transfer(0x00); + + digitalWrite(_csPin, HIGH); + + SPI.setDataMode(SPI_MODE0); + return (high << 8 | low); +} + + +/**************************************************************************/ +// Low level 16bit register write +/**************************************************************************/ +uint8_t LT8900MiLightRadio::uiWriteRegister(uint8_t reg, uint16_t data) +{ + uint8_t high = data >> 8; + uint8_t low = data & 0xFF; + + digitalWrite(_csPin, LOW); + + uint8_t result = SPI.transfer(REGISTER_WRITE | (REGISTER_MASK & reg)); + SPI.transfer(high); + SPI.transfer(low); + + digitalWrite(_csPin, HIGH); + + return result; +} + +/**************************************************************************/ +// Start listening on specified channel and syncword +/**************************************************************************/ +void LT8900MiLightRadio::vStartListening(uint uiChannelToListenTo) +{ + _dupes_received = 0; + vSetSyncWord(_config.syncword3, 0,0,_config.syncword0); + //vSetChannel(uiChannelToListenTo); + + _channel = uiChannelToListenTo; + + vResumeRX(); + delay(5); +} + +/**************************************************************************/ +// Resume listening - without changing the channel and syncword +/**************************************************************************/ +void LT8900MiLightRadio::vResumeRX(void) +{ + _dupes_received = 0; + uiWriteRegister(R_CHANNEL, _channel & CHANNEL_MASK); //turn off rx/tx + delay(3); + uiWriteRegister(R_FIFO_CONTROL, 0x0080); //flush rx + uiWriteRegister(R_CHANNEL, (_channel & CHANNEL_MASK) | _BV(CHANNEL_RX_BIT)); //enable RX +} + +/**************************************************************************/ +// Check if data is available using the hardware pin PKT_FLAG +/**************************************************************************/ +bool LT8900MiLightRadio::bAvailablePin() { + return digitalRead(_pin_pktflag) > 0; +} + +/**************************************************************************/ +// Check if data is available using the PKT_FLAG state in the status register +/**************************************************************************/ +bool LT8900MiLightRadio::bAvailableRegister() { + //read the PKT_FLAG state; this can also be done with a hard wire. + uint16_t value = uiReadRegister(R_STATUS); + + if (bitRead(value, STATUS_CRC_BIT) != 0) { +#ifdef DEBUG_PRINTF + Serial.println(F("LT8900: CRC failed")); +#endif + vResumeRX(); + return false; + } + + return (value & STATUS_PKT_BIT_MASK) > 0; +} + +/**************************************************************************/ +// Read the RX buffer +/**************************************************************************/ +int LT8900MiLightRadio::iReadRXBuffer(uint8_t *buffer, size_t maxBuffer) { + size_t bufferIx = 0; + uint16_t data; + + if (_currentPacketLen == 0) { + if (! available()) { + return -1; + } + + data = uiReadRegister(R_FIFO); + + _currentPacketLen = (data >> 8); + _currentPacketPos = 1; + + buffer[bufferIx++] = (data & 0xFF); + } + + while (_currentPacketPos < _currentPacketLen && (bufferIx+1) < maxBuffer) { + data = uiReadRegister(R_FIFO); + buffer[bufferIx++] = data >> 8; + buffer[bufferIx++] = data & 0xFF; + + _currentPacketPos += 2; + } + +#ifdef DEBUG_PRINTF + printf_P( + PSTR("Read %d/%d bytes in RX, read %d bytes into buffer\n"), + _currentPacketPos, + _currentPacketLen, + bufferIx + ); +#endif + + if (_currentPacketPos >= _currentPacketLen) { + _currentPacketPos = 0; + _currentPacketLen = 0; + } + + return bufferIx; +} + + +/**************************************************************************/ +// Set the active channel for the radio module +/**************************************************************************/ +void LT8900MiLightRadio::vSetChannel(uint8_t channel) +{ + _channel = channel; + uiWriteRegister(R_CHANNEL, (_channel & CHANNEL_MASK)); +} + +/**************************************************************************/ +// Startup +/**************************************************************************/ +int LT8900MiLightRadio::begin() +{ + vSetChannel(_config.channels[0]); + configure(); + available(); + return 0; +} + +/**************************************************************************/ +// Configure the module according to type, and start listening +/**************************************************************************/ +int LT8900MiLightRadio::configure() +{ + vInitRadioModule(); + vSetSyncWord(_config.syncword3, 0,0,_config.syncword0); + vStartListening(_config.channels[0]); + return 0; +} + +/**************************************************************************/ +// Check if data is available +/**************************************************************************/ +bool LT8900MiLightRadio::available() +{ + if (_currentPacketPos < _currentPacketLen) { + return true; + } + + return bAvailablePin() && bAvailableRegister(); +} + +/**************************************************************************/ +// Read received data from buffer to upper layer +/**************************************************************************/ +int LT8900MiLightRadio::read(uint8_t frame[], size_t &frame_length) +{ + if (!available()) { + frame_length = 0; + return -1; + } + + #ifdef DEBUG_PRINTF + Serial.println(F("LT8900: Radio was available, reading packet...")); + #endif + + uint8_t buf[MILIGHT_MAX_PACKET_LENGTH]; + int packetSize = iReadRXBuffer(buf, MILIGHT_MAX_PACKET_LENGTH); + + if (packetSize > 0) { + frame_length = packetSize; + memcpy(frame, buf, packetSize); + } + + vResumeRX(); + + return packetSize; +} + +/**************************************************************************/ +// Write data +/**************************************************************************/ +int LT8900MiLightRadio::write(uint8_t frame[], size_t frame_length) +{ + if (frame_length > sizeof(_out_packet) - 1) { + return -1; + } + + memcpy(_out_packet + 1, frame, frame_length); + _out_packet[0] = frame_length; + + SPI.setDataMode(SPI_MODE1); + + int retval = resend(); + yield(); + + SPI.setDataMode(SPI_MODE0); + + if (retval < 0) { + return retval; + } + return frame_length; +} + +/**************************************************************************/ +// Handle the transmission to regarding to freq diversity and repeats +/**************************************************************************/ +int LT8900MiLightRadio::resend() +{ + byte Length = _out_packet[0]; + + for (size_t i = 0; i < MiLightRadioConfig::NUM_CHANNELS; i++) + { + sendPacket(_out_packet, Length, _config.channels[i]); + delayMicroseconds(DEFAULT_TIME_BETWEEN_RETRANSMISSIONS_uS); + } + + return 0; +} + +/**************************************************************************/ +// The actual transmit happens here +/**************************************************************************/ +bool LT8900MiLightRadio::sendPacket(uint8_t *data, size_t packetSize, byte byChannel) +{ + if(_bConnected) // Must be connected to module otherwise it might lookup waiting for _pin_pktflag + { + if (packetSize < 1 || packetSize > 255) + { + return false; + } + + uiWriteRegister(R_CHANNEL, 0x0000); + uiWriteRegister(R_FIFO_CONTROL, 0x8080); //flush tx and RX + + digitalWrite(_csPin, LOW); // Enable PL1167 SPI transmission + SPI.transfer(R_FIFO); // Start writing PL1167's FIFO Data register + SPI.transfer(packetSize); // Length of data buffer: x bytes + + for (byte iCounter = 0; iCounter < packetSize; iCounter++) + { + SPI.transfer((data[1+iCounter])); + } + digitalWrite(_csPin, HIGH); // Disable PL1167 SPI transmission + delayMicroseconds(10); + + uiWriteRegister(R_CHANNEL, (byChannel & CHANNEL_MASK) | _BV(CHANNEL_TX_BIT)); //enable RX + + //Wait until the packet is sent. + while (digitalRead(_pin_pktflag) == 0) + { + //do nothing. + } + + return true; + } + + return false; +} + +const MiLightRadioConfig& LT8900MiLightRadio::config() { + return _config; +} diff --git a/lib/Radio/LT8900MiLightRadio.h b/lib/Radio/LT8900MiLightRadio.h new file mode 100644 index 0000000..0965561 --- /dev/null +++ b/lib/Radio/LT8900MiLightRadio.h @@ -0,0 +1,90 @@ +#ifdef ARDUINO +#include "Arduino.h" +#else +#include +#include +#include +#endif + +#include +#include + +//#define DEBUG_PRINTF + +// Register defines +#define REGISTER_READ 0b10000000 //bin +#define REGISTER_WRITE 0b00000000 //bin +#define REGISTER_MASK 0b01111111 //bin + +#define R_CHANNEL 7 +#define CHANNEL_RX_BIT 7 +#define CHANNEL_TX_BIT 8 +#define CHANNEL_MASK 0b01111111 ///bin + +#define STATUS_PKT_BIT_MASK 0x40 + +#define R_STATUS 48 +#define STATUS_CRC_BIT 15 + +#define R_FIFO 50 +#define R_FIFO_CONTROL 52 + +#define R_SYNCWORD1 36 +#define R_SYNCWORD2 37 +#define R_SYNCWORD3 38 +#define R_SYNCWORD4 39 + +#define DEFAULT_TIME_BETWEEN_RETRANSMISSIONS_uS 350 +// #define DEFAULT_TIME_BETWEEN_RETRANSMISSIONS_uS 0 + +#ifndef MILIGHTRADIOPL1167_LT8900_H_ +#define MILIGHTRADIOPL1167_LT8900_H_ + +class LT8900MiLightRadio : public MiLightRadio { + public: + LT8900MiLightRadio(byte byCSPin, byte byResetPin, byte byPktFlag, const MiLightRadioConfig& config); + + virtual int begin(); + virtual bool available(); + virtual int read(uint8_t frame[], size_t &frame_length); + virtual int write(uint8_t frame[], size_t frame_length); + virtual int resend(); + virtual int configure(); + virtual const MiLightRadioConfig& config(); + + private: + + void vInitRadioModule(); + void vSetSyncWord(uint16_t syncWord3, uint16_t syncWord2, uint16_t syncWord1, uint16_t syncWord0); + uint16_t uiReadRegister(uint8_t reg); + void regWrite16(byte ADDR, byte V1, byte V2, byte WAIT); + uint8_t uiWriteRegister(uint8_t reg, uint16_t data); + + bool bAvailablePin(void); + bool bAvailableRegister(void); + void vStartListening(uint uiChannelToListenTo); + void vResumeRX(void); + int iReadRXBuffer(uint8_t *buffer, size_t maxBuffer); + void vSetChannel(uint8_t channel); + void vGenericSendPacket(int iMode, int iLength, byte *pbyFrame, byte byChannel ); + bool bCheckRadioConnection(void); + bool sendPacket(uint8_t *data, size_t packetSize,byte byChannel); + + byte _pin_pktflag; + byte _csPin; + bool _bConnected; + + const MiLightRadioConfig& _config; + + uint8_t _channel; + uint8_t _packet[10]; + uint8_t _out_packet[10]; + bool _waiting; + int _dupes_received; + size_t _currentPacketLen; + size_t _currentPacketPos; +}; + + + +#endif diff --git a/lib/Radio/MiLightRadio.h b/lib/Radio/MiLightRadio.h new file mode 100644 index 0000000..df533cf --- /dev/null +++ b/lib/Radio/MiLightRadio.h @@ -0,0 +1,30 @@ + +#ifdef ARDUINO +#include "Arduino.h" +#else +#include +#include +#endif + +#include + +#ifndef _MILIGHT_RADIO_H_ +#define _MILIGHT_RADIO_H_ + +class MiLightRadio { + public: + + virtual int begin(); + virtual bool available(); + virtual int read(uint8_t frame[], size_t &frame_length); + virtual int write(uint8_t frame[], size_t frame_length); + virtual int resend(); + virtual int configure(); + virtual const MiLightRadioConfig& config(); + +}; + + + + +#endif diff --git a/lib/Radio/MiLightRadioConfig.cpp b/lib/Radio/MiLightRadioConfig.cpp new file mode 100644 index 0000000..9af7744 --- /dev/null +++ b/lib/Radio/MiLightRadioConfig.cpp @@ -0,0 +1,9 @@ +#include + +MiLightRadioConfig MiLightRadioConfig::ALL_CONFIGS[] = { + 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 new file mode 100644 index 0000000..cd512da --- /dev/null +++ b/lib/Radio/MiLightRadioConfig.h @@ -0,0 +1,83 @@ +#include +#include +#include +#include + +#ifndef _MILIGHT_RADIO_CONFIG +#define _MILIGHT_RADIO_CONFIG + +#define MILIGHT_MAX_PACKET_LENGTH 9 + +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, + 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); + } + } + + uint8_t channels[3]; + uint8_t syncwordBytes[SYNCWORD_LENGTH]; + uint16_t syncword0, syncword3; + + const size_t packetLength; + + static const size_t NUM_CONFIGS = 5; + static MiLightRadioConfig ALL_CONFIGS[NUM_CONFIGS]; +}; + +#endif diff --git a/lib/Radio/MiLightRadioFactory.cpp b/lib/Radio/MiLightRadioFactory.cpp new file mode 100644 index 0000000..5488e06 --- /dev/null +++ b/lib/Radio/MiLightRadioFactory.cpp @@ -0,0 +1,48 @@ +#include + +std::shared_ptr MiLightRadioFactory::fromSettings(const Settings& settings) { + switch (settings.radioInterfaceType) { + case nRF24: + return std::make_shared( + settings.csnPin, + settings.cePin, + settings.rf24PowerLevel, + settings.rf24Channels, + settings.rf24ListenChannel + ); + + case LT8900: + return std::make_shared(settings.csnPin, settings.resetPin, settings.cePin); + + default: + return NULL; + } +} + +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)); +} + +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) + : _csPin(csPin), + _resetPin(resetPin), + _pktFlag(pktFlag) +{ } + +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 new file mode 100644 index 0000000..ee95817 --- /dev/null +++ b/lib/Radio/MiLightRadioFactory.h @@ -0,0 +1,62 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef _MILIGHT_RADIO_FACTORY_H +#define _MILIGHT_RADIO_FACTORY_H + +class MiLightRadioFactory { +public: + + virtual ~MiLightRadioFactory() { }; + virtual std::shared_ptr create(const MiLightRadioConfig& config) = 0; + + static std::shared_ptr fromSettings(const Settings& settings); + +}; + +class NRF24Factory : public MiLightRadioFactory { +public: + + NRF24Factory( + uint8_t cePin, + uint8_t csnPin, + RF24PowerLevel rF24PowerLevel, + const std::vector& channels, + RF24Channel listenChannel + ); + + virtual std::shared_ptr create(const MiLightRadioConfig& config); + +protected: + + RF24 rf24; + const std::vector& channels; + const RF24Channel listenChannel; + +}; + +class LT8900Factory : public MiLightRadioFactory { +public: + + LT8900Factory(uint8_t csPin, uint8_t resetPin, uint8_t pktFlag); + + virtual std::shared_ptr create(const MiLightRadioConfig& config); + +protected: + + uint8_t _csPin; + uint8_t _resetPin; + uint8_t _pktFlag; + +}; + +#endif diff --git a/lib/Radio/NRF24MiLightRadio.cpp b/lib/Radio/NRF24MiLightRadio.cpp new file mode 100644 index 0000000..b2fefc9 --- /dev/null +++ b/lib/Radio/NRF24MiLightRadio.cpp @@ -0,0 +1,139 @@ +// Adapated from code from henryk + +#include +#include + +#define PACKET_ID(packet, packet_length) ( (packet[1] << 8) | packet[packet_length - 1] ) + +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() { + int retval = _pl1167.open(); + if (retval < 0) { + return retval; + } + + retval = configure(); + if (retval < 0) { + return retval; + } + + available(); + + return 0; +} + +int NRF24MiLightRadio::configure() { + int retval = _pl1167.setSyncword(_config.syncwordBytes, MiLightRadioConfig::SYNCWORD_LENGTH); + if (retval < 0) { + return retval; + } + + // +1 to be able to buffer the length + retval = _pl1167.setMaxPacketLength(_config.packetLength + 1); + if (retval < 0) { + return retval; + } + + return 0; +} + +bool NRF24MiLightRadio::available() { + if (_waiting) { +#ifdef DEBUG_PRINTF + printf("_waiting\n"); +#endif + return true; + } + + if (_pl1167.receive(_config.channels[listenChannelIx]) > 0) { +#ifdef DEBUG_PRINTF + printf("NRF24MiLightRadio - received packet!\n"); +#endif + size_t packet_length = sizeof(_packet); + if (_pl1167.readFIFO(_packet, packet_length) < 0) { + return false; + } +#ifdef DEBUG_PRINTF + printf("NRF24MiLightRadio - Checking packet length (expecting %d, is %d)\n", _packet[0] + 1U, packet_length); +#endif + if (packet_length == 0 || packet_length != _packet[0] + 1U) { + return false; + } + uint32_t packet_id = PACKET_ID(_packet, packet_length); +#ifdef DEBUG_PRINTF + printf("Packet id: %d\n", packet_id); +#endif + if (packet_id == _prev_packet_id) { + _dupes_received++; + } else { + _prev_packet_id = packet_id; + _waiting = true; + } + } + + return _waiting; +} + +int NRF24MiLightRadio::read(uint8_t frame[], size_t &frame_length) +{ + if (!_waiting) { + frame_length = 0; + return -1; + } + + if (frame_length > sizeof(_packet) - 1) { + frame_length = sizeof(_packet) - 1; + } + + if (frame_length > _packet[0]) { + frame_length = _packet[0]; + } + + memcpy(frame, _packet + 1, frame_length); + _waiting = false; + + return _packet[0]; +} + +int NRF24MiLightRadio::write(uint8_t frame[], size_t frame_length) { + if (frame_length > sizeof(_out_packet) - 1) { + return -1; + } + + memcpy(_out_packet + 1, frame, frame_length); + _out_packet[0] = frame_length; + + int retval = resend(); + if (retval < 0) { + return retval; + } + return frame_length; +} + +int NRF24MiLightRadio::resend() { + 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(channel); + } + + return 0; +} + +const MiLightRadioConfig& NRF24MiLightRadio::config() { + return _config; +} diff --git a/lib/Radio/NRF24MiLightRadio.h b/lib/Radio/NRF24MiLightRadio.h new file mode 100644 index 0000000..b7900e5 --- /dev/null +++ b/lib/Radio/NRF24MiLightRadio.h @@ -0,0 +1,53 @@ +#ifdef ARDUINO +#include "Arduino.h" +#else +#include +#include +#include +#endif + +#include +#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, + const std::vector& channels, + RF24Channel listenChannel + ); + + int begin(); + bool available(); + int read(uint8_t frame[], size_t &frame_length); + int dupesReceived(); + int write(uint8_t frame[], size_t frame_length); + int resend(); + int configure(); + const MiLightRadioConfig& config(); + + private: + const std::vector& channels; + const size_t listenChannelIx; + + PL1167_nRF24 _pl1167; + const MiLightRadioConfig& _config; + uint32_t _prev_packet_id; + + uint8_t _packet[10]; + uint8_t _out_packet[10]; + bool _waiting; + int _dupes_received; +}; + + + +#endif diff --git a/lib/Radio/PL1167_nRF24.cpp b/lib/Radio/PL1167_nRF24.cpp new file mode 100644 index 0000000..dc85474 --- /dev/null +++ b/lib/Radio/PL1167_nRF24.cpp @@ -0,0 +1,261 @@ +/* + * PL1167_nRF24.cpp + * + * Adapted from work by henryk: + * https://github.com/henryk/openmili + * Created on: 29 May 2015 + * Author: henryk + * Optimizations by khamann: + * https://github.com/khmann/esp8266_milight_hub/blob/e3600cef75b102ff3be51a7afdb55ab7460fe712/lib/MiLight/PL1167_nRF24.cpp + * + */ + +#include "PL1167_nRF24.h" +#include +#include + +static uint16_t calc_crc(uint8_t *data, size_t data_length); + +PL1167_nRF24::PL1167_nRF24(RF24 &radio) + : _radio(radio) +{ } + +int PL1167_nRF24::open() { + _radio.begin(); + _radio.setAutoAck(false); + _radio.setDataRate(RF24_1MBPS); + _radio.disableCRC(); + + _syncwordLength = MiLightRadioConfig::SYNCWORD_LENGTH; + _radio.setAddressWidth(_syncwordLength); + + return recalc_parameters(); +} + +int PL1167_nRF24::recalc_parameters() { + size_t nrf_address_length = _syncwordLength; + + // +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; + } + + if (packet_length > sizeof(_packet) || nrf_address_length < 3) { + return -1; + } + + if (_syncwordBytes != nullptr) { + _radio.openWritingPipe(_syncwordBytes); + _radio.openReadingPipe(1, _syncwordBytes); + } + + _receive_length = packet_length; + + _radio.setChannel(2 + _channel); + _radio.setPayloadSize( packet_length ); + + return 0; +} + +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) { + _maxPacketLength = maxPacketLength; + return recalc_parameters(); +} + +int PL1167_nRF24::receive(uint8_t channel) { + if (channel != _channel) { + _channel = channel; + int retval = recalc_parameters(); + if (retval < 0) { + return retval; + } + } + + _radio.startListening(); + if (_radio.available()) { +#ifdef DEBUG_PRINTF + printf("Radio is available\n"); +#endif + internal_receive(); + } + + if(_received) { +#ifdef DEBUG_PRINTF + if (_packet_length > 0) { + printf("Received packet (len = %d)!\n", _packet_length); + } +#endif + return _packet_length; + } else { + return 0; + } +} + +int PL1167_nRF24::readFIFO(uint8_t data[], size_t &data_length) +{ + if (data_length > _packet_length) { + data_length = _packet_length; + } + memcpy(data, _packet, data_length); + _packet_length -= data_length; + if (_packet_length) { + memmove(_packet, _packet + data_length, _packet_length); + } + return _packet_length; +} + +int PL1167_nRF24::writeFIFO(const uint8_t data[], size_t data_length) +{ + if (data_length > sizeof(_packet)) { + data_length = sizeof(_packet); + } + memcpy(_packet, data, data_length); + _packet_length = data_length; + _received = false; + + return data_length; +} + +int PL1167_nRF24::transmit(uint8_t channel) { + if (channel != _channel) { + _channel = channel; + int retval = recalc_parameters(); + if (retval < 0) { + return retval; + } + yield(); + } + + _radio.stopListening(); + uint8_t tmp[sizeof(_packet)]; + int outp=0; + + uint16_t crc = calc_crc(_packet, _packet_length); + + // +1 for packet length + // +2 for crc + // = 3 + for (int inp = 0; inp < _packet_length + 3; inp++) { + if (inp < _packet_length) { + tmp[outp++] = reverseBits(_packet[inp]);} + else if (inp < _packet_length + 2) { + tmp[outp++] = reverseBits((crc >> ( (inp - _packet_length) * 8)) & 0xff); + } + } + + yield(); + + _radio.write(tmp, outp); + return 0; +} + +/** + * 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; + + _radio.read(tmp, _receive_length); + + // HACK HACK HACK: Reset radio + open(); + +// 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++] = reverseBits(tmp[inp]); + } + +#ifdef DEBUG_PRINTF + Serial.printf_P(PSTR("Packet received (%d bytes): "), outp); + for (int i = 0; i < outp; i++) { + Serial.printf_P(PSTR("%02X "), tmp[i]); + } + Serial.print(F("\n")); +#endif + + if (outp < 2) { +#ifdef DEBUG_PRINTF + Serial.println(F("Failed CRC: outp < 2")); +#endif + 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 + Serial.printf_P(PSTR("Failed CRC: expected %04X, got %04X\n"), crc, recvCrc); +#endif + return 0; + } + outp -= 2; + + memcpy(_packet, tmp, outp); + + _packet_length = outp; + _received = true; + +#ifdef DEBUG_PRINTF + Serial.printf_P(PSTR("Successfully parsed packet of length %d\n"), _packet_length); +#endif + + return outp; +} + +#define CRC_POLY 0x8408 + +static uint16_t calc_crc(uint8_t *data, size_t data_length) { + uint16_t state = 0; + for (size_t i = 0; i < data_length; i++) { + uint8_t byte = data[i]; + for (int j = 0; j < 8; j++) { + if ((byte ^ state) & 0x01) { + state = (state >> 1) ^ CRC_POLY; + } else { + state = state >> 1; + } + byte = byte >> 1; + } + } + return state; +} \ No newline at end of file diff --git a/lib/Radio/PL1167_nRF24.h b/lib/Radio/PL1167_nRF24.h new file mode 100644 index 0000000..b38dde7 --- /dev/null +++ b/lib/Radio/PL1167_nRF24.h @@ -0,0 +1,56 @@ +/* + * PL1167_nRF24.h + * + * Created on: 29 May 2015 + * Author: henryk + */ + +#ifdef ARDUINO +#include "Arduino.h" +#endif + +#include "RF24.h" + +// #define DEBUG_PRINTF + +#ifndef PL1167_NRF24_H_ +#define PL1167_NRF24_H_ + +class PL1167_nRF24 { + public: + PL1167_nRF24(RF24& radio); + int open(); + + 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); + int readFIFO(uint8_t data[], size_t &data_length); + + private: + RF24 &_radio; + + const uint8_t* _syncwordBytes = nullptr; + uint8_t _syncwordLength = 4; + uint8_t _maxPacketLength = 8; + + uint8_t _channel = 0; + + uint8_t _nrf_pipe[5]; + uint8_t _nrf_pipe_length; + + uint8_t _packet_length = 0; + uint8_t _receive_length = 0; + uint8_t _preamble = 0; + uint8_t _packet[32]; + bool _received = false; + + int recalc_parameters(); + int internal_receive(); + +}; + + +#endif /* PL1167_NRF24_H_ */ diff --git a/lib/Radio/RadioUtils.cpp b/lib/Radio/RadioUtils.cpp new file mode 100644 index 0000000..aa97a14 --- /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 0000000..f909bec --- /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/SSDP/New_ESP8266SSDP.cpp b/lib/SSDP/New_ESP8266SSDP.cpp new file mode 100644 index 0000000..481cd97 --- /dev/null +++ b/lib/SSDP/New_ESP8266SSDP.cpp @@ -0,0 +1,441 @@ +/* +ESP8266 Simple Service Discovery +Copyright (c) 2015 Hristo Gochkov + +Original (Arduino) version by Filippo Sallemi, July 23, 2014. +Can be found at: https://github.com/nomadnt/uSSDP + +License (MIT license): + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + +*/ +#define LWIP_OPEN_SRC +#include +#include "New_ESP8266SSDP.h" +#include "WiFiUdp.h" +#include "debug.h" + +extern "C" { + #include "osapi.h" + #include "ets_sys.h" + #include "user_interface.h" +} + +#include "lwip/opt.h" +#include "lwip/udp.h" +#include "lwip/inet.h" +#include "lwip/igmp.h" +#include "lwip/mem.h" +#include "include/UdpContext.h" + +// #define DEBUG_SSDP Serial + +#define SSDP_INTERVAL 1200 +#define SSDP_PORT 1900 +#define SSDP_METHOD_SIZE 10 +#define SSDP_URI_SIZE 2 +#define SSDP_BUFFER_SIZE 64 +#define SSDP_MULTICAST_TTL 2 +static const IPAddress SSDP_MULTICAST_ADDR(239, 255, 255, 250); + + + +static const char _ssdp_response_template[] PROGMEM = + "HTTP/1.1 200 OK\r\n" + "EXT:\r\n"; + +static const char _ssdp_notify_template[] PROGMEM = + "NOTIFY * HTTP/1.1\r\n" + "HOST: 239.255.255.250:1900\r\n" + "NTS: ssdp:alive\r\n"; + +static const char _ssdp_packet_template[] PROGMEM = + "%s" // _ssdp_response_template / _ssdp_notify_template + "CACHE-CONTROL: max-age=%u\r\n" // SSDP_INTERVAL + "SERVER: Arduino/1.0 UPNP/1.1 %s/%s\r\n" // _modelName, _modelNumber + "USN: uuid:%s\r\n" // _uuid + "%s: %s\r\n" // "NT" or "ST", _deviceType + "LOCATION: http://%u.%u.%u.%u:%u/%s\r\n" // WiFi.localIP(), _port, _schemaURL + "\r\n"; + +static const char _ssdp_schema_template[] PROGMEM = + "HTTP/1.1 200 OK\r\n" + "Content-Type: text/xml\r\n" + "Connection: close\r\n" + "Access-Control-Allow-Origin: *\r\n" + "\r\n" + "" + "" + "" + "1" + "0" + "" + "http://%u.%u.%u.%u:%u/" // WiFi.localIP(), _port + "" + "%s" + "%s" + "%s" + "%s" + "%s" + "%s" + "%s" + "%s" + "%s" + "uuid:%s" + "" +// "" +// "" +// "image/png" +// "48" +// "48" +// "24" +// "icon48.png" +// "" +// "" +// "image/png" +// "120" +// "120" +// "24" +// "icon120.png" +// "" +// "" + "\r\n" + "\r\n"; + + +struct SSDPTimer { + ETSTimer timer; +}; + +SSDPClass::SSDPClass() : +_server(0), +_timer(new SSDPTimer), +_port(80), +_ttl(SSDP_MULTICAST_TTL), +_respondToPort(0), +_pending(false), +_delay(0), +_process_time(0), +_notify_time(0) +{ + _uuid[0] = '\0'; + _modelNumber[0] = '\0'; + sprintf(_deviceType, "urn:schemas-upnp-org:device:Basic:1"); + _friendlyName[0] = '\0'; + _presentationURL[0] = '\0'; + _serialNumber[0] = '\0'; + _modelName[0] = '\0'; + _modelURL[0] = '\0'; + _manufacturer[0] = '\0'; + _manufacturerURL[0] = '\0'; + sprintf(_schemaURL, "ssdp/schema.xml"); +} + +SSDPClass::~SSDPClass(){ + delete _timer; +} + +bool SSDPClass::begin(){ + _pending = false; + + uint32_t chipId = ESP.getChipId(); + sprintf(_uuid, "38323636-4558-4dda-9188-cda0e6%02x%02x%02x", + (uint16_t) ((chipId >> 16) & 0xff), + (uint16_t) ((chipId >> 8) & 0xff), + (uint16_t) chipId & 0xff ); + +#ifdef DEBUG_SSDP + DEBUG_SSDP.printf("SSDP UUID: %s\n", (char *)_uuid); +#endif + + if (_server) { + _server->unref(); + _server = 0; + } + + _server = new UdpContext; + _server->ref(); + + ip_addr_t ifaddr; + ifaddr.addr = WiFi.localIP(); + ip_addr_t multicast_addr; + multicast_addr.addr = (uint32_t) SSDP_MULTICAST_ADDR; + if (igmp_joingroup(&ifaddr, &multicast_addr) != ERR_OK ) { + DEBUGV("SSDP failed to join igmp group"); + return false; + } + + if (!_server->listen(*IP_ADDR_ANY, SSDP_PORT)) { + return false; + } + + _server->setMulticastInterface(ifaddr); + _server->setMulticastTTL(_ttl); + _server->onRx(std::bind(&SSDPClass::_update, this)); + if (!_server->connect(multicast_addr, SSDP_PORT)) { + return false; + } + + _startTimer(); + + return true; +} + +void SSDPClass::_send(ssdp_method_t method){ + char buffer[1460]; + uint32_t ip = WiFi.localIP(); + + char valueBuffer[strlen(_ssdp_notify_template)+1]; + strcpy_P(valueBuffer, (method == NONE)?_ssdp_response_template:_ssdp_notify_template); + + int len = snprintf_P(buffer, sizeof(buffer), + _ssdp_packet_template, + valueBuffer, + SSDP_INTERVAL, + _modelName, _modelNumber, + _uuid, + (method == NONE)?"ST":"NT", + _deviceType, + IP2STR(&ip), _port, _schemaURL + ); + + _server->append(buffer, len); + + ip_addr_t remoteAddr; + uint16_t remotePort; + if(method == NONE) { + remoteAddr.addr = _respondToAddr; + remotePort = _respondToPort; +#ifdef DEBUG_SSDP + DEBUG_SSDP.print("Sending Response to "); +#endif + } else { + remoteAddr.addr = SSDP_MULTICAST_ADDR; + remotePort = SSDP_PORT; +#ifdef DEBUG_SSDP + DEBUG_SSDP.println("Sending Notify to "); +#endif + } +#ifdef DEBUG_SSDP + DEBUG_SSDP.print(IPAddress(remoteAddr.addr)); + DEBUG_SSDP.print(":"); + DEBUG_SSDP.println(remotePort); +#endif + + _server->send(&remoteAddr, remotePort); +} + +void SSDPClass::schema(WiFiClient client){ + uint32_t ip = WiFi.localIP(); + char buffer[strlen(_ssdp_schema_template)+1]; + strcpy_P(buffer, _ssdp_schema_template); + client.printf(buffer, + IP2STR(&ip), _port, + _deviceType, + _friendlyName, + _presentationURL, + _serialNumber, + _modelName, + _modelNumber, + _modelURL, + _manufacturer, + _manufacturerURL, + _uuid + ); +} + +void SSDPClass::_update(){ + if(!_pending && _server->next()) { + ssdp_method_t method = NONE; + + _respondToAddr = _server->getRemoteAddress(); + _respondToPort = _server->getRemotePort(); + + typedef enum {METHOD, URI, PROTO, KEY, VALUE, ABORT} states; + states state = METHOD; + + typedef enum {START, MAN, ST, MX} headers; + headers header = START; + + uint8_t cursor = 0; + uint8_t cr = 0; + + char buffer[SSDP_BUFFER_SIZE] = {0}; + + while(_server->getSize() > 0){ + char c = _server->read(); + + (c == '\r' || c == '\n') ? cr++ : cr = 0; + + switch(state){ + case METHOD: + if(c == ' '){ + if(strcmp(buffer, "M-SEARCH") == 0) method = SEARCH; + else if(strcmp(buffer, "NOTIFY") == 0) method = NOTIFY; + + if(method == NONE) state = ABORT; + else state = URI; + cursor = 0; + + } else if(cursor < SSDP_METHOD_SIZE - 1){ buffer[cursor++] = c; buffer[cursor] = '\0'; } + break; + case URI: + if(c == ' '){ + if(strcmp(buffer, "*")) state = ABORT; + else state = PROTO; + cursor = 0; + } else if(cursor < SSDP_URI_SIZE - 1){ buffer[cursor++] = c; buffer[cursor] = '\0'; } + break; + case PROTO: + if(cr == 2){ state = KEY; cursor = 0; } + break; + case KEY: + if(cr == 4){ _pending = true; _process_time = millis(); } + else if(c == ' '){ cursor = 0; state = VALUE; } + else if(c != '\r' && c != '\n' && c != ':' && cursor < SSDP_BUFFER_SIZE - 1){ buffer[cursor++] = c; buffer[cursor] = '\0'; } + break; + case VALUE: + if(cr == 2){ + switch(header){ + case START: + break; + case MAN: +#ifdef DEBUG_SSDP + DEBUG_SSDP.printf("MAN: %s\n", (char *)buffer); +#endif + break; + case ST: + if(strcmp(buffer, "ssdp:all")){ + state = ABORT; +#ifdef DEBUG_SSDP + DEBUG_SSDP.printf("REJECT: %s\n", (char *)buffer); +#endif + } + // if the search type matches our type, we should respond instead of ABORT + if(strcmp(buffer, _deviceType) == 0){ + _pending = true; + _process_time = millis(); + state = KEY; + } + break; + case MX: + _delay = random(0, atoi(buffer)) * 1000L; + break; + } + + if(state != ABORT){ state = KEY; header = START; cursor = 0; } + } else if(c != '\r' && c != '\n'){ + if(header == START){ + if(strncmp(buffer, "MA", 2) == 0) header = MAN; + else if(strcmp(buffer, "ST") == 0) header = ST; + else if(strcmp(buffer, "MX") == 0) header = MX; + } + + if(cursor < SSDP_BUFFER_SIZE - 1){ buffer[cursor++] = c; buffer[cursor] = '\0'; } + } + break; + case ABORT: + _pending = false; _delay = 0; + break; + } + } + } + + if(_pending && (millis() - _process_time) > _delay){ + _pending = false; _delay = 0; + _send(NONE); + } else if(_notify_time == 0 || (millis() - _notify_time) > (SSDP_INTERVAL * 1000L)){ + _notify_time = millis(); + _send(NOTIFY); + } + + if (_pending) { + while (_server->next()) + _server->flush(); + } + +} + +void SSDPClass::setSchemaURL(const char *url){ + strlcpy(_schemaURL, url, sizeof(_schemaURL)); +} + +void SSDPClass::setHTTPPort(uint16_t port){ + _port = port; +} + +void SSDPClass::setDeviceType(const char *deviceType){ + strlcpy(_deviceType, deviceType, sizeof(_deviceType)); +} + +void SSDPClass::setName(const char *name){ + strlcpy(_friendlyName, name, sizeof(_friendlyName)); +} + +void SSDPClass::setURL(const char *url){ + strlcpy(_presentationURL, url, sizeof(_presentationURL)); +} + +void SSDPClass::setSerialNumber(const char *serialNumber){ + strlcpy(_serialNumber, serialNumber, sizeof(_serialNumber)); +} + +void SSDPClass::setSerialNumber(const uint32_t serialNumber){ + snprintf(_serialNumber, sizeof(uint32_t)*2+1, "%08X", serialNumber); +} + +void SSDPClass::setModelName(const char *name){ + strlcpy(_modelName, name, sizeof(_modelName)); +} + +void SSDPClass::setModelNumber(const char *num){ + strlcpy(_modelNumber, num, sizeof(_modelNumber)); +} + +void SSDPClass::setModelURL(const char *url){ + strlcpy(_modelURL, url, sizeof(_modelURL)); +} + +void SSDPClass::setManufacturer(const char *name){ + strlcpy(_manufacturer, name, sizeof(_manufacturer)); +} + +void SSDPClass::setManufacturerURL(const char *url){ + strlcpy(_manufacturerURL, url, sizeof(_manufacturerURL)); +} + +void SSDPClass::setTTL(const uint8_t ttl){ + _ttl = ttl; +} + +void SSDPClass::_onTimerStatic(SSDPClass* self) { + self->_update(); +} + +void SSDPClass::_startTimer() { + ETSTimer* tm = &(_timer->timer); + const int interval = 1000; + os_timer_disarm(tm); + os_timer_setfn(tm, reinterpret_cast(&SSDPClass::_onTimerStatic), reinterpret_cast(this)); + os_timer_arm(tm, interval, 1 /* repeat */); +} + +#if !defined(NO_GLOBAL_INSTANCES) && !defined(NO_GLOBAL_SSDP) +SSDPClass SSDP; +#endif diff --git a/lib/SSDP/New_ESP8266SSDP.h b/lib/SSDP/New_ESP8266SSDP.h new file mode 100644 index 0000000..74d8bba --- /dev/null +++ b/lib/SSDP/New_ESP8266SSDP.h @@ -0,0 +1,128 @@ +/* +ESP8266 Simple Service Discovery +Copyright (c) 2015 Hristo Gochkov + +Original (Arduino) version by Filippo Sallemi, July 23, 2014. +Can be found at: https://github.com/nomadnt/uSSDP + +License (MIT license): + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + +*/ + +#ifndef ESP8266SSDP_H +#define ESP8266SSDP_H + +#include +#include +#include + +class UdpContext; + +#define SSDP_UUID_SIZE 37 +#define SSDP_SCHEMA_URL_SIZE 64 +#define SSDP_DEVICE_TYPE_SIZE 64 +#define SSDP_FRIENDLY_NAME_SIZE 64 +#define SSDP_SERIAL_NUMBER_SIZE 32 +#define SSDP_PRESENTATION_URL_SIZE 128 +#define SSDP_MODEL_NAME_SIZE 64 +#define SSDP_MODEL_URL_SIZE 128 +#define SSDP_MODEL_VERSION_SIZE 32 +#define SSDP_MANUFACTURER_SIZE 64 +#define SSDP_MANUFACTURER_URL_SIZE 128 + +typedef enum { + NONE, + SEARCH, + NOTIFY +} ssdp_method_t; + + +struct SSDPTimer; + +class SSDPClass{ + public: + SSDPClass(); + ~SSDPClass(); + + bool begin(); + + void schema(WiFiClient client); + + void setDeviceType(const String& deviceType) { setDeviceType(deviceType.c_str()); } + void setDeviceType(const char *deviceType); + void setName(const String& name) { setName(name.c_str()); } + void setName(const char *name); + void setURL(const String& url) { setURL(url.c_str()); } + void setURL(const char *url); + void setSchemaURL(const String& url) { setSchemaURL(url.c_str()); } + void setSchemaURL(const char *url); + void setSerialNumber(const String& serialNumber) { setSerialNumber(serialNumber.c_str()); } + void setSerialNumber(const char *serialNumber); + void setSerialNumber(const uint32_t serialNumber); + void setModelName(const String& name) { setModelName(name.c_str()); } + void setModelName(const char *name); + void setModelNumber(const String& num) { setModelNumber(num.c_str()); } + void setModelNumber(const char *num); + void setModelURL(const String& url) { setModelURL(url.c_str()); } + void setModelURL(const char *url); + void setManufacturer(const String& name) { setManufacturer(name.c_str()); } + void setManufacturer(const char *name); + void setManufacturerURL(const String& url) { setManufacturerURL(url.c_str()); } + void setManufacturerURL(const char *url); + void setHTTPPort(uint16_t port); + void setTTL(uint8_t ttl); + + protected: + void _send(ssdp_method_t method); + void _update(); + void _startTimer(); + static void _onTimerStatic(SSDPClass* self); + + UdpContext* _server; + SSDPTimer* _timer; + uint16_t _port; + uint8_t _ttl; + + IPAddress _respondToAddr; + uint16_t _respondToPort; + + bool _pending; + unsigned short _delay; + unsigned long _process_time; + unsigned long _notify_time; + + char _schemaURL[SSDP_SCHEMA_URL_SIZE]; + char _uuid[SSDP_UUID_SIZE]; + char _deviceType[SSDP_DEVICE_TYPE_SIZE]; + char _friendlyName[SSDP_FRIENDLY_NAME_SIZE]; + char _serialNumber[SSDP_SERIAL_NUMBER_SIZE]; + char _presentationURL[SSDP_PRESENTATION_URL_SIZE]; + char _manufacturer[SSDP_MANUFACTURER_SIZE]; + char _manufacturerURL[SSDP_MANUFACTURER_URL_SIZE]; + char _modelName[SSDP_MODEL_NAME_SIZE]; + char _modelURL[SSDP_MODEL_URL_SIZE]; + char _modelNumber[SSDP_MODEL_VERSION_SIZE]; +}; + +#if !defined(NO_GLOBAL_INSTANCES) && !defined(NO_GLOBAL_SSDP) +extern SSDPClass SSDP; +#endif + +#endif diff --git a/lib/Settings/AboutHelper.cpp b/lib/Settings/AboutHelper.cpp new file mode 100644 index 0000000..aac1233 --- /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 0000000..0a25f85 --- /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 new file mode 100644 index 0000000..0410039 --- /dev/null +++ b/lib/Settings/Settings.cpp @@ -0,0 +1,383 @@ +#include +#include +#include +#include +#include +#include + +#define PORT_POSITION(s) ( s.indexOf(':') ) + +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; +} + +size_t Settings::getAutoRestartPeriod() { + if (_autoRestartPeriod == 0) { + return 0; + } + + return std::max(_autoRestartPeriod, static_cast(MINIMUM_RESTART_PERIOD)); +} + +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::updateGatewayConfigs(JsonArray arr) { + gatewayConfigs.clear(); + + 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::patch(JsonObject parsedSettings) { + if (parsedSettings.isNull()) { + Serial.println(F("Skipping patching loaded settings. Parsed settings was null.")); + return; + } + + 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, "mqtt_debounce_delay", mqttDebounceDelay); + this->setIfPresent(parsedSettings, "mqtt_retain", mqttRetain); + 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); + this->setIfPresent(parsedSettings, "default_transition_period", defaultTransitionPeriod); + + if (parsedSettings.containsKey("wifi_mode")) { + this->wifiMode = wifiModeFromString(parsedSettings["wifi_mode"]); + } + + if (parsedSettings.containsKey("rf24_channels")) { + JsonArray arr = parsedSettings["rf24_channels"]; + rf24Channels = JsonHelpers::jsonArrToVector(arr, RF24ChannelHelpers::valueFromName); + } + + if (parsedSettings.containsKey("rf24_listen_channel")) { + this->rf24ListenChannel = RF24ChannelHelpers::valueFromName(parsedSettings["rf24_listen_channel"]); + } + + 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"]); + } + + if (parsedSettings.containsKey("led_mode_wifi_failed")) { + this->ledModeWifiFailed = LEDStatus::stringToLEDMode(parsedSettings["led_mode_wifi_failed"]); + } + + if (parsedSettings.containsKey("led_mode_operating")) { + this->ledModeOperating = LEDStatus::stringToLEDMode(parsedSettings["led_mode_operating"]); + } + + 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); + } +} + +std::map::const_iterator Settings::findAlias(MiLightRemoteType deviceType, uint16_t deviceId, uint8_t groupId) { + BulbId searchId{ deviceId, groupId, deviceType }; + + for (auto it = groupIdAliases.begin(); it != groupIdAliases.end(); ++it) { + if (searchId == it->second) { + return it; + } + } + + return groupIdAliases.end(); +} + +void Settings::parseGroupIdAliases(JsonObject json) { + JsonObject aliases = json["group_id_aliases"]; + + // 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; + } + + 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"); + + DynamicJsonDocument json(MILIGHT_HUB_SETTINGS_BUFFER_SIZE); + auto error = deserializeJson(json, f); + f.close(); + + 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(); + } +} + +String Settings::toJson(const bool prettyPrint) { + String buffer = ""; + StringStream s(buffer); + serialize(s, prettyPrint); + return buffer; +} + +void Settings::save() { + File f = SPIFFS.open(SETTINGS_FILE, "w"); + + if (!f) { + Serial.println(F("Opening settings file failed")); + } else { + serialize(f); + f.close(); + } +} + +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; + root["ce_pin"] = this->cePin; + root["csn_pin"] = this->csnPin; + root["reset_pin"] = this->resetPin; + root["led_pin"] = this->ledPin; + root["radio_interface_type"] = typeToString(this->radioInterfaceType); + root["packet_repeats"] = this->packetRepeats; + root["http_repeat_factor"] = this->httpRepeatFactor; + root["auto_restart_period"] = this->_autoRestartPeriod; + root["mqtt_server"] = this->_mqttServer; + root["mqtt_username"] = this->mqttUsername; + root["mqtt_password"] = this->mqttPassword; + 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; + root["mqtt_state_rate_limit"] = this->mqttStateRateLimit; + root["mqtt_debounce_delay"] = this->mqttDebounceDelay; + root["mqtt_retain"] = this->mqttRetain; + root["packet_repeat_throttle_sensitivity"] = this->packetRepeatThrottleSensitivity; + root["packet_repeat_throttle_threshold"] = this->packetRepeatThrottleThreshold; + root["packet_repeat_minimum"] = this->packetRepeatMinimum; + root["enable_automatic_mode_switching"] = this->enableAutomaticModeSwitching; + root["led_mode_wifi_config"] = LEDStatus::LEDModeToString(this->ledModeWifiConfig); + root["led_mode_wifi_failed"] = LEDStatus::LEDModeToString(this->ledModeWifiFailed); + root["led_mode_operating"] = LEDStatus::LEDModeToString(this->ledModeOperating); + root["led_mode_packet"] = LEDStatus::LEDModeToString(this->ledModePacket); + root["led_mode_packet_count"] = this->ledModePacketCount; + 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); + root["default_transition_period"] = this->defaultTransitionPeriod; + + 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); + } + + JsonArray groupStateFieldArr = root.createNestedArray("group_state_fields"); + JsonHelpers::vectorToJsonArr(groupStateFieldArr, groupStateFields, GroupStateFieldHelpers::getFieldName); + + dumpGroupIdAliases(root.as()); + + if (prettyPrint) { + serializeJsonPretty(root, stream); + } else { + serializeJson(root, stream); + } +} + +String Settings::mqttServer() { + int pos = PORT_POSITION(_mqttServer); + + if (pos == -1) { + return _mqttServer; + } else { + return _mqttServer.substring(0, pos); + } +} + +uint16_t Settings::mqttPort() { + int pos = PORT_POSITION(_mqttServer); + + if (pos == -1) { + return DEFAULT_MQTT_PORT; + } else { + return atoi(_mqttServer.c_str() + pos + 1); + } +} + +RadioInterfaceType Settings::typeFromString(const String& s) { + if (s.equalsIgnoreCase("lt8900")) { + return LT8900; + } else { + return nRF24; + } +} + +String Settings::typeToString(RadioInterfaceType type) { + switch (type) { + case LT8900: + return "LT8900"; + + case nRF24: + default: + 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 new file mode 100644 index 0000000..bb95d38 --- /dev/null +++ b/lib/Settings/Settings.h @@ -0,0 +1,219 @@ +#include +#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 + +#ifndef MILIGHT_HUB_VERSION +#define MILIGHT_HUB_VERSION unknown +#endif + +#ifndef MILIGHT_MAX_STATE_ITEMS +#define MILIGHT_MAX_STATE_ITEMS 100 +#endif + +#ifndef MILIGHT_MAX_STALE_MQTT_GROUPS +#define MILIGHT_MAX_STALE_MQTT_GROUPS 10 +#endif + +#define SETTINGS_FILE "/config.json" +#define SETTINGS_TERMINATOR '\0' + +#define WEB_INDEX_FILENAME "/web/index.html" + +#define MILIGHT_GITHUB_USER "sidoh" +#define MILIGHT_GITHUB_REPO "esp8266_milight_hub" +#define MILIGHT_REPO_WEB_PATH "/data/web/index.html" + +#define MINIMUM_RESTART_PERIOD 1 +#define DEFAULT_MQTT_PORT 1883 +#define MAX_IP_ADDR_LEN 15 + +enum RadioInterfaceType { + nRF24 = 0, + LT8900 = 1, +}; + +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 +}); + +struct GatewayConfig { + GatewayConfig(uint16_t deviceId, uint16_t port, uint8_t protocolVersion); + + const uint16_t deviceId; + const uint16_t port; + const uint8_t protocolVersion; +}; + +class Settings { +public: + Settings() : + adminUsername(""), + adminPassword(""), + // CE and CSN pins from nrf24l01 + cePin(4), + csnPin(15), + resetPin(0), + ledPin(-2), + radioInterfaceType(nRF24), + packetRepeats(50), + httpRepeatFactor(1), + listenRepeats(3), + discoveryPort(48899), + simpleMqttClientStatus(false), + stateFlushInterval(10000), + mqttStateRateLimit(500), + mqttDebounceDelay(500), + mqttRetain(true), + packetRepeatThrottleThreshold(200), + packetRepeatThrottleSensitivity(0), + packetRepeatMinimum(3), + enableAutomaticModeSwitching(false), + ledModeWifiConfig(LEDStatus::LEDMode::FastToggle), + ledModeWifiFailed(LEDStatus::LEDMode::On), + ledModeOperating(LEDStatus::LEDMode::SlowBlip), + ledModePacket(LEDStatus::LEDMode::Flicker), + 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), + defaultTransitionPeriod(500), + _autoRestartPeriod(0) + { } + + ~Settings() { } + + bool isAuthenticationEnabled() const; + const String& getUsername() const; + const String& getPassword() const; + + bool isAutoRestartEnabled(); + size_t getAutoRestartPeriod(); + + 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(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; + uint8_t cePin; + uint8_t csnPin; + uint8_t resetPin; + int8_t ledPin; + RadioInterfaceType radioInterfaceType; + size_t packetRepeats; + size_t httpRepeatFactor; + uint8_t listenRepeats; + uint16_t discoveryPort; + String _mqttServer; + String mqttUsername; + String mqttPassword; + String mqttTopicPattern; + String mqttUpdateTopicPattern; + String mqttStateTopicPattern; + String mqttClientStatusTopic; + bool simpleMqttClientStatus; + size_t stateFlushInterval; + size_t mqttStateRateLimit; + size_t mqttDebounceDelay; + bool mqttRetain; + size_t packetRepeatThrottleThreshold; + size_t packetRepeatThrottleSensitivity; + size_t packetRepeatMinimum; + bool enableAutomaticModeSwitching; + LEDStatus::LEDMode ledModeWifiConfig; + LEDStatus::LEDMode ledModeWifiFailed; + 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; + uint16_t defaultTransitionPeriod; + +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) { + if (obj.containsKey(key)) { + JsonVariant val = obj[key]; + var = val.as(); + } + } +}; + +#endif diff --git a/lib/Settings/StringStream.h b/lib/Settings/StringStream.h new file mode 100644 index 0000000..a543cc6 --- /dev/null +++ b/lib/Settings/StringStream.h @@ -0,0 +1,29 @@ +/* + * Adapated from https://gist.github.com/cmaglie/5883185 + */ + +#ifndef _STRING_STREAM_H_INCLUDED_ +#define _STRING_STREAM_H_INCLUDED_ + +#include + +class StringStream : public Stream +{ +public: + StringStream(String &s) : string(s), position(0) { } + + // Stream methods + virtual int available() { return string.length() - position; } + virtual int read() { return position < string.length() ? string[position++] : -1; } + 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; return 1; }; + +private: + String &string; + unsigned int length; + unsigned int position; +}; + +#endif // _STRING_STREAM_H_INCLUDED_ \ No newline at end of file diff --git a/lib/Transitions/ChangeFieldOnFinishTransition.cpp b/lib/Transitions/ChangeFieldOnFinishTransition.cpp new file mode 100644 index 0000000..680a709 --- /dev/null +++ b/lib/Transitions/ChangeFieldOnFinishTransition.cpp @@ -0,0 +1,60 @@ +#include +#include + +ChangeFieldOnFinishTransition::Builder::Builder( + size_t id, + GroupStateField field, + uint16_t arg, + std::shared_ptr delegate +) + : Transition::Builder(delegate->id, delegate->defaultPeriod, delegate->bulbId, delegate->callback, delegate->getMaxSteps()) + , delegate(delegate) + , field(field) + , arg(arg) +{ } + +std::shared_ptr ChangeFieldOnFinishTransition::Builder::_build() const { + delegate->setDurationRaw(this->getOrComputeDuration()); + 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 0000000..7ffe6cc --- /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 0000000..e8a0272 --- /dev/null +++ b/lib/Transitions/ColorTransition.cpp @@ -0,0 +1,156 @@ +#include +#include + +ColorTransition::Builder::Builder(size_t id, uint16_t defaultPeriod, const BulbId& bulbId, TransitionFn callback, const ParsedColor& start, const ParsedColor& end) + : Transition::Builder(id, defaultPeriod, bulbId, callback, calculateMaxDistance(start, end)) + , 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::calculateMaxDistance(const ParsedColor& start, const ParsedColor& end) { + 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 maxAbs; +} + +size_t ColorTransition::calculateColorPeriod(ColorTransition* t, const ParsedColor& start, const ParsedColor& end, size_t stepSize, size_t duration) { + return Transition::calculatePeriod(calculateMaxDistance(start, end), 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 0000000..1c66071 --- /dev/null +++ b/lib/Transitions/ColorTransition.h @@ -0,0 +1,59 @@ +#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, uint16_t defaultPeriod, 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 size_t calculateMaxDistance(const ParsedColor& start, const ParsedColor& end); + 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 0000000..2a39cef --- /dev/null +++ b/lib/Transitions/FieldTransition.cpp @@ -0,0 +1,85 @@ +#include +#include +#include + +FieldTransition::Builder::Builder(size_t id, uint16_t defaultPeriod, const BulbId& bulbId, TransitionFn callback, GroupStateField field, uint16_t start, uint16_t end) + : Transition::Builder( + id, + defaultPeriod, + bulbId, + callback, + max( + static_cast(1), + static_cast(std::abs(static_cast(end) - static_cast(start))) + ) + ) + , 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 0000000..95a4052 --- /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, uint16_t defaultPeriod, 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 0000000..b1fead1 --- /dev/null +++ b/lib/Transitions/Transition.cpp @@ -0,0 +1,179 @@ +#include +#include +#include + +Transition::Builder::Builder(size_t id, uint16_t defaultPeriod, const BulbId& bulbId, TransitionFn callback, size_t maxSteps) + : id(id) + , defaultPeriod(defaultPeriod) + , bulbId(bulbId) + , callback(callback) + , duration(0) + , period(0) + , numPeriods(0) + , maxSteps(maxSteps) +{ } + +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::setDurationAwarePeriod(size_t period, size_t duration, size_t maxSteps) { + if ((period * maxSteps) < duration) { + setPeriod(std::ceil(duration / static_cast(maxSteps))); + } else { + setPeriod(period); + } + 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; +} + +size_t Transition::Builder::getMaxSteps() const { + return this->maxSteps; +} + +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); + setDurationAwarePeriod(defaultPeriod, duration, maxSteps); + } else if (numSet == 1) { + // If duration is unbound, bind it + if (! isSetDuration()) { + setDurationRaw(DEFAULT_DURATION); + // Otherwise, bind the period + } else { + setDurationAwarePeriod(defaultPeriod, duration, maxSteps); + } + } + + 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 0000000..72404a7 --- /dev/null +++ b/lib/Transitions/Transition.h @@ -0,0 +1,99 @@ +#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, uint16_t defaultPeriod, const BulbId& bulbId, TransitionFn callback, size_t maxSteps); + + Builder& setDuration(float duration); + Builder& setPeriod(size_t period); + + /** + * Users are typically defining transitions using: + * 1. The desired end state (and implicitly the start state, assumed to be current) + * 2. The duraiton + * The user only cares about the period to the degree that it affects the smoothness of + * the transition. + * + * For example, if the user wants to throttle brightness from 0 -> 100 over 5min, the + * default period is going to be way too short to enable that. So we need to force the + * period to be longer to fit the duration. + */ + Builder& setDurationAwarePeriod(size_t desiredPeriod, size_t duration, size_t maxSteps); + + 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; + size_t getMaxSteps() const; + + std::shared_ptr build(); + + const size_t id; + const uint16_t defaultPeriod; + const BulbId& bulbId; + const TransitionFn callback; + + private: + size_t duration; + size_t period; + size_t numPeriods; + size_t maxSteps; + + virtual std::shared_ptr _build() const = 0; + size_t numSetParams() const; + }; + + // If period goes lower than this, throttle other parameters up to adjust. + static const size_t MIN_PERIOD = 150; + static const size_t DEFAULT_DURATION = 10000; + + 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 0000000..478efdf --- /dev/null +++ b/lib/Transitions/TransitionController.cpp @@ -0,0 +1,142 @@ +#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) + , defaultPeriod(500) +{ } + +void TransitionController::setDefaultPeriod(uint16_t defaultPeriod) { + this->defaultPeriod = defaultPeriod; +} + +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++, + defaultPeriod, + 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++, + defaultPeriod, + 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 0000000..3ddfb8a --- /dev/null +++ b/lib/Transitions/TransitionController.h @@ -0,0 +1,39 @@ +#include +#include +#include +#include +#include +#include + +#pragma once + +class TransitionController { +public: + TransitionController(); + + void clearListeners(); + void addListener(Transition::TransitionFn fn); + void setDefaultPeriod(uint16_t period); + + 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; + uint16_t defaultPeriod; + + 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 0000000..037078d --- /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 0000000..c96a579 --- /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 new file mode 100644 index 0000000..33434b6 --- /dev/null +++ b/lib/Types/GroupStateField.cpp @@ -0,0 +1,52 @@ +#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])) { + return static_cast(i); + } + } + return GroupStateField::UNKNOWN; +} + +const char* GroupStateFieldHelpers::getFieldName(GroupStateField field) { + for (size_t i = 0; i < size(STATE_NAMES); i++) { + if (field == static_cast(i)) { + return STATE_NAMES[i]; + } + } + 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 new file mode 100644 index 0000000..e58dc2d --- /dev/null +++ b/lib/Types/GroupStateField.h @@ -0,0 +1,58 @@ +#ifndef _GROUP_STATE_FIELDS_H +#define _GROUP_STATE_FIELDS_H + +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 { + UNKNOWN, + 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 +}; + +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 0000000..7542391 --- /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/MiLightRemoteType.cpp b/lib/Types/MiLightRemoteType.cpp new file mode 100644 index 0000000..2e08f7c --- /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 0000000..1b051dd --- /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 0000000..88cf23d --- /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 0000000..d75bc6d --- /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 0000000..f65f5b1 --- /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 0000000..f53c4ee --- /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 0000000..c15a693 --- /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 0000000..60cf295 --- /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 0000000..0beea27 --- /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 0000000..905698a --- /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 new file mode 100644 index 0000000..3e501b8 --- /dev/null +++ b/lib/Udp/MiLightDiscoveryServer.cpp @@ -0,0 +1,90 @@ +#include +#include +#include + +const char V3_SEARCH_STRING[] = "Link_Wi-Fi"; +const char V6_SEARCH_STRING[] = "HF-A11ASSISTHREAD"; + +MiLightDiscoveryServer::MiLightDiscoveryServer(Settings& settings) + : settings(settings) +{ } + +MiLightDiscoveryServer::MiLightDiscoveryServer(MiLightDiscoveryServer& other) + : settings(other.settings) +{ } + +MiLightDiscoveryServer& MiLightDiscoveryServer::operator=(MiLightDiscoveryServer other) { + this->settings = other.settings; + this->socket = other.socket; + return *this; +} + +MiLightDiscoveryServer::~MiLightDiscoveryServer() { + socket.stop(); +} + +void MiLightDiscoveryServer::begin() { + socket.begin(settings.discoveryPort); +} + +void MiLightDiscoveryServer::handleClient() { + size_t packetSize = socket.parsePacket(); + + if (packetSize) { + char buffer[size(V6_SEARCH_STRING) + 1]; + socket.read(buffer, packetSize); + buffer[packetSize] = 0; + +#ifdef MILIGHT_UDP_DEBUG + printf("Got discovery packet: %s\n", buffer); +#endif + + if (strcmp(buffer, V3_SEARCH_STRING) == 0) { + handleDiscovery(5); + } else if (strcmp(buffer, V6_SEARCH_STRING) == 0) { + handleDiscovery(6); + } + } +} + +void MiLightDiscoveryServer::handleDiscovery(uint8_t version) { +#ifdef MILIGHT_UDP_DEBUG + 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.gatewayConfigs.size(); i++) { + const GatewayConfig& config = *settings.gatewayConfigs[i]; + + if (config.protocolVersion != version) { + continue; + } + + IPAddress addr = WiFi.localIP(); + char* ptr = buffer; + ptr += sprintf_P( + buffer, + PSTR("%d.%d.%d.%d,00000000%02X%02X"), + addr[0], addr[1], addr[2], addr[3], + (config.deviceId >> 8), (config.deviceId & 0xFF) + ); + + if (config.protocolVersion == 5) { + sendResponse(buffer); + } else { + sprintf_P(ptr, PSTR(",HF-LPB100")); + sendResponse(buffer); + } + } +} + +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/MiLightDiscoveryServer.h b/lib/Udp/MiLightDiscoveryServer.h new file mode 100644 index 0000000..6c421bd --- /dev/null +++ b/lib/Udp/MiLightDiscoveryServer.h @@ -0,0 +1,25 @@ +#include +#include + +#ifndef MILIGHT_DISCOVERY_SERVER_H +#define MILIGHT_DISCOVERY_SERVER_H + +class MiLightDiscoveryServer { +public: + MiLightDiscoveryServer(Settings& settings); + MiLightDiscoveryServer(MiLightDiscoveryServer&); + MiLightDiscoveryServer& operator=(MiLightDiscoveryServer other); + ~MiLightDiscoveryServer(); + + void begin(); + void handleClient(); + +private: + Settings& settings; + WiFiUDP socket; + + void handleDiscovery(uint8_t version); + void sendResponse(char* buffer); +}; + +#endif diff --git a/lib/Udp/MiLightUdpServer.cpp b/lib/Udp/MiLightUdpServer.cpp new file mode 100644 index 0000000..71fa931 --- /dev/null +++ b/lib/Udp/MiLightUdpServer.cpp @@ -0,0 +1,51 @@ +#include +#include +#include +#include + +MiLightUdpServer::MiLightUdpServer(MiLightClient*& client, uint16_t port, uint16_t deviceId) + : client(client), + port(port), + deviceId(deviceId), + lastGroup(0) +{ } + +MiLightUdpServer::~MiLightUdpServer() { + stop(); +} + +void MiLightUdpServer::begin() { + socket.begin(port); +} + +void MiLightUdpServer::stop() { + socket.stop(); +} + +void MiLightUdpServer::handleClient() { + const size_t packetSize = socket.parsePacket(); + + if (packetSize) { + socket.read(packetBuffer, packetSize); + +#ifdef MILIGHT_UDP_DEBUG + printf("[MiLightUdpServer port %d] - Handling packet: ", port); + for (size_t i = 0; i < packetSize; i++) { + printf("%02X ", packetBuffer[i]); + } + printf("\n"); +#endif + + handlePacket(packetBuffer, packetSize); + } +} + +std::shared_ptr MiLightUdpServer::fromVersion(uint8_t version, MiLightClient*& client, uint16_t port, uint16_t deviceId) { + if (version == 0 || version == 5) { + return std::make_shared(client, port, deviceId); + } else if (version == 6) { + return std::make_shared(client, port, deviceId); + } + + return NULL; +} diff --git a/lib/Udp/MiLightUdpServer.h b/lib/Udp/MiLightUdpServer.h new file mode 100644 index 0000000..4a9066f --- /dev/null +++ b/lib/Udp/MiLightUdpServer.h @@ -0,0 +1,42 @@ +#include +#include +#include + +#include + +// This protocol is documented here: +// http://www.limitlessled.com/dev/ + +#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 + +class MiLightUdpServer { +public: + MiLightUdpServer(MiLightClient*& client, uint16_t port, uint16_t deviceId); + virtual ~MiLightUdpServer(); + + void stop(); + void begin(); + void handleClient(); + + static std::shared_ptr fromVersion(uint8_t version, MiLightClient*&, uint16_t port, uint16_t deviceId); + +protected: + WiFiUDP socket; + MiLightClient*& client; + uint16_t port; + uint16_t deviceId; + 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; +}; + +#endif \ No newline at end of file diff --git a/lib/Udp/V5MiLightUdpServer.cpp b/lib/Udp/V5MiLightUdpServer.cpp new file mode 100644 index 0000000..f5f8b5b --- /dev/null +++ b/lib/Udp/V5MiLightUdpServer.cpp @@ -0,0 +1,134 @@ +#include +#include + +void V5MiLightUdpServer::handlePacket(uint8_t* packet, size_t packetSize) { + if (packetSize == 2 || packetSize == 3) { + handleCommand(packet[0], packet[1]); + } else { + Serial.print(F("V5MilightUdpServer: unexpected packet length. Should always be 2-3, was: ")); + Serial.println(packetSize); + } +} + +void V5MiLightUdpServer::handleCommand(uint8_t command, uint8_t commandArg) { + // On/off for RGBW + if (command >= UDP_RGBW_GROUP_1_ON && command <= UDP_RGBW_GROUP_4_OFF) { + const MiLightStatus status = (command % 2) == 1 ? ON : OFF; + const uint8_t groupId = (command - UDP_RGBW_GROUP_1_ON + 2)/2; + + client->prepare(&FUT096Config, deviceId, groupId); + client->updateStatus(status); + + this->lastGroup = groupId; + // Command set_white for RGBW + } else if (command == UDP_RGBW_GROUP_ALL_WHITE || command == UDP_RGBW_GROUP_1_WHITE || command == UDP_RGBW_GROUP_2_WHITE || command == UDP_RGBW_GROUP_3_WHITE || command == UDP_RGBW_GROUP_4_WHITE) { + const uint8_t groupId = (command - UDP_RGBW_GROUP_ALL_WHITE)/2; + client->prepare(&FUT096Config, deviceId, groupId); + client->updateColorWhite(); + + 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) { + 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(); + + this->lastGroup = groupId; + } else { + client->prepare(&FUT096Config, deviceId, lastGroup); + bool handled = true; + + switch (command) { + case UDP_RGBW_ALL_ON: + client->updateStatus(ON, 0); + break; + + case UDP_RGBW_ALL_OFF: + client->updateStatus(OFF, 0); + break; + + case UDP_RGBW_COLOR: + // UDP color is shifted by 0xC8 from 2.4 GHz color, and the spectrum is + // flipped (R->B->G instead of R->G->B) + client->updateColorRaw(0xFF-(commandArg + 0x35)); + break; + + case UDP_RGBW_DISCO_MODE: + client->nextMode(); + break; + + case UDP_RGBW_SPEED_DOWN: + pressButton(RGBW_SPEED_DOWN); + break; + + case UDP_RGBW_SPEED_UP: + pressButton(RGBW_SPEED_UP); + break; + + case UDP_RGBW_BRIGHTNESS: + // map [2, 27] --> [0, 100] + client->updateBrightness( + round(((commandArg - 2) / 25.0)*100) + ); + break; + + default: + handled = false; + } + + if (handled) { + return; + } + + uint8_t onOffGroup = CctPacketFormatter::cctCommandIdToGroup(command); + + if (onOffGroup != 255) { + client->prepare(&FUT007Config, deviceId, onOffGroup); + // Night mode commands are same as off commands with MSB set + if ((command & 0x80) == 0x80) { + client->enableNightMode(); + } else { + client->updateStatus(CctPacketFormatter::cctCommandToStatus(command)); + } + return; + } + + client->prepare(&FUT007Config, deviceId, lastGroup); + + switch(command) { + case UDP_CCT_BRIGHTNESS_DOWN: + client->decreaseBrightness(); + break; + + case UDP_CCT_BRIGHTNESS_UP: + client->increaseBrightness(); + break; + + case UDP_CCT_TEMPERATURE_DOWN: + client->decreaseTemperature(); + break; + + case UDP_CCT_TEMPERATURE_UP: + client->increaseTemperature(); + break; + + case UDP_CCT_NIGHT_MODE: + client->enableNightMode(); + break; + + default: + if (!handled) { + Serial.print(F("V5MiLightUdpServer - Unhandled command: ")); + Serial.println(command); + } + } + } +} + +void V5MiLightUdpServer::pressButton(uint8_t button) { + client->command(button, 0); +} diff --git a/lib/Udp/V5MiLightUdpServer.h b/lib/Udp/V5MiLightUdpServer.h new file mode 100644 index 0000000..3f064c2 --- /dev/null +++ b/lib/Udp/V5MiLightUdpServer.h @@ -0,0 +1,70 @@ +// This protocol is documented here: +// http://www.limitlessled.com/dev/ + +#include +#include +#include +#include + +#ifndef _V5_MILIGHT_UDP_SERVER +#define _V5_MILIGHT_UDP_SERVER + +enum MiLightUdpCommands { + UDP_CCT_ALL_ON = 0x35, + UDP_CCT_ALL_OFF = 0x39, + UDP_CCT_GROUP_1_ON = 0x38, + UDP_CCT_GROUP_1_OFF = 0x3B, + UDP_CCT_GROUP_2_ON = 0x3D, + UDP_CCT_GROUP_2_OFF = 0x33, + UDP_CCT_GROUP_3_ON = 0x37, + UDP_CCT_GROUP_3_OFF = 0x3A, + UDP_CCT_GROUP_4_ON = 0x32, + UDP_CCT_GROUP_4_OFF = 0x36, + UDP_CCT_TEMPERATURE_DOWN = 0x3F, + UDP_CCT_TEMPERATURE_UP = 0x3E, + UDP_CCT_BRIGHTNESS_DOWN = 0x34, + UDP_CCT_BRIGHTNESS_UP = 0x3C, + UDP_CCT_NIGHT_MODE = 0xB9, + + UDP_RGBW_ALL_OFF = 0x41, + UDP_RGBW_ALL_ON = 0x42, + UDP_RGBW_SPEED_UP = 0x43, + UDP_RGBW_SPEED_DOWN = 0x44, + UDP_RGBW_GROUP_1_ON = 0x45, + UDP_RGBW_GROUP_1_OFF = 0x46, + UDP_RGBW_GROUP_2_ON = 0x47, + UDP_RGBW_GROUP_2_OFF = 0x48, + UDP_RGBW_GROUP_3_ON = 0x49, + UDP_RGBW_GROUP_3_OFF = 0x4A, + UDP_RGBW_GROUP_4_ON = 0x4B, + UDP_RGBW_GROUP_4_OFF = 0x4C, + UDP_RGBW_DISCO_MODE = 0x4D, + UDP_RGBW_GROUP_ALL_WHITE = 0xC2, + UDP_RGBW_GROUP_1_WHITE = 0xC5, + UDP_RGBW_GROUP_2_WHITE = 0xC7, + UDP_RGBW_GROUP_3_WHITE = 0xC9, + UDP_RGBW_GROUP_4_WHITE = 0xCB, + UDP_RGBW_GROUP_ALL_NIGHT = 0xC1, + UDP_RGBW_GROUP_1_NIGHT = 0xC6, + UDP_RGBW_GROUP_2_NIGHT = 0xC8, + UDP_RGBW_GROUP_3_NIGHT = 0xCA, + UDP_RGBW_GROUP_4_NIGHT = 0xCC, + UDP_RGBW_BRIGHTNESS = 0x4E, + UDP_RGBW_COLOR = 0x40 +}; + +class V5MiLightUdpServer : public MiLightUdpServer { +public: + V5MiLightUdpServer(MiLightClient*& client, uint16_t port, uint16_t deviceId) + : MiLightUdpServer(client, port, deviceId) + { } + + // Should return size of the response packet + virtual void handlePacket(uint8_t* packet, size_t packetSize); + +protected: + void handleCommand(uint8_t command, uint8_t commandArg); + void pressButton(uint8_t button); +}; + +#endif diff --git a/lib/Udp/V6CctCommandHandler.cpp b/lib/Udp/V6CctCommandHandler.cpp new file mode 100644 index 0000000..a5cc1f2 --- /dev/null +++ b/lib/Udp/V6CctCommandHandler.cpp @@ -0,0 +1,59 @@ +#include + +bool V6CctCommandHandler::handlePreset( + MiLightClient* client, + uint8_t commandLsb, + uint32_t commandArg) +{ + return false; +} + +bool V6CctCommandHandler::handleCommand( + MiLightClient* client, + uint32_t command, + uint32_t commandArg) +{ + const uint8_t cmd = command & 0x7F; + const uint8_t arg = commandArg >> 24; + + client->setHeld((command & 0x80) == 0x80); + + if (cmd == V2_CCT_COMMAND_PREFIX) { + switch (arg) { + case V2_CCT_ON: + client->updateStatus(ON); + break; + + case V2_CCT_OFF: + client->updateStatus(OFF); + break; + + case V2_CCT_BRIGHTNESS_DOWN: + client->decreaseBrightness(); + break; + + case V2_CCT_BRIGHTNESS_UP: + client->increaseBrightness(); + break; + + case V2_CCT_TEMPERATURE_DOWN: + client->decreaseTemperature(); + break; + + case V2_CCT_TEMPERATURE_UP: + client->increaseTemperature(); + break; + + case V2_CCT_NIGHT_LIGHT: + client->enableNightMode(); + break; + + default: + return false; + } + + return true; + } + + return false; +} diff --git a/lib/Udp/V6CctCommandHandler.h b/lib/Udp/V6CctCommandHandler.h new file mode 100644 index 0000000..2f2e742 --- /dev/null +++ b/lib/Udp/V6CctCommandHandler.h @@ -0,0 +1,38 @@ +#include + +#ifndef _V6_CCT_COMMAND_HANDLER_H +#define _V6_CCT_COMMAND_HANDLER_H + +enum CctCommandIds { + V2_CCT_COMMAND_PREFIX = 0x01, + + V2_CCT_BRIGHTNESS_UP = 0x01, + V2_CCT_BRIGHTNESS_DOWN = 0x02, + V2_CCT_TEMPERATURE_UP = 0x03, + V2_CCT_TEMPERATURE_DOWN = 0x04, + V2_CCT_NIGHT_LIGHT = 0x06, + V2_CCT_ON = 0x07, + V2_CCT_OFF = 0x08 +}; + +class V6CctCommandHandler : public V6CommandHandler { +public: + V6CctCommandHandler() + : V6CommandHandler(0x0100, FUT007Config) + { } + + virtual bool handleCommand( + MiLightClient* client, + uint32_t command, + uint32_t commandArg + ); + + virtual bool handlePreset( + MiLightClient* client, + uint8_t commandLsb, + uint32_t commandArg + ); + +}; + +#endif diff --git a/lib/Udp/V6ComamndHandler.cpp b/lib/Udp/V6ComamndHandler.cpp new file mode 100644 index 0000000..97ed51d --- /dev/null +++ b/lib/Udp/V6ComamndHandler.cpp @@ -0,0 +1,70 @@ +#include +#include +#include +#include +#include +#include + +V6CommandHandler* V6CommandHandler::ALL_HANDLERS[] = { + new V6RgbCctCommandHandler(), + new V6RgbwCommandHandler(), + new V6RgbCommandHandler(), + new V6CctCommandHandler() +}; + +const size_t V6CommandHandler::NUM_HANDLERS = size(ALL_HANDLERS); + +bool V6CommandHandler::handleCommand(MiLightClient* client, + uint16_t deviceId, + uint8_t group, + uint8_t commandType, + uint32_t command, + uint32_t commandArg) +{ + client->prepare(&remoteConfig, deviceId, group); + + if (commandType == V6_PAIR) { + client->pair(); + } else if (commandType == V6_UNPAIR) { + client->unpair(); + } else if (commandType == V6_PRESET) { + return this->handlePreset(client, command, commandArg); + } else if (commandType == V6_COMMAND) { + return this->handleCommand(client, command, commandArg); + } else { + return false; + } + + return true; +} + +bool V6CommandDemuxer::handleCommand(MiLightClient* client, + uint16_t deviceId, + uint8_t group, + uint8_t commandType, + uint32_t command, + uint32_t commandArg) +{ + for (size_t i = 0; i < numHandlers; i++) { + if (((handlers[i]->commandId & command) == handlers[i]->commandId) + && handlers[i]->handleCommand(client, deviceId, group, commandType, command, commandArg)) { + return true; + } + } + + return false; +} + +bool V6CommandDemuxer::handleCommand(MiLightClient* client, + uint32_t commandLsb, + uint32_t commandArg) +{ + return false; +} + +bool V6CommandDemuxer::handlePreset(MiLightClient* client, + uint8_t commandLsb, + uint32_t commandArg) +{ + return false; +} diff --git a/lib/Udp/V6CommandHandler.h b/lib/Udp/V6CommandHandler.h new file mode 100644 index 0000000..b9ca222 --- /dev/null +++ b/lib/Udp/V6CommandHandler.h @@ -0,0 +1,85 @@ +#include +#include + +#ifndef _V6_COMMAND_HANDLER_H +#define _V6_COMMAND_HANDLER_H + +enum V6CommandTypes { + V6_PAIR = 0x3D, + V6_UNPAIR = 0x3E, + V6_PRESET = 0x3F, + V6_COMMAND = 0x31 +}; + +class V6CommandHandler { +public: + static V6CommandHandler* ALL_HANDLERS[]; + static const size_t NUM_HANDLERS; + + V6CommandHandler(uint16_t commandId, const MiLightRemoteConfig& remoteConfig) + : commandId(commandId), + remoteConfig(remoteConfig) + { } + + virtual bool handleCommand( + MiLightClient* client, + uint16_t deviceId, + uint8_t group, + uint8_t commandType, + uint32_t command, + uint32_t commandArg + ); + + const uint16_t commandId; + const MiLightRemoteConfig& remoteConfig; + +protected: + + virtual bool handleCommand( + MiLightClient* client, + uint32_t command, + uint32_t commandArg + ) = 0; + + virtual bool handlePreset( + MiLightClient* client, + uint8_t commandLsb, + uint32_t commandArg + ) = 0; +}; + +class V6CommandDemuxer : public V6CommandHandler { +public: + V6CommandDemuxer(V6CommandHandler* handlers[], size_t numHandlers) + : V6CommandHandler(0, FUT096Config), + handlers(handlers), + numHandlers(numHandlers) + { } + + virtual bool handleCommand( + MiLightClient* client, + uint16_t deviceId, + uint8_t group, + uint8_t commandType, + uint32_t command, + uint32_t commandArg + ); + +protected: + V6CommandHandler** handlers; + size_t numHandlers; + + virtual bool handleCommand( + MiLightClient* client, + uint32_t command, + uint32_t commandArg + ); + + virtual bool handlePreset( + MiLightClient* client, + uint8_t commandLsb, + uint32_t commandArg + ); +}; + +#endif diff --git a/lib/Udp/V6MiLightUdpServer.cpp b/lib/Udp/V6MiLightUdpServer.cpp new file mode 100644 index 0000000..a1b93fe --- /dev/null +++ b/lib/Udp/V6MiLightUdpServer.cpp @@ -0,0 +1,290 @@ +#include +#include +#include +#include +#include + +#define MATCHES_PACKET(packet1) ( \ + matchesPacket(packet1, size(packet1), packet, packetSize) \ +) + +V6CommandDemuxer V6MiLightUdpServer::COMMAND_DEMUXER = V6CommandDemuxer( + V6CommandHandler::ALL_HANDLERS, + V6CommandHandler::NUM_HANDLERS +); + +uint8_t V6MiLightUdpServer::START_SESSION_COMMAND[] = { + 0x20, 0x00, 0x00, 0x00, 0x16, 0x02, 0x62, 0x3A, 0xD5, 0xED, 0xA3, 0x01, 0xAE, + 0x08, 0x2D, 0x46, 0x61, 0x41, 0xA7, 0xF6, 0xDC, 0xAF +}; + +uint8_t V6MiLightUdpServer::START_SESSION_RESPONSE[] = { + 0x28, 0x00, 0x00, 0x00, 0x11, 0x00, 0x02, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // should be replaced with hw addr + 0x69, 0xF0, 0x3C, 0x23, 0x00, 0x01, + 0xFF, 0xFF, // should be replaced with a session ID + 0x00 +}; + +uint8_t V6MiLightUdpServer::COMMAND_HEADER[] = { + 0x80, 0x00, 0x00, 0x00 +}; + +uint8_t V6MiLightUdpServer::HEARTBEAT_HEADER[] = { + 0xD0, 0x00, 0x00, 0x00, 0x02 +}; + +uint8_t V6MiLightUdpServer::HEARTBEAT_HEADER2[] = { + 0x30, 0x00, 0x00, 0x00, 0x03 +}; + +uint8_t V6MiLightUdpServer::COMMAND_RESPONSE[] = { + 0x88, 0x00, 0x00, 0x00, 0x03, 0x00, 0xFF, 0x00 +}; + +uint8_t V6MiLightUdpServer::SEARCH_COMMAND[] = { + 0x10, 0x00, 0x00, 0x00 + //, 0x24, 0x02 + //, 0xAE, 0x65, 0x02, 0x39, 0x38, 0x35, 0x62 +}; + +uint8_t V6MiLightUdpServer::SEARCH_RESPONSE[] = { + 0x18, 0x00, 0x00, 0x00, 0x40, 0x02, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // mac address + 0x00, 0x20, 0x39, 0x38, 0x35, 0x62, + 0x31, 0x35, 0x37, 0x62, 0x66, 0x36, + 0x66, 0x63, 0x34, 0x33, 0x33, 0x36, + 0x38, 0x61, 0x36, 0x33, 0x34, 0x36, + 0x37, 0x65, 0x61, 0x33, 0x62, 0x31, + 0x39, 0x64, 0x30, 0x64, 0x01, 0x00, + 0x01, + 0x17, 0x63, // this is 5987 in hex. specifying a different value seems to + // cause client to connect on a different port for some commands + 0x00, 0x00, 0x05, 0x00, 0x09, 0x78, + 0x6C, 0x69, 0x6E, 0x6B, 0x5F, 0x64, + 0x65, 0x76, 0x07, 0x5B, 0xCD, 0x15 +}; + +uint8_t V6MiLightUdpServer::OPEN_COMMAND_RESPONSE[] = { + 0x80, 0x00, 0x00, 0x00, 0x15, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // mac address + 0x05, 0x02, 0x00, 0x34, 0x00, 0x00, + 0x00, 0x00 ,0x00 ,0x00, 0x00, 0x00, + 0x00, 0x00, 0x34 +}; + +V6MiLightUdpServer::~V6MiLightUdpServer() { + V6Session* cur = firstSession; + + while (cur != NULL) { + V6Session* next = cur->next; + delete cur; + cur = next; + } +} + +template +T V6MiLightUdpServer::readInt(uint8_t* packet) { + size_t numBytes = sizeof(T); + T value = 0; + + for (size_t i = 0; i < numBytes; i++) { + value |= packet[i] << (8 * (numBytes - i - 1)); + } + + return value; +} + +template +uint8_t* V6MiLightUdpServer::writeInt(const T& value, uint8_t* packet) { + size_t numBytes = sizeof(T); + + for (size_t i = 0; i < numBytes; i++) { + packet[i] = (value >> (8 * (numBytes - i - 1))) & 0xFF; + } + + return packet + numBytes; +} + +uint16_t V6MiLightUdpServer::beginSession() { + const uint16_t id = sessionId++; + + V6Session* session = new V6Session(socket.remoteIP(), socket.remotePort(), id); + session->next = firstSession; + firstSession = session; + + if (numSessions >= V6_MAX_SESSIONS) { + V6Session* cur = firstSession; + + for (size_t i = 1; i < V6_MAX_SESSIONS; i++) { + cur = cur->next; + } + + delete cur->next; + cur->next = NULL; + } else { + numSessions++; + } + + return id; +} + +void V6MiLightUdpServer::handleSearch() { + const size_t packetLen = size(SEARCH_RESPONSE); + uint8_t response[packetLen]; + memcpy(response, SEARCH_RESPONSE, packetLen); + writeMacAddr(response + 6); + + socket.beginPacket(socket.remoteIP(), socket.remotePort()); + socket.write(response, packetLen); + socket.endPacket(); +} + +void V6MiLightUdpServer::handleStartSession() { + size_t len = size(START_SESSION_RESPONSE); + uint8_t response[len]; + uint16_t sessionId = beginSession(); + + memcpy(response, START_SESSION_RESPONSE, len); + writeMacAddr(response + 7); + + response[19] = sessionId >> 8; + response[20] = sessionId & 0xFF; + + sendResponse(sessionId, response, len); +} + +bool V6MiLightUdpServer::sendResponse(uint16_t sessionId, uint8_t* responseBuffer, size_t responseSize) { + V6Session* session = firstSession; + + while (session != NULL) { + if (session->sessionId == sessionId) { + break; + } + session = session->next; + } + + if (session == NULL || session->sessionId != sessionId) { + Serial.print("Received request with untracked session ID: "); + Serial.println(sessionId); + return false; + } + +#ifdef MILIGHT_UDP_DEBUG + printf_P("Sending response to %s:%d\n", session->ipAddr.toString().c_str(), session->port); +#endif + + socket.beginPacket(session->ipAddr, session->port); + socket.write(responseBuffer, responseSize); + socket.endPacket(); + + return true; +} + +bool V6MiLightUdpServer::handleOpenCommand(uint16_t sessionId) { + size_t len = size(OPEN_COMMAND_RESPONSE); + uint8_t response[len]; + memcpy(response, OPEN_COMMAND_RESPONSE, len); + writeMacAddr(response + 5); + + return sendResponse(sessionId, response, len); +} + +void V6MiLightUdpServer::handleCommand( + uint16_t sessionId, + uint8_t sequenceNum, + uint8_t* cmd, + uint8_t group, + uint8_t checksum +) { + + uint8_t cmdType = readInt(cmd); + uint32_t cmdHeader = readInt(cmd+1); + uint32_t cmdArg = readInt(cmd+5); + +#ifdef MILIGHT_UDP_DEBUG + printf("Command cmdType: %02X, cmdHeader: %08X, cmdArg: %08X\n", cmdType, cmdHeader, cmdArg); +#endif + + bool handled = false; + + if (cmdHeader == 0) { + handled = handleOpenCommand(sessionId); + } else { + handled = COMMAND_DEMUXER.handleCommand( + client, + deviceId, + group, + cmdType, + cmdHeader, + cmdArg + ); + } + + if (handled) { + size_t len = size(COMMAND_RESPONSE); + memcpy(responseBuffer, COMMAND_RESPONSE, len); + responseBuffer[6] = sequenceNum; + + sendResponse(sessionId, responseBuffer, len); + + return; + } + +#ifdef MILIGHT_UDP_DEBUG + printf("V6MiLightUdpServer - Unhandled command: "); + for (size_t i = 0; i < V6_COMMAND_LEN; i++) { + printf("%02X ", cmd[i]); + } + printf("\n"); +#endif +} + +void V6MiLightUdpServer::handleHeartbeat(uint16_t sessionId) { + char header[] = { 0xD8, 0x00, 0x00, 0x00, 0x07 }; + memcpy(responseBuffer, header, size(header)); + writeMacAddr(responseBuffer + 5); + + responseBuffer[11] = 0; + + sendResponse(sessionId, responseBuffer, 12); +} + +bool V6MiLightUdpServer::matchesPacket(uint8_t* packet1, size_t packet1Len, uint8_t* packet2, size_t packet2Len) { + return packet2Len >= packet1Len && memcmp(packet1, packet2, packet1Len) == 0; +} + +void V6MiLightUdpServer::handlePacket(uint8_t* packet, size_t packetSize) { +#ifdef MILIGHT_UDP_DEBUG + printf_P("Packet size: %d\n", packetSize); +#endif + + if (MATCHES_PACKET(START_SESSION_COMMAND)) { + handleStartSession(); + } else if (MATCHES_PACKET(HEARTBEAT_HEADER) || MATCHES_PACKET(HEARTBEAT_HEADER2)) { + uint16_t sessionId = readInt(packet+5); + handleHeartbeat(sessionId); + } else if (MATCHES_PACKET(SEARCH_COMMAND)) { + handleSearch(); + } else if (packetSize == 22 && MATCHES_PACKET(COMMAND_HEADER)) { + uint16_t sessionId = readInt(packet+5); + uint8_t sequenceNum = packet[8]; + uint8_t* cmd = packet+10; + uint8_t group = packet[19]; + uint8_t checksum = packet[21]; + +#ifdef MILIGHT_UDP_DEBUG + printf("session: %04X, sequence: %d, group: %d, checksum: %d\n", sessionId, sequenceNum, group, checksum); +#endif + + handleCommand(sessionId, sequenceNum, cmd, group, checksum); + } else { + Serial.println(F("Unhandled V6 packet")); + } +} + +void V6MiLightUdpServer::writeMacAddr(uint8_t* packet) { + memset(packet, 0, 6); + packet[4] = deviceId >> 8; + packet[5] = deviceId; +} diff --git a/lib/Udp/V6MiLightUdpServer.h b/lib/Udp/V6MiLightUdpServer.h new file mode 100644 index 0000000..cd250a0 --- /dev/null +++ b/lib/Udp/V6MiLightUdpServer.h @@ -0,0 +1,88 @@ +// This protocol is documented here: +// http://www.limitlessled.com/dev/ + +#include +#include +#include +#include +#include + +#define V6_COMMAND_LEN 8 +#define V6_MAX_SESSIONS 10 + +#ifndef _V6_MILIGHT_UDP_SERVER +#define _V6_MILIGHT_UDP_SERVER + +struct V6Session { + V6Session(IPAddress ipAddr, uint16_t port, uint16_t sessionId) + : ipAddr(ipAddr), + port(port), + sessionId(sessionId), + next(NULL) + { } + + IPAddress ipAddr; + uint16_t port; + uint16_t sessionId; + V6Session* next; +}; + +class V6MiLightUdpServer : public MiLightUdpServer { +public: + V6MiLightUdpServer(MiLightClient*& client, uint16_t port, uint16_t deviceId) + : MiLightUdpServer(client, port, deviceId), + sessionId(0), + numSessions(0), + firstSession(NULL) + { } + + ~V6MiLightUdpServer(); + + // Should return size of the response packet + virtual void handlePacket(uint8_t* packet, size_t packetSize); + + template + static T readInt(uint8_t* packet); + + template + static uint8_t* writeInt(const T& value, uint8_t* packet); + +protected: + static V6CommandDemuxer COMMAND_DEMUXER; + + static uint8_t START_SESSION_COMMAND[]; + static uint8_t START_SESSION_RESPONSE[]; + static uint8_t COMMAND_HEADER[]; + static uint8_t COMMAND_RESPONSE[]; + static uint8_t LOCAL_SEARCH_COMMAND[]; + static uint8_t HEARTBEAT_HEADER[]; + static uint8_t HEARTBEAT_HEADER2[]; + + static uint8_t SEARCH_COMMAND[]; + static uint8_t SEARCH_RESPONSE[]; + + static uint8_t OPEN_COMMAND_RESPONSE[]; + + uint16_t sessionId; + size_t numSessions; + V6Session* firstSession; + + uint16_t beginSession(); + bool sendResponse(uint16_t sessionId, uint8_t* responseBuffer, size_t responseSize); + bool matchesPacket(uint8_t* packet1, size_t packet1Len, uint8_t* packet2, size_t packet2Len); + void writeMacAddr(uint8_t* packet); + + void handleSearch(); + void handleStartSession(); + bool handleOpenCommand(uint16_t sessionId); + void handleHeartbeat(uint16_t sessionId); + void handleCommand( + uint16_t sessionId, + uint8_t sequenceNum, + uint8_t* cmd, + uint8_t group, + uint8_t checksum + ); +}; + +#endif diff --git a/lib/Udp/V6RgbCctCommandHandler.cpp b/lib/Udp/V6RgbCctCommandHandler.cpp new file mode 100644 index 0000000..eb693a6 --- /dev/null +++ b/lib/Udp/V6RgbCctCommandHandler.cpp @@ -0,0 +1,103 @@ +#include + +bool V6RgbCctCommandHandler::handlePreset( + MiLightClient* client, + uint8_t commandLsb, + uint32_t commandArg) +{ + if (commandLsb == 0) { + const uint8_t saturation = commandArg >> 24; + const uint8_t color = (commandArg >> 16); + const uint8_t brightness = (commandArg >> 8); + + client->updateBrightness(brightness); + client->updateColorRaw(color); + client->updateSaturation(saturation); + } else if (commandLsb == 1) { + const uint8_t brightness = (commandArg >> 16); + const uint8_t kelvin = (commandArg >> 8); + + client->updateBrightness(brightness); + client->updateTemperature(0x64 - kelvin); + } else { + return false; + } + + return true; +} + +bool V6RgbCctCommandHandler::handleCommand( + MiLightClient* client, + uint32_t command, + uint32_t commandArg) +{ + const uint8_t cmd = command & 0x7F; + const uint8_t arg = commandArg >> 24; + + client->setHeld((command & 0x80) == 0x80); + + if (cmd == V2_STATUS) { + switch (arg) { + case V2_RGB_CCT_ON: + case V2_RGB_CCT_OFF: + client->updateStatus(arg == V2_RGB_CCT_ON ? ON : OFF); + break; + + case V2_RGB_NIGHT_MODE: + client->enableNightMode(); + break; + + case V2_RGB_CCT_SPEED_DOWN: + client->modeSpeedDown(); + break; + + case V2_RGB_CCT_SPEED_UP: + client->modeSpeedUp(); + break; + + default: + return false; + } + + return true; + } + + switch (cmd) { + case V2_COLOR: + handleUpdateColor(client, commandArg); + break; + + case V2_KELVIN: + client->updateTemperature(100 - arg); + break; + + case V2_BRIGHTNESS: + client->updateBrightness(arg); + break; + + case V2_SATURATION: + client->updateSaturation(100 - arg); + break; + + case V2_MODE: + client->updateMode(arg-1); + break; + + default: + return false; + } + + return true; +} + +/* + * Arguments are 32 bits. Most commands use the first byte, but color arguments + * can use all four. Triggered in app when quickly transitioning through colors. + */ +void V6RgbCctCommandHandler::handleUpdateColor(MiLightClient *client, uint32_t color) { + for (int i = 3; i >= 0; i--) { + const uint8_t argValue = (color >> (i*8)) & 0xFF; + + client->updateColorRaw(argValue + 0xF6); + } +} diff --git a/lib/Udp/V6RgbCctCommandHandler.h b/lib/Udp/V6RgbCctCommandHandler.h new file mode 100644 index 0000000..49c510a --- /dev/null +++ b/lib/Udp/V6RgbCctCommandHandler.h @@ -0,0 +1,45 @@ +#include + +#ifndef _V6_RGB_CCT_COMMAND_HANDLER_H +#define _V6_RGB_CCT_COMMAND_HANDLER_H + +enum V2CommandIds { + V2_COLOR = 0x01, + V2_SATURATION = 0x02, + V2_BRIGHTNESS = 0x03, + V2_STATUS = 0x04, + V2_KELVIN = 0x05, + V2_MODE = 0x06 +}; + +enum V2CommandArgIds { + V2_RGB_CCT_ON = 0x01, + V2_RGB_CCT_OFF = 0x02, + V2_RGB_CCT_SPEED_UP = 0x03, + V2_RGB_CCT_SPEED_DOWN = 0x04, + V2_RGB_NIGHT_MODE = 0x05 +}; + +class V6RgbCctCommandHandler : public V6CommandHandler { +public: + V6RgbCctCommandHandler() + : V6CommandHandler(0x0800, FUT092Config) + { } + + virtual bool handleCommand( + MiLightClient* client, + uint32_t command, + uint32_t commandArg + ); + + virtual bool handlePreset( + MiLightClient* client, + uint8_t commandLsb, + uint32_t commandArg + ); + + void handleUpdateColor(MiLightClient* client, uint32_t color); + +}; + +#endif diff --git a/lib/Udp/V6RgbCommandHandler.cpp b/lib/Udp/V6RgbCommandHandler.cpp new file mode 100644 index 0000000..2c10f62 --- /dev/null +++ b/lib/Udp/V6RgbCommandHandler.cpp @@ -0,0 +1,64 @@ +#include + +bool V6RgbCommandHandler::handlePreset( + MiLightClient* client, + uint8_t commandLsb, + uint32_t commandArg) +{ return true; } + +bool V6RgbCommandHandler::handleCommand( + MiLightClient* client, + uint32_t command, + uint32_t commandArg) +{ + const uint8_t cmd = command & 0x7F; + const uint8_t arg = commandArg >> 24; + + client->setHeld((command & 0x80) == 0x80); + + if (cmd == V2_RGB_COMMAND_PREFIX) { + switch (arg) { + case V2_RGB_ON: + client->updateStatus(ON); + break; + + case V2_RGB_OFF: + client->updateStatus(OFF); + break; + + case V2_RGB_BRIGHTNESS_DOWN: + client->decreaseBrightness(); + break; + + case V2_RGB_BRIGHTNESS_UP: + client->increaseBrightness(); + break; + + case V2_RGB_MODE_DOWN: + client->previousMode(); + break; + + case V2_RGB_MODE_UP: + client->nextMode(); + break; + + case V2_RGB_SPEED_DOWN: + client->modeSpeedDown(); + break; + + case V2_RGB_SPEED_UP: + client->modeSpeedUp(); + break; + + default: + return false; + } + + return true; + } else if (cmd == V2_RGB_COLOR_PREFIX) { + client->updateColorRaw(arg); + return true; + } + + return false; +} diff --git a/lib/Udp/V6RgbCommandHandler.h b/lib/Udp/V6RgbCommandHandler.h new file mode 100644 index 0000000..b0b6a2a --- /dev/null +++ b/lib/Udp/V6RgbCommandHandler.h @@ -0,0 +1,39 @@ +#include + +#ifndef _V6_RGB_COMMAND_HANDLER_H +#define _V6_RGB_COMMAND_HANDLER_H + +enum RgbCommandIds { + V2_RGB_COMMAND_PREFIX = 0x02, + V2_RGB_COLOR_PREFIX = 0x01, + V2_RGB_BRIGHTNESS_DOWN = 0x01, + V2_RGB_BRIGHTNESS_UP = 0x02, + V2_RGB_SPEED_DOWN = 0x03, + V2_RGB_SPEED_UP = 0x04, + V2_RGB_MODE_DOWN = 0x05, + V2_RGB_MODE_UP = 0x06, + V2_RGB_ON = 0x09, + V2_RGB_OFF = 0x0A +}; + +class V6RgbCommandHandler : public V6CommandHandler { +public: + V6RgbCommandHandler() + : V6CommandHandler(0x0500, FUT098Config) + { } + + virtual bool handleCommand( + MiLightClient* client, + uint32_t command, + uint32_t commandArg + ); + + virtual bool handlePreset( + MiLightClient* client, + uint8_t commandLsb, + uint32_t commandArg + ); + +}; + +#endif diff --git a/lib/Udp/V6RgbwCommandHandler.cpp b/lib/Udp/V6RgbwCommandHandler.cpp new file mode 100644 index 0000000..9448ccc --- /dev/null +++ b/lib/Udp/V6RgbwCommandHandler.cpp @@ -0,0 +1,74 @@ +#include + +bool V6RgbwCommandHandler::handlePreset( + MiLightClient* client, + uint8_t commandLsb, + uint32_t commandArg) +{ + if (commandLsb == 0) { + client->updateColorRaw(commandArg >> 24); + client->updateBrightness(commandArg >> 16); + } else if (commandLsb == 1) { + client->updateColorWhite(); + client->updateBrightness(commandArg >> 16); + } else { + return false; + } + + return true; +} + +bool V6RgbwCommandHandler::handleCommand( + MiLightClient* client, + uint32_t command, + uint32_t commandArg) +{ + const uint8_t cmd = command & 0x7F; + const uint8_t arg = commandArg >> 24; + + client->setHeld((command & 0x80) == 0x80); + + if (cmd == V2_RGBW_COMMAND_PREFIX) { + switch (arg) { + case V2_RGBW_ON: + client->updateStatus(ON); + break; + + case V2_RGBW_OFF: + client->updateStatus(OFF); + break; + + case V2_RGBW_WHITE_ON: + client->updateColorWhite(); + break; + + case V2_RGBW_NIGHT_LIGHT: + client->enableNightMode(); + break; + + case V2_RGBW_SPEED_DOWN: + client->modeSpeedDown(); + break; + + case V2_RGBW_SPEED_UP: + client->modeSpeedUp(); + break; + + default: + return false; + } + + return true; + } else if (cmd == V2_RGBW_COLOR_PREFIX) { + client->updateColorRaw(arg); + return true; + } else if (cmd == V2_RGBW_BRIGHTNESS_PREFIX) { + client->updateBrightness(arg); + return true; + } else if (cmd == V2_RGBW_MODE_PREFIX) { + client->updateMode(arg); + return true; + } + + return false; +} diff --git a/lib/Udp/V6RgbwCommandHandler.h b/lib/Udp/V6RgbwCommandHandler.h new file mode 100644 index 0000000..720f725 --- /dev/null +++ b/lib/Udp/V6RgbwCommandHandler.h @@ -0,0 +1,40 @@ +#include + +#ifndef _V6_RGBW_COMMAND_HANDLER_H +#define _V6_RGBW_COMMAND_HANDLER_H + +enum RgbwCommandIds { + V2_RGBW_COLOR_PREFIX = 0x01, + V2_RGBW_BRIGHTNESS_PREFIX = 0x02, + V2_RGBW_COMMAND_PREFIX = 0x03, + V2_RGBW_MODE_PREFIX = 0x04, + + V2_RGBW_ON = 0x01, + V2_RGBW_OFF = 0x02, + V2_RGBW_SPEED_DOWN = 0x03, + V2_RGBW_SPEED_UP = 0x04, + V2_RGBW_WHITE_ON = 0x05, + V2_RGBW_NIGHT_LIGHT = 0x06 +}; + +class V6RgbwCommandHandler : public V6CommandHandler { +public: + V6RgbwCommandHandler() + : V6CommandHandler(0x0700, FUT096Config) + { } + + virtual bool handleCommand( + MiLightClient* client, + uint32_t command, + uint32_t commandArg + ); + + virtual bool handlePreset( + MiLightClient* client, + uint8_t commandLsb, + uint32_t commandArg + ); + +}; + +#endif diff --git a/lib/WebServer/MiLightHttpServer.cpp b/lib/WebServer/MiLightHttpServer.cpp new file mode 100644 index 0000000..6c84d32 --- /dev/null +++ b/lib/WebServer/MiLightHttpServer.cpp @@ -0,0 +1,678 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace std::placeholders; + +void MiLightHttpServer::begin() { + // set up HTTP end points to serve + + 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( + [this](uint8_t num, WStype_t type, uint8_t * payload, size_t length) { + handleWsEvent(num, type, payload, length); + } + ); + wsServer.begin(); + + server.begin(); +} + +void MiLightHttpServer::handleClient() { + server.handleClient(); + wsServer.loop(); +} + +WiFiClient MiLightHttpServer::client() { + return server.client(); +} + +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 (requestBody.containsKey(GroupStateFieldNames::COMMAND)) { + if (requestBody[GroupStateFieldNames::COMMAND] == "restart") { + Serial.println(F("Restarting...")); + server.send_P(200, TEXT_PLAIN, PSTR("true")); + + delay(100); + + ESP.restart(); + + handled = true; + } else if (requestBody[GroupStateFieldNames::COMMAND] == "clear_wifi_config") { + Serial.println(F("Resetting Wifi and then Restarting...")); + server.send_P(200, TEXT_PLAIN, PSTR("true")); + + delay(100); + ESP.eraseConfig(); + delay(100); + ESP.restart(); + + handled = true; + } + } + + if (handled) { + request.response.json["success"] = true; + } else { + request.response.json["success"] = false; + request.response.json["error"] = "Unhandled command"; + request.response.setCode(400); + } +} + +void MiLightHttpServer::serveSettings() { + // Save first to set defaults + settings.save(); + serveFile(SETTINGS_FILE, APPLICATION_JSON); +} + +void MiLightHttpServer::onSettingsSaved(SettingsSavedHandler handler) { + this->settingsSavedHandler = handler; +} + +void MiLightHttpServer::onGroupDeleted(GroupDeletedHandler handler) { + this->groupDeletedHandler = handler; +} + +void MiLightHttpServer::handleAbout(RequestContext& request) { + AboutHelper::generateAboutObject(request.response.json); + + JsonObject queueStats = request.response.json.createNestedObject("queue_stats"); + queueStats[F("length")] = packetSender->queueLength(); + queueStats[F("dropped_packets")] = packetSender->droppedPackets(); +} + +void MiLightHttpServer::handleGetRadioConfigs(RequestContext& request) { + JsonArray arr = request.response.json.to(); + + for (size_t i = 0; i < MiLightRemoteConfig::NUM_REMOTES; i++) { + const MiLightRemoteConfig* config = MiLightRemoteConfig::ALL_REMOTES[i]; + arr.add(config->name); + } +} + +bool MiLightHttpServer::serveFile(const char* file, const char* contentType) { + if (SPIFFS.exists(file)) { + File f = SPIFFS.open(file, "r"); + server.streamFile(f, contentType); + f.close(); + return true; + } + + return false; +} + +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(); + } +} + +void MiLightHttpServer::handleUpdateSettings(RequestContext& request) { + JsonObject parsedSettings = request.getJsonBody().as(); + + if (! parsedSettings.isNull()) { + settings.patch(parsedSettings); + settings.save(); + + if (this->settingsSavedHandler) { + this->settingsSavedHandler(); + } + + request.response.json["success"] = true; + Serial.println(F("Settings successfully updated")); + } +} + +void MiLightHttpServer::handleUpdateSettingsPost(RequestContext& request) { + Settings::load(settings); + + if (this->settingsSavedHandler) { + this->settingsSavedHandler(); + } + + request.response.json["success"] = true; +} + +void MiLightHttpServer::handleFirmwarePost() { + server.sendHeader("Connection", "close"); + server.sendHeader("Access-Control-Allow-Origin", "*"); + + if (Update.hasError()) { + server.send_P( + 500, + TEXT_PLAIN, + PSTR("Failed updating firmware. Check serial logs for more information. You may need to re-flash the device.") + ); + } else { + server.send_P( + 200, + TEXT_PLAIN, + PSTR("Success. Device will now reboot.") + ); + } + + delay(1000); + + ESP.restart(); +} + +void MiLightHttpServer::handleFirmwareUpload() { + HTTPUpload& upload = server.upload(); + if(upload.status == UPLOAD_FILE_START){ + WiFiUDP::stopAll(); + uint32_t maxSketchSpace = (ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000; + if(!Update.begin(maxSketchSpace)){//start with max available size + Update.printError(Serial); + } + } else if(upload.status == UPLOAD_FILE_WRITE){ + if(Update.write(upload.buf, upload.currentSize) != upload.currentSize){ + Update.printError(Serial); + } + } else if(upload.status == UPLOAD_FILE_END){ + if(Update.end(true)){ //true to set the size to the current progress + } else { + Update.printError(Serial); + } + } + yield(); +} + + +void MiLightHttpServer::handleListenGateway(RequestContext& request) { + bool listenAll = !request.pathVariables.hasBinding("type"); + size_t configIx = 0; + std::shared_ptr radio = NULL; + const MiLightRemoteConfig* remoteConfig = NULL; + const MiLightRemoteConfig* tmpRemoteConfig = NULL; + + uint8_t packet[MILIGHT_MAX_PACKET_LENGTH]; + + if (!listenAll) { + String strType(request.pathVariables.get("type")); + tmpRemoteConfig = MiLightRemoteConfig::fromType(strType); + milightClient->prepare(tmpRemoteConfig, 0, 0); + } + + 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.client().connected()) { + return; + } + + if (listenAll) { + radio = radios->switchRadio(configIx++ % radios->getNumRadios()); + } else { + radio->configure(); + } + + if (radios->available()) { + size_t packetLen = radios->read(packet); + remoteConfig = MiLightRemoteConfig::fromReceivedPacket( + radio->config(), + packet, + packetLen + ); + } + + yield(); + } + + char responseBody[200]; + char* responseBuffer = responseBody; + + responseBuffer += sprintf_P( + responseBuffer, + PSTR("\n%s packet received (%d bytes):\n"), + remoteConfig->name.c_str(), + remoteConfig->packetFormatter->getPacketLength() + ); + remoteConfig->packetFormatter->format(packet, responseBuffer); + + request.response.json["packet_info"] = responseBody; +} + +void MiLightHttpServer::sendGroupState(bool allowAsync, BulbId& bulbId, RichHttp::Response& response) { + bool blockOnQueue = server.arg("blockOnQueue").equalsIgnoreCase("true"); + + // 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(); + } + + JsonObject obj = response.json.to(); + GroupState* state = stateStore->get(bulbId); + + if (blockOnQueue || allowAsync) { + if (state == nullptr) { + obj[F("error")] = F("not found"); + response.setCode(404); + } else { + state->applyState(obj, bulbId, settings.groupStateFields); + } + } else { + obj[F("success")] = true; + } +} + +void MiLightHttpServer::_handleGetGroup(bool allowAsync, BulbId bulbId, RequestContext& request) { + sendGroupState(allowAsync, bulbId, request.response); +} + +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(true, 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")); + request.response.setCode(400); + request.response.json["error"] = buffer; + return; + } + + BulbId bulbId(parseInt(_deviceId), _groupId, _remoteType->type); + _handleGetGroup(true, bulbId, request); +} + +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 (_remoteType == NULL) { + char buffer[40]; + sprintf_P(buffer, PSTR("Unknown device type\n")); + request.response.setCode(400); + request.response.json["error"] = buffer; + return; + } + + BulbId bulbId(parseInt(_deviceId), _groupId, _remoteType->type); + _handleDeleteGroup(bulbId, request); +} + +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(false, 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()]; + strcpy(remoteTypes, _remoteTypes.c_str()); + strcpy(groupIds, _groupIds.c_str()); + strcpy(deviceIds, _deviceIds.c_str()); + + TokenIterator deviceIdItr(deviceIds, _deviceIds.length()); + TokenIterator groupIdItr(groupIds, _groupIds.length()); + TokenIterator remoteTypesItr(remoteTypes, _remoteTypes.length()); + + BulbId foundBulbId; + size_t groupCount = 0; + + while (remoteTypesItr.hasNext()) { + const char* _remoteType = remoteTypesItr.nextToken(); + const MiLightRemoteConfig* config = MiLightRemoteConfig::fromType(_remoteType); + + if (config == NULL) { + char buffer[40]; + sprintf_P(buffer, PSTR("Unknown device type: %s"), _remoteType); + request.response.setCode(400); + request.response.json["error"] = buffer; + return; + } + + deviceIdItr.reset(); + while (deviceIdItr.hasNext()) { + const uint16_t deviceId = parseInt(deviceIdItr.nextToken()); + + groupIdItr.reset(); + while (groupIdItr.hasNext()) { + const uint8_t groupId = atoi(groupIdItr.nextToken()); + + milightClient->prepare(config, deviceId, groupId); + handleRequest(reqObj); + foundBulbId = BulbId(deviceId, groupId, config->type); + groupCount++; + } + } + } + + if (groupCount == 1) { + sendGroupState(false, foundBulbId, request.response); + } else { + 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(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"), request.pathVariables.get("type")); + request.response.setCode(400); + request.response.json["error"] = buffer; + return; + } + + uint8_t packet[MILIGHT_MAX_PACKET_LENGTH]; + const String& hexPacket = requestBody["packet"]; + hexStrToBytes(hexPacket.c_str(), hexPacket.length(), packet, MILIGHT_MAX_PACKET_LENGTH); + + size_t numRepeats = settings.packetRepeats; + if (requestBody.containsKey("num_repeats")) { + numRepeats = requestBody["num_repeats"]; + } + + packetSender->enqueue(packet, config, numRepeats); + + // To make this response synchronous, wait for packet to be flushed + while (packetSender->isSending()) { + packetSender->loop(); + } + + request.response.json["success"] = true; +} + +void MiLightHttpServer::handleWsEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) { + switch (type) { + case WStype_DISCONNECTED: + if (numWsClients > 0) { + numWsClients--; + } + break; + + case WStype_CONNECTED: + numWsClients++; + break; + + default: + Serial.printf_P(PSTR("Unhandled websocket event: %d\n"), static_cast(type)); + break; + } +} + +void MiLightHttpServer::handlePacketSent(uint8_t *packet, const MiLightRemoteConfig& config) { + if (numWsClients > 0) { + size_t packetLen = config.packetFormatter->getPacketLength(); + char buffer[packetLen*3]; + IntParsing::bytesToHexStr(packet, packetLen, buffer, packetLen*3); + + char formattedPacket[200]; + config.packetFormatter->format(packet, formattedPacket); + + char responseBuffer[300]; + sprintf_P( + responseBuffer, + PSTR("\n%s packet received (%d bytes):\n%s"), + config.name.c_str(), + packetLen, + formattedPacket + ); + wsServer.broadcastTXT(reinterpret_cast(responseBuffer)); + } +} + +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")) && !body.containsKey(GroupStateFieldNames::DEVICE_TYPE))) { + char buffer[200]; + sprintf_P(buffer, PSTR("Must specify required keys: device_id, group_id, device_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 = nullptr; + + if (body.containsKey(GroupStateFieldNames::DEVICE_TYPE)) { + _remoteType = MiLightRemoteConfig::fromType(body[GroupStateFieldNames::DEVICE_TYPE].as()); + } else if (body.containsKey(F("remote_type"))) { + _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 new file mode 100644 index 0000000..ff272aa --- /dev/null +++ b/lib/WebServer/MiLightHttpServer.h @@ -0,0 +1,111 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef _MILIGHT_HTTP_SERVER +#define _MILIGHT_HTTP_SERVER + +#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, + 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: + + bool serveFile(const char* file, const char* contentType = "text/html"); + void handleServe_P(const char* data, size_t length); + void sendGroupState(bool allowAsync, BulbId& bulbId, 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(RequestContext& request); + void handleSendRaw(RequestContext& request); + + void handleUpdateGroup(RequestContext& request); + void handleUpdateGroupAlias(RequestContext& request); + + void handleGetGroup(RequestContext& request); + void handleGetGroupAlias(RequestContext& request); + void _handleGetGroup(bool allowAsync, 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; + + PassthroughAuthProvider authProvider; + RichHttpServer server; + WebSocketsServer wsServer; + size_t numWsClients; + MiLightClient*& milightClient; + Settings& settings; + GroupStateStore*& stateStore; + SettingsSavedHandler settingsSavedHandler; + GroupDeletedHandler groupDeletedHandler; + ESP8266WebServer::THandlerFunction _handleRootPage; + PacketSender*& packetSender; + RadioSwitchboard*& radios; + TransitionController& transitions; + +}; + +#endif diff --git a/lib/readme.txt b/lib/readme.txt new file mode 100644 index 0000000..dbadc3d --- /dev/null +++ b/lib/readme.txt @@ -0,0 +1,36 @@ + +This directory is intended for the project specific (private) libraries. +PlatformIO will compile them to static libraries and link to executable file. + +The source code of each library should be placed in separate directory, like +"lib/private_lib/[here are source files]". + +For example, see how can be organized `Foo` and `Bar` libraries: + +|--lib +| |--Bar +| | |--docs +| | |--examples +| | |--src +| | |- Bar.c +| | |- Bar.h +| |--Foo +| | |- Foo.c +| | |- Foo.h +| |- readme.txt --> THIS FILE +|- platformio.ini +|--src + |- main.c + +Then in `src/main.c` you should use: + +#include +#include + +// rest H/C/CPP code + +PlatformIO will find your libraries automatically, configure preprocessor's +include paths and build them. + +More information about PlatformIO Library Dependency Finder +- http://docs.platformio.org/page/librarymanager/ldf.html diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..d70b6cc --- /dev/null +++ b/platformio.ini @@ -0,0 +1,36 @@ +; PlatformIO Project Configuration File +; +; Build options: build flags, source filter +; Upload options: custom upload port, speed and extra flags +; Library options: dependencies, extra library storages +; Advanced options: extra scripting +; +; Please visit documentation for the other options and examples +; https://docs.platformio.org/page/projectconf.html + +[common] +framework = arduino +platform = espressif32 +board = esp32-poe +lib_deps_external = + WiFiManager=https://github.com/sidoh/WiFiManager.git#cmidgley + RF24@~1.3.2 + ArduinoJson@~6.10.1 + PubSubClient@~2.7 + https://github.com/ratkins/RGBConverter.git#07010f2 + WebSockets@~2.2.0 + CircularBuffer@~1.2.0 + PathVariableHandlers@~2.0.0 + RichHttpServer@~2.0.2 +extra_scripts = + pre:.build_web.py +test_ignore = remote +build_flags = + !python3 .get_version.py + -D USING_AXTLS + -D MQTT_MAX_PACKET_SIZE=360 + -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 diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..1c76567 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,477 @@ +#ifndef UNIT_TEST + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#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; +RadioSwitchboard* radios = nullptr; +PacketSender* packetSender = nullptr; +std::shared_ptr radioFactory; +MiLightHttpServer *httpServer = NULL; +MqttClient* mqttClient = NULL; +MiLightDiscoveryServer* discoveryServer = NULL; +uint8_t currentRadioType = 0; + +// For tracking and managing group state +GroupStateStore* stateStore = NULL; +BulbStateUpdater* bulbStateUpdater = NULL; +TransitionController transitions; + +int numUdpServers = 0; +std::vector> udpServers; +WiFiUDP udpSeder; + +/** + * Set up UDP servers (both v5 and v6). Clean up old ones if necessary. + */ +void initMilightUdpServers() { + udpServers.clear(); + + for (size_t i = 0; i < settings.gatewayConfigs.size(); ++i) { + const GatewayConfig& config = *settings.gatewayConfigs[i]; + + std::shared_ptr server = MiLightUdpServer::fromVersion( + config.protocolVersion, + milightClient, + config.port, + config.deviceId + ); + + if (server == NULL) { + Serial.print(F("Error creating UDP server with protocol version: ")); + Serial.println(config.protocolVersion); + } else { + udpServers.push_back(std::move(server)); + udpServers[i]->begin(); + } + } +} + +/** + * Milight RF packet handler. + * + * Called both when a packet is sent locally, and when an intercepted packet + * is read. + */ +void onPacketSentHandler(uint8_t* packet, const MiLightRemoteConfig& config) { + StaticJsonDocument<200> buffer; + JsonObject result = buffer.to(); + + BulbId bulbId = config.packetFormatter->parsePacket(packet, result); + + // set LED mode for a packet movement + ledStatus->oneshot(settings.ledModePacket, settings.ledModePacketCount); + + if (&bulbId == &DEFAULT_BULB_ID) { + Serial.println(F("Skipping packet handler because packet was not decoded")); + return; + } + + const MiLightRemoteConfig& remoteConfig = + *MiLightRemoteConfig::fromType(bulbId.deviceType); + + // 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(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]; + serializeJson(result, output); + mqttClient->sendUpdate(remoteConfig, bulbId.deviceId, bulbId.groupId, output); + + // Sends the entire state + if (groupState != NULL) { + bulbStateUpdater->enqueueUpdate(bulbId, *groupState); + } + } + + httpServer->handlePacketSent(packet, remoteConfig); +} + +/** + * Listen for packets on one radio config. Cycles through all configs as its + * called. + */ +void handleListen() { + // 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; + } + + std::shared_ptr radio = radios->switchRadio(currentRadioType++ % radios->getNumRadios()); + + for (size_t i = 0; i < settings.listenRepeats; i++) { + if (radios->available()) { + uint8_t readPacket[MILIGHT_MAX_PACKET_LENGTH]; + size_t packetLen = radios->read(readPacket); + + const MiLightRemoteConfig* remoteConfig = MiLightRemoteConfig::fromReceivedPacket( + radio->config(), + readPacket, + packetLen + ); + + if (remoteConfig == NULL) { + // This can happen under normal circumstances, so not an error condition +#ifdef DEBUG_PRINTF + Serial.println(F("WARNING: Couldn't find remote for received packet")); +#endif + return; + } + + // update state to reflect this packet + onPacketSentHandler(readPacket, *remoteConfig); + } + } +} + +/** + * Called when MqttClient#update is first being processed. Stop sending updates + * and aggregate state changes until the update is finished. + */ +void onUpdateBegin() { + if (bulbStateUpdater) { + bulbStateUpdater->disable(); + } +} + +/** + * Called when MqttClient#update is finished processing. Re-enable state + * updates, which will flush accumulated state changes. + */ +void onUpdateEnd() { + if (bulbStateUpdater) { + bulbStateUpdater->enable(); + } +} + +/** + * Apply what's in the Settings object. + */ +void applySettings() { + if (milightClient) { + delete milightClient; + } + if (mqttClient) { + delete mqttClient; + delete bulbStateUpdater; + + mqttClient = NULL; + bulbStateUpdater = NULL; + } + if (stateStore) { + delete stateStore; + } + if (packetSender) { + delete packetSender; + } + if (radios) { + delete radios; + } + + transitions.setDefaultPeriod(settings.defaultTransitionPeriod); + + radioFactory = MiLightRadioFactory::fromSettings(settings); + + if (radioFactory == NULL) { + Serial.println(F("ERROR: unable to construct radio factory")); + } + + stateStore = new GroupStateStore(MILIGHT_MAX_STATE_ITEMS, settings.stateFlushInterval); + + radios = new RadioSwitchboard(radioFactory, stateStore, settings); + packetSender = new PacketSender(*radios, settings, onPacketSentHandler); + + milightClient = new MiLightClient( + *radios, + *packetSender, + stateStore, + settings, + transitions + ); + milightClient->onUpdateBegin(onUpdateBegin); + milightClient->onUpdateEnd(onUpdateEnd); + + 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); + } + + initMilightUdpServers(); + + if (discoveryServer) { + delete discoveryServer; + discoveryServer = NULL; + } + if (settings.discoveryPort != 0) { + discoveryServer = new MiLightDiscoveryServer(settings); + discoveryServer->begin(); + } + + // update LED pin and operating mode + if (ledStatus) { + 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); +} + +/** + * + */ +bool shouldRestart() { + if (! settings.isAutoRestartEnabled()) { + return false; + } + + return settings.getAutoRestartPeriod()*60*1000 < millis(); +} + +// give a bit of time to update the status LED +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); + applySettings(); + + // set up the LED status for wifi configuration + ledStatus = new LEDStatus(settings.ledPin); + ledStatus->continuous(settings.ledModeWifiConfig); + + // start up the wifi manager + if (! MDNS.begin("milight-hub")) { + Serial.println(F("Error setting up MDNS responder")); + } + // tell Wifi manager to call us during the setup. Note that this "setSetupLoopCallback" is an addition + // made to Wifi manager in a private fork. As of this writing, WifiManager has a new feature coming that + // allows the "autoConnect" method to be non-blocking which can implement this same functionality. However, + // 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); + Serial.println(F("Wifi connected succesfully\n")); + + // if the config portal was started, make sure to turn off the config AP + WiFi.mode(WIFI_STA); + } else { + // set LED mode for Wifi failed + ledStatus->continuous(settings.ledModeWifiFailed); + Serial.println(F("Wifi failed. Restarting in 10 seconds.\n")); + + delay(10000); + ESP.restart(); + } + + + MDNS.addService("http", "tcp", 80); + + SSDP.setSchemaURL("description.xml"); + SSDP.setHTTPPort(80); + SSDP.setName("ESP8266 MiLight Gateway"); + SSDP.setSerialNumber(ESP.getChipId()); + SSDP.setURL("/"); + SSDP.setDeviceType("upnp:rootdevice"); + SSDP.begin(); + + 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)); +} + +void loop() { + httpServer->handleClient(); + + if (mqttClient) { + mqttClient->handleClient(); + bulbStateUpdater->loop(); + } + + for (size_t i = 0; i < udpServers.size(); i++) { + udpServers[i]->handleClient(); + } + + if (discoveryServer) { + discoveryServer->handleClient(); + } + + handleListen(); + + stateStore->limitedFlush(); + packetSender->loop(); + + // update LED with status + ledStatus->handle(); + + transitions.loop(); + + if (shouldRestart()) { + Serial.println(F("Auto-restart triggered. Restarting...")); + ESP.restart(); + } +} + +#endif \ No newline at end of file diff --git a/test/d1_mini/test.cpp b/test/d1_mini/test.cpp new file mode 100644 index 0000000..8fe1041 --- /dev/null +++ b/test/d1_mini/test.cpp @@ -0,0 +1,368 @@ +// #if defined(ARDUINO) && defined(UNIT_TEST) + +#include +#include + +#include +#include +#include +#include + +#include +#include +#include + +#include "unity.h" + +//================================================================================ +// Packet formatter +//================================================================================ + +template +void run_packet_test(uint8_t* packet, PacketFormatter* packetFormatter, const BulbId& expectedBulbId, const String& expectedKey, const T expectedValue) { + GroupStateStore stateStore(10, 0); + Settings settings; + RgbCctPacketFormatter formatter; + DynamicJsonBuffer jsonBuffer; + JsonObject& result = jsonBuffer.createObject(); + + 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"); + TEST_ASSERT_EQUAL_INT_MESSAGE(expectedBulbId.groupId, bulbId.groupId, "Should get the expected group ID"); + TEST_ASSERT_EQUAL_INT_MESSAGE(expectedBulbId.deviceType, bulbId.deviceType, "Should get the expected remote type"); + + TEST_ASSERT_TRUE_MESSAGE(result.containsKey(expectedKey), "Parsed packet should be for expected command type"); + TEST_ASSERT_TRUE_MESSAGE(result[expectedKey] == expectedValue, "Parsed packet should have expected value"); +} + +void test_fut092_packet_formatter() { + RgbCctPacketFormatter packetFormatter; + + 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", + "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", + 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", + COLOR_TEMP_MAX_MIREDS + ); +} + +void test_fut091_packet_formatter() { + FUT091PacketFormatter packetFormatter; + + uint8_t onPacket[] = {0x00, 0xDC, 0xE1, 0x24, 0x66, 0xCA, 0xBA, 0x66, 0xB5}; + run_packet_test( + 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", + 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", + COLOR_TEMP_MAX_MIREDS + ); +} + +//================================================================================ +// Group State +//================================================================================ + +GroupState color() { + GroupState s; + + s.setState(MiLightStatus::ON); + s.setBulbMode(BulbMode::BULB_MODE_COLOR); + s.setHue(1); + s.setSaturation(10); + s.setBrightness(100); + + return s; +} + +void test_init_state() { + GroupState s; + + TEST_ASSERT_EQUAL(s.getBulbMode(), BulbMode::BULB_MODE_WHITE); + TEST_ASSERT_EQUAL(s.isSetBrightness(), false); +} + +void test_state_updates() { + GroupState s = color(); + + // Make sure values are packed and unpacked correctly + TEST_ASSERT_EQUAL(s.getBulbMode(), BulbMode::BULB_MODE_COLOR); + TEST_ASSERT_EQUAL(s.getBrightness(), 100); + TEST_ASSERT_EQUAL(s.getHue(), 1); + TEST_ASSERT_EQUAL(s.getSaturation(), 10); + + // Make sure brightnesses are tied to mode + s.setBulbMode(BulbMode::BULB_MODE_WHITE); + s.setBrightness(0); + + TEST_ASSERT_EQUAL(s.getBulbMode(), BulbMode::BULB_MODE_WHITE); + TEST_ASSERT_EQUAL(s.getBrightness(), 0); + + s.setBulbMode(BulbMode::BULB_MODE_COLOR); + + TEST_ASSERT_EQUAL(s.getBrightness(), 100); +} + +void test_cache() { + BulbId id1(1, 1, REMOTE_TYPE_FUT089); + BulbId id2(1, 2, REMOTE_TYPE_FUT089); + + GroupState s = color(); + s.clearDirty(); + s.clearMqttDirty(); + + GroupStateCache cache(1); + GroupState* storedState = cache.get(id2); + + TEST_ASSERT_NULL_MESSAGE(storedState, "Should not retrieve value which hasn't been stored"); + + cache.set(id1, s); + storedState = cache.get(id1); + + TEST_ASSERT_NOT_NULL_MESSAGE(storedState, "Should retrieve a value"); + TEST_ASSERT_TRUE_MESSAGE(s == *storedState, "State should be the same when retrieved"); + + cache.set(id2, s); + storedState = cache.get(id2); + + TEST_ASSERT_NOT_NULL_MESSAGE(storedState, "Should retrieve a value"); + TEST_ASSERT_TRUE_MESSAGE(s == *storedState, "State should be the same when retrieved"); + + storedState = cache.get(id1); + + TEST_ASSERT_NULL_MESSAGE(storedState, "Should evict old entry from cache"); +} + +void test_persistence() { + BulbId id1(1, 1, REMOTE_TYPE_FUT089); + BulbId id2(1, 2, REMOTE_TYPE_FUT089); + + GroupStatePersistence persistence; + + persistence.clear(id1); + persistence.clear(id2); + + GroupState storedState; + GroupState s = color(); + s.clearDirty(); + s.clearMqttDirty(); + + GroupState defaultState = GroupState::defaultState(REMOTE_TYPE_FUT089); + + persistence.get(id1, storedState); + TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(defaultState), "Should start with clean state"); + persistence.get(id2, storedState); + TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(defaultState), "Should start with clean state"); + + persistence.set(id1, s); + + persistence.get(id2, storedState); + TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(defaultState), "Should return default for state that hasn't been stored"); + + persistence.get(id1, storedState); + TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(s), "Should retrieve state from flash without modification"); + + GroupState newState = s; + newState.setBulbMode(BulbMode::BULB_MODE_WHITE); + newState.setBrightness(255); + persistence.set(id2, newState); + + persistence.get(id1, storedState); + + TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(s), "Should retrieve unmodified state"); + + persistence.get(id2, storedState); + + TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(newState), "Should retrieve modified state"); +} + +void test_store() { + BulbId id1(1, 1, REMOTE_TYPE_FUT089); + BulbId id2(1, 2, REMOTE_TYPE_FUT089); + + GroupStateStore store(4, 0); + GroupStatePersistence persistence; + + persistence.clear(id1); + persistence.clear(id2); + + GroupState initState = color(); + GroupState initState2 = color(); + GroupState defaultState = GroupState::defaultState(REMOTE_TYPE_FUT089); + initState2.setBrightness(50); + + GroupState* storedState; + + 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); + + 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); + 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); + TEST_ASSERT_TRUE_MESSAGE(storedState->isEqualIgnoreDirty(defaultState), "Should return cached state"); + + storedState = store.get(id1); + TEST_ASSERT_TRUE_MESSAGE(storedState->isEqualIgnoreDirty(initState), "Should return persisted state"); +} + +void test_group_0() { + BulbId group0Id(1, 0, REMOTE_TYPE_FUT089); + BulbId id1(1, 1, REMOTE_TYPE_FUT089); + BulbId id2(1, 2, REMOTE_TYPE_FUT089); + + // cache 1 item, flush immediately + GroupStateStore store(10, 0); + GroupStatePersistence persistence; + + persistence.clear(id1); + persistence.clear(id2); + + GroupState initState = color(); + GroupState initState2 = color(); + GroupState storedState; + GroupState expectedState; + GroupState group0State; + + initState2.setBrightness(255); + group0State.setHue(100); + + store.set(id1, initState); + store.set(id2, initState2); + + 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); + TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(initState), "Should fetch persisted state"); + + storedState = *store.get(id2); + TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(initState2), "Should fetch persisted state"); + + store.set(group0Id, group0State); + + 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); + 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 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); + GroupState rgbState = GroupState::defaultState(REMOTE_TYPE_RGB); + rgbState.setHue(100); + rgbState.setBrightness(100); + + store.set(rgbId, rgbState); + store.flush(); + + storedState = *store.get(rgbId); + + TEST_ASSERT_TRUE_MESSAGE(storedState.isEqualIgnoreDirty(rgbState), "Should persist group 0 for device type with no groups"); +} + +// setup connects serial, runs test cases (upcoming) +void setup() { + delay(2000); + SPIFFS.begin(); + Serial.begin(9600); + + UNITY_BEGIN(); + + RUN_TEST(test_init_state); + RUN_TEST(test_state_updates); + RUN_TEST(test_cache); + RUN_TEST(test_persistence); + RUN_TEST(test_store); + RUN_TEST(test_group_0); + + RUN_TEST(test_fut091_packet_formatter); + RUN_TEST(test_fut092_packet_formatter); + + UNITY_END(); +} + +void loop() { + // nothing to be done here. +} + +// #endif \ No newline at end of file diff --git a/test/remote/.rspec b/test/remote/.rspec new file mode 100644 index 0000000..c99d2e7 --- /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 0000000..ec1cf33 --- /dev/null +++ b/test/remote/.ruby-version @@ -0,0 +1 @@ +2.6.3 diff --git a/test/remote/Gemfile b/test/remote/Gemfile new file mode 100644 index 0000000..7e79e9e --- /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 0000000..bee8d38 --- /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 0000000..ecedf84 --- /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 0000000..6f3aec8 --- /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 0000000..f848649 --- /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 0000000..59a6189 --- /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 0000000..f2a0851 --- /dev/null +++ b/test/remote/helpers/transition_helpers.rb @@ -0,0 +1,131 @@ +require 'chroma' + +module TransitionHelpers + module Defaults + PERIOD = 500 + NUM_PERIODS = 20 + DURATION = PERIOD * NUM_PERIODS + 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), + "Transition was a different length than expected.\n" << + " Expected : #{expected}\n" << + " Seen : #{seen}" + + 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 0000000..4f3d46c --- /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 0000000..134df2f --- /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 0000000..6137229 --- /dev/null +++ b/test/remote/settings.json.example @@ -0,0 +1,41 @@ +{ + "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, + "mqtt_debounce_delay": 0, + "mqtt_retain": true, + "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 0000000..6d2cd0c --- /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 0000000..928cb25 --- /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 0000000..d8593cc --- /dev/null +++ b/test/remote/spec/mqtt_spec.rb @@ -0,0 +1,559 @@ +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.merge(mqtt_retain: true) + ) + + @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 'retained messages' do + it 'should publish retained state messages when enabled' do + @client.put('/settings', mqtt_retain: 'true') + @client.patch_state({status: 'ON'}, @id_params) + + # Sleep to make sure we're getting a retained message + sleep 1 + + @mqtt_client.on_state(@id_params) { true } + @mqtt_client.wait_for_listeners + end + + it 'should not publish retained state messages when not enabled' do + @client.put('/settings', mqtt_retain: 'false') + @client.patch_state({status: 'ON'}, @id_params) + + # Sleep to make sure we're getting a retained message + sleep 1 + + @mqtt_client.on_state(@id_params) { true } + expect { @mqtt_client.wait_for_listeners }.to raise_error(Timeout::Error) + 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 + + it 'should respect the update debouce interval' do + @client.put( + '/settings', + mqtt_debounce_delay: 1000, + packet_repeats: 1 + ) + + start_time = Time.now + + @mqtt_client.on_state(@id_params) do |id, message| + true + end + + # Set initial state + @client.patch_state({status: 'ON', level: 0}, @id_params) + @mqtt_client.wait_for_listeners + + expect(Time.now - start_time).to be >= 1 + end + + it 'should only send one state update for many commands if debounce interval is enabled' do + @client.put( + '/settings', + mqtt_update_topic_pattern: '', + mqtt_debounce_delay: 1000, + packet_repeats: 1 + ) + + # Set initial state + @client.patch_state({status: 'ON', level: 0}, @id_params) + + num_updates = 10 + seen_updates = 0 + last_level_value = 0 + + @mqtt_client.on_state(@id_params) do |id, message| + seen_updates += 1 + last_level_value = message['level'] + last_level_value == num_updates + end + + (1..num_updates).each do |i| + @mqtt_client.patch_state(@id_params, level: i) + sleep 0.5 + end + + @mqtt_client.wait_for_listeners + + expect(seen_updates).to eq(1) + expect(last_level_value).to eq(num_updates) + 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 0000000..45c7a6d --- /dev/null +++ b/test/remote/spec/rest_spec.rb @@ -0,0 +1,193 @@ +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 + + before(:each) do + @id_params = { + id: @client.generate_id, + type: 'rgb_cct', + group_id: 1 + } + @client.delete_state(@id_params) + 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 + + context 'async state' do + it 'should respond with state for GET, regardless of blockOnQueue param value' do + @client.patch_state({status: 'ON'}, @id_params) + response = @client.get_state(@id_params.merge(blockOnQueue: false)) + + expect(response['status']).to eq('ON') + 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 0000000..97b494c --- /dev/null +++ b/test/remote/spec/settings_spec.rb @@ -0,0 +1,219 @@ +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], + 'mqtt_retain' => [true, false], + 'packet_repeats_per_loop' => [10], + 'home_assistant_discovery_prefix' => ['', 'abc', 'a/b/c'], + 'wifi_mode' => %w(b g n), + 'default_transition_period' => [200, 500] + }.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 0000000..84f4073 --- /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 0000000..96be897 --- /dev/null +++ b/test/remote/spec/state_spec.rb @@ -0,0 +1,566 @@ +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: ['brightness_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 + + %w(rgb fut020 cct).each do |protocol| + it "should set brightness to a level if value is not known for #{protocol} protocol" do + # RGB / fut020 don't have groups -- so use group 0 for them. + id = @id_params.merge(type: protocol, group_id: protocol == 'cct' ? 1 : 0) + + @client.delete_state(id) + + expect(@client.get_state(id)).to_not include('level', 'kelvin') + + @client.patch_state({status: 'ON', level: 50}, id) + + state = @client.get_state(id) + expect(state).to include('level') + expect(state['level']).to eq(50) + end + 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 0000000..bfabf17 --- /dev/null +++ b/test/remote/spec/transition_spec.rb @@ -0,0 +1,742 @@ +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 accept device_type as an ID parameter' do + params = { + device_id: @id_params[:id], + device_type: @id_params[:type], + group_id: @id_params[:group_id] + }.merge(@transition_params) + response = @client.post("/transitions", 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 + + it 'should support creating long transitions' do + @client.patch_state({status: 'ON', level: 0}, @id_params) + @client.patch_state({level: 100, transition: 60000}, @id_params) + + t = @client.transitions.last + calculated_duration = t['period'] * (100.to_f / t['step_size']) + + expect(calculated_duration).to be_within(100).of(60000*1000), "Calculated duration should be close to 600s" + 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 turn off even if starting brightness is 0' do + @client.patch_state({status: 'ON', brightness: 0}, @id_params) + seen_off = false + + @mqtt_client.on_update(@id_params) do |id, message| + seen_off = (message['state'] == 'OFF') + end + + @client.patch_state({status: "OFF", transition: 1}, @id_params) + @mqtt_client.wait_for_listeners + + expect(seen_off).to eq(true) + end + + 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, 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 == 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 if the bulb is off' 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 + + @mqtt_client.patch_state(@id_params, {state: 'ON', transition: 1.0}) + + @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 last known brightness if the bulb is already on' 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['brightness'] && seen_updates['brightness'].last == 255 + end + + @mqtt_client.patch_state(@id_params, {state: 'ON', brightness: 255, transition: 1.0}) + + @mqtt_client.wait_for_listeners + + transitions_are_equal( + expected: calculate_transition_steps(start_value: 99, 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: 10.0, test: 2}, + period: {default: 500, 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(3).of(expected_duration) + expect(gap).to be_within(10).of((255/num_periods).ceil) + end + end + end + + context 'default parameters in settings' do + it 'should respect the default parameter setting key' do + [500, 1000, 2000].each do |period| + field = 'brightness' + + @client.patch_settings(default_transition_period: period) + @client.delete_state(@id_params) + @client.patch_state({'status' => 'ON', field => 0}, @id_params) + seen_updates = [] + + @mqtt_client.on_update(@id_params) do |id, message| + seen_updates << message[field] if !message[field].nil? + seen_updates.last == 255 + end + + @client.patch_state({field => 255, 'transition' => 2.0, period: period}, @id_params) + + @mqtt_client.wait_for_listeners + @mqtt_client = create_mqtt_client() + + transitions_are_equal( + expected: calculate_transition_steps(start_value: 0, end_value: 255, duration: 2000, period: period), + seen: seen_updates, + allowed_variation: 3 + ) + end + end + + it 'for upwards transition, should throttle frequency if step size does not allow for default period' do + @client.patch_state({status: "ON", brightness: 0}, @id_params) + @client.patch_state({brightness: 255, transition: 3600}, @id_params) + + transitions = @client.transitions + + expect(transitions.count).to eq(1) + expect(transitions.first['period']).to eq((3600000/255.0).round) + end + + it 'for downwards transition, should throttle frequency if step size does not allow for default period' do + @client.patch_state({status: "ON", brightness: 255}, @id_params) + @client.patch_state({brightness: 0, transition: 3600}, @id_params) + + transitions = @client.transitions + + expect(transitions.count).to eq(1) + expect(transitions.first['period']).to eq((3600000/255.0).round) + 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 0000000..0008773 --- /dev/null +++ b/test/remote/spec/udp_spec.rb @@ -0,0 +1,153 @@ +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 + @client.delete_state(@id_params) + @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 + @client.delete_state(@id_params) + 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/.nvmrc b/web/.nvmrc new file mode 100644 index 0000000..f599e28 --- /dev/null +++ b/web/.nvmrc @@ -0,0 +1 @@ +10 diff --git a/web/gulpfile.js b/web/gulpfile.js new file mode 100644 index 0000000..93170d5 --- /dev/null +++ b/web/gulpfile.js @@ -0,0 +1,63 @@ +const fs = require('fs'); +const { src, dest, series } = require('gulp'); +const htmlmin = require('gulp-htmlmin'); +const cleancss = require('gulp-clean-css'); +const uglify = require('gulp-uglify'); +const gzip = require('gulp-gzip'); +const del = require('del'); +const inline = require('gulp-inline'); +const inlineImages = require('gulp-css-base64'); +const favicon = require('gulp-base64-favicon'); + +const dataFolder = 'build/'; + +function clean() { + return del([ dataFolder + '*']); +} + +function buildfs_embeded() { + var source = dataFolder + 'index.html.gz'; + var destination = dataFolder + 'index.html.gz.h'; + + var wstream = fs.createWriteStream(destination); + wstream.on('error', function (err) { + console.log(err); + }); + + var data = fs.readFileSync(source); + + wstream.write('#define index_html_gz_len ' + data.length + '\n'); + wstream.write('static const char index_html_gz[] PROGMEM = {') + + for (i=0; i + + + + + + MiLight Hub + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + +
+
+
+ +
+
+ + +
+ +
+
+ + +
+ + + + + + + + + +
+
+
+ +
+ + +
+
+ +
+
+
+
+
Hue
+
+
+ +
+
+ + + + +
+
+
+
+ +
+
+
+
Saturation
+
+
+
+
+ +
+
+
+ +
+
+
+
Color Temperature
+
+
+
+
+
+ +
+
+
+
+ +
+
+
Brightness
+
+
+ +
+
+ +
+
+ +
+
+
Commands
+
+
+ +
+
+
    +
  • + +
  • +
    +
  • + +
  • +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+

+
    +
    +
  • +
    + + Mode + +
    +
    +
  • +
    +
    +
  • + +
  • +
    +
    +
  • +
    + + Speed + +
    +
    +
  • +
    +
+
+
+ + + + +
 
+ + + +
+ +

Traffic sniffed (sent and received)

+
+
+
+ + + + + + + + + + diff --git a/web/src/js/rgb2hsv.js b/web/src/js/rgb2hsv.js new file mode 100644 index 0000000..f19d24b --- /dev/null +++ b/web/src/js/rgb2hsv.js @@ -0,0 +1,48 @@ +// https://gist.github.com/mjackson/5311256 +function rgbToHsl(r, g, b) { + r /= 255, g /= 255, b /= 255; + + var max = Math.max(r, g, b), min = Math.min(r, g, b); + var h, s, l = (max + min) / 2; + + if (max == min) { + h = s = 0; // achromatic + } else { + var d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + + switch (max) { + case r: h = (g - b) / d + (g < b ? 6 : 0); break; + case g: h = (b - r) / d + 2; break; + case b: h = (r - g) / d + 4; break; + } + + h /= 6; + } + + return {h: h, s: s, l: l }; +} + +function RGBtoHSV(r, g, b) { + if (arguments.length === 1) { + g = r.g, b = r.b, r = r.r; + } + var max = Math.max(r, g, b), min = Math.min(r, g, b), + d = max - min, + h, + s = (max === 0 ? 0 : d / max), + v = max / 255; + + switch (max) { + case min: h = 0; break; + case r: h = (g - b) + d * (g < b ? 6: 0); h /= 6 * d; break; + case g: h = (b - r) + d * 2; h /= 6 * d; break; + case b: h = (r - g) + d * 4; h /= 6 * d; break; + } + + return { + h: h, + s: s, + v: v + }; +}; diff --git a/web/src/js/script.js b/web/src/js/script.js new file mode 100644 index 0000000..a2bb2bf --- /dev/null +++ b/web/src/js/script.js @@ -0,0 +1,1372 @@ +$(function() { + $(document).on('change', ':file', function() { + var input = $(this), + label = input.val().replace(/\\/g, '/').replace(/.*\//, ''); + input.trigger('fileselect', [label]); + }); + + $(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); + } + }); + }); +}); + +var UNIT_PARAMS = { + minMireds: 153, + maxMireds: 370, + maxBrightness: 255 +}; + +var UI_TABS = [ { + tag: "tab-wifi", + friendly: "Wifi", + }, { + tag: "tab-setup", + friendly: "Setup", + }, { + tag: "tab-led", + friendly: "LED", + }, { + tag: "tab-radio", + friendly: "Radio", + }, { + tag: "tab-mqtt", + friendly: "MQTT" + }, { + tag: "tab-transitions", + friendly: "Transitions" + } +]; + +var UI_FIELDS = [ { + tag: "admin_username", + friendly: "Admin username", + help: "Username for logging into this webpage", + type: "string", + tab: "tab-wifi" + }, { + tag: "admin_password", + friendly: "Password", + help: "Password for logging into this webpage", + type: "string", + tab: "tab-wifi" + }, { + tag: "hostname", + friendly: "Hostname", + help: "Self-reported hostname to send along with DCHP request", + type: "string", + tab: "tab-wifi" + }, { + tag: "wifi_static_ip", + friendly: "Static IP Address", + help: "Static IP address (leave blank to use DHCP)", + type: "string", + tab: "tab-wifi" + }, { + tag: "wifi_static_ip_netmask", + friendly: "Static IP Netmask", + help: "Netmask to use with Static IP", + type: "string", + tab: "tab-wifi" + }, { + tag: "wifi_static_ip_gateway", + friendly: "Static IP Gateway Address", + help: "IP address to use as gateway when a Static IP is speicifed", + type: "string", + tab: "tab-wifi" + }, { + tag: "wifi_mode", + friendly: "WiFi Mode", + help: "Try using G mode if you're having stability problems", + type: "option_buttons", + options: { + 'b': 'B', + 'g': 'G', + 'n': 'N' + }, + tab: "tab-wifi" + }, { + tag: "ce_pin", + friendly: "CE / PKT pin", + help: "Pin on ESP8266 used for 'CE' (for NRF24L01 interface) or 'PKT' (for 'PL1167/LT8900' interface)", + type: "string", + tab: "tab-setup" + }, { + tag: "csn_pin", + friendly: "CSN pin", + help: "Pin on ESP8266 used for 'CSN'", + type: "string", + tab: "tab-setup" + }, { + tag: "reset_pin", + friendly: "RESET pin", + help: "Pin on ESP8266 used for 'RESET'", + type: "string", + tab: "tab-setup" + }, { + tag: "led_pin", + friendly: "LED pin", + help: "Pin to use for LED status display (0=disabled); negative inverses signal (recommend -2 for on-board LED)", + type: "string", + tab: "tab-setup" + }, { + tag: "packet_repeats", + friendly: "Packet repeats", + help: "The number of times to repeat RF packets sent to bulbs", + type: "string", + tab: "tab-radio" + }, { + tag: "packet_repeats_per_loop", + friendly: "Packet repeats per loop", + help: "Number of repeats to send in a single go. Higher values mean more throughput, but less multitasking.", + type: "string", + tab: "tab-radio" + }, { + tag: "http_repeat_factor", + friendly: "HTTP repeat factor", + help: "Multiplicative factor on packet_repeats for requests initiated by the HTTP API. UDP API typically receives " + + "duplicate packets, so more repeats should be used for HTTP", + type: "string", + tab: "tab-wifi" + }, { + tag: "auto_restart_period", + friendly: "Auto-restart period", + help: "Automatically restart the device every number of minutes specified. Use 0 for disabled", + type: "string", + tab: "tab-setup" + }, { + tag: "discovery_port", + friendly: "Discovery port", + help: "UDP port to listen for discovery packets on. Defaults to the same port used by MiLight devices, 48899. Use 0 to disable", + type: "string", + tab: "tab-wifi" + }, { + tag: "mqtt_server", + friendly: "MQTT server", + help: "Domain or IP address of MQTT broker. Optionally specify a port with (example) myMQTTbroker.com:1884", + type: "string", + tab: "tab-mqtt" + }, { + tag: "mqtt_topic_pattern", + friendly: "MQTT topic pattern", + help: "Pattern for MQTT topics to listen on. Example: lights/:device_id/:device_type/:group_id. See README for further details", + type: "string", + tab: "tab-mqtt" + }, { + tag: "mqtt_update_topic_pattern", + friendly: "MQTT update topic pattern", + help: "Pattern to publish MQTT updates. Packets that are received from other devices, and packets that are sent from this device will " + + "result in updates being sent", + type: "string", + tab: "tab-mqtt" + }, { + tag: "mqtt_state_topic_pattern", + friendly: "MQTT state topic pattern", + help: "Pattern for MQTT topic to publish state to. When a group changes state, the full known state of the group will be published to this topic pattern", + type: "string", + tab: "tab-mqtt" + }, { + tag: "mqtt_username", + friendly: "MQTT user name", + help: "User name to log in to MQTT server", + type: "string", + tab: "tab-mqtt" + }, { + tag: "mqtt_password", + friendly: "MQTT password", + help: "Password to log into MQTT server", + type: "string", + tab: "tab-mqtt" + }, { + tag: "mqtt_client_status_topic", + friendly: "MQTT Client Status Topic", + help: "Connection status messages will be published to this topic. This includes LWT and birth. See README for further detail.", + type: "string", + tab: "tab-mqtt" + }, { + tag: "mqtt_retain", + friendly: "Publish state messages with retain flag", + help: "If enabled, state messages will be published with the MQTT retain flag.", + type: "option_buttons", + options: { + true: "Enabled", + false: "Disabled" + }, + tab: "tab-mqtt" + }, { + tag: "simple_mqtt_client_status", + friendly: "Client Status Messages Mode", + help: "In simple mode, only the strings 'connected' and 'disconnected' will be published. In detailed mode, data about the version, IP address, etc. will be included.", + type: "option_buttons", + options: { + true: "Simple", + false: "Detailed" + }, + tab: "tab-mqtt" + }, { + tag: "home_assistant_discovery_prefix", + friendly: "HomeAssistant MQTT Discovery Prefix", + help: "If set, will enable integration with HomeAssistant's MQTT discovery functionality to allow saved aliases to be detected automatically", + type: "string", + tab: "tab-mqtt" + }, { + tag: "radio_interface_type", + friendly: "Radio interface type", + help: "2.4 GHz radio model. Only change this if you know you're not using an NRF24L01!", + type: "option_buttons", + options: { + 'nRF24': 'nRF24', + 'LT8900': 'PL1167/LT8900' + }, + tab: "tab-radio" + }, { + tag: "rf24_power_level", + friendly: "nRF24 Power Level", + help: "Power level for nRF24L01", + type: "option_buttons", + options: { + 'MIN': 'Min', + 'LOW': 'Low', + 'HIGH': 'High', + 'MAX': 'Max' + }, + tab: "tab-radio" + }, { + tag: "rf24_listen_channel", + friendly: "nRF24 Listen Channel", + help: "Which channels to listen for messages on the nRF24", + type: "option_buttons", + options: { + 'LOW': 'Min', + 'MID': 'Mid', + 'HIGH': 'High' + }, + tab: "tab-radio" + }, { + tag: "rf24_channels", + friendly: "nRF24 Send Channels", + help: "Which channels to send messages on for the nRF24. Using fewer channels speeds up sends.", + type: "option_buttons", + settings: { + multiple: true, + }, + options: { + 'LOW': 'Min', + 'MID': 'Mid', + 'HIGH': 'High' + }, + tab: "tab-radio" + }, { + tag: "listen_repeats", + friendly: "Listen repeats", + help: "Increasing this increases the amount of time spent listening for " + + "packets. Set to 0 to disable listening. Default is 3.", + type: "string", + tab: "tab-wifi" + }, { + tag: "state_flush_interval", + friendly: "State flush interval", + help: "Minimum number of milliseconds between flushing state to flash. " + + "Set to 0 to disable delay and immediately persist state to flash", + type: "string", + tab: "tab-setup" + }, { + tag: "mqtt_state_rate_limit", + friendly: "MQTT state rate limit", + help: "Minimum number of milliseconds between MQTT updates of bulb state (defaults to 500)", + type: "string", + tab: "tab-mqtt" + }, { + tag: "mqtt_debounce_delay", + friendly: "MQTT debounce delay", + help: "Minimum number of milliseconds delay for MQTT state updates after change (defaults to 500)", + type: "string", + tab: "tab-mqtt" + }, { + tag: "packet_repeat_throttle_threshold", + friendly: "Packet repeat throttle threshold", + help: "Controls how packet repeats are throttled. Packets sent " + + "with less time between them than this value (in milliseconds) will cause " + + "packet repeats to be throttled down. More than this value will unthrottle " + + "up. Defaults to 200ms", + type: "string", + tab: "tab-radio" + }, { + tag: "packet_repeat_throttle_sensitivity", + friendly: "Packet repeat throttle sensitivity", + help: "Controls how packet repeats are throttled. " + + "Higher values cause packets to be throttled up and down faster " + + "(defaults to 0, maximum value 1000, 0 disables)", + type: "string", + tab: "tab-radio" + }, { + tag: "packet_repeat_minimum", + friendly: "Packet repeat minimum", + help: "Controls how far throttling can decrease the number " + + "of repeated packets (defaults to 3)", + type: "string", + tab: "tab-radio" + }, { + tag: "group_state_fields", + friendly: "Group state fields", + help: "Selects which fields should be included in MQTT state updates and REST responses for bulb state", + type: "group_state_fields", + tab: "tab-mqtt" + }, { + tag: "enable_automatic_mode_switching", + friendly: "Switch to previous mode after saturation/color commands", + help: "For RGBWW bulbs (using RGB+CCT or FUT089), commands that adjust color saturation or white temperature " + + "will switch into the appropriate mode, make the change, and switch back to previous mode. WARNING: this " + + "feature is not compatible with 'color' commands.", + type: "option_buttons", + options: { + true: 'Enable', + false: 'Disable' + }, + tab: "tab-radio" + }, { + tag: "led_mode_wifi_config", + friendly: "LED mode during wifi config", + help: "LED mode when the device is in Access Point mode waiting to configure Wifi", + type: "led_mode", + tab: "tab-led" + }, { + tag: "led_mode_wifi_failed", + friendly: "LED mode when wifi failed to connect", + help: "LED mode when the device has failed to connect to the wifi network", + type: "led_mode", + tab: "tab-led" + }, { + tag: "led_mode_operating", + friendly: "LED mode when operating", + help: "LED mode when the device is in successfully running", + type: "led_mode", + tab: "tab-led" + }, { + tag: "led_mode_packet", + friendly: "LED mode on packets", + help: "LED mode when the device is sending or receiving packets", + type: "led_mode", + tab: "tab-led" + }, { + tag: "led_mode_packet_count", + friendly: "Flash count on packets", + help: "Number of times the LED will flash when packets are changing", + type: "string", + tab: "tab-led" + }, { + tag: "default_transition_period", + friendly: "Default transition period (milliseconds)", + help: "Controls how many milliseconds pass between transition packets. "+ + "For more granular transitions, set this lower.", + type: "string", + tab: "tab-transitions" + } +]; + +// TODO: sync this with GroupStateField.h +var GROUP_STATE_KEYS = [ + "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" +]; + +var LED_MODES = [ + "Off", + "Slow toggle", + "Fast toggle", + "Slow blip", + "Fast blip", + "Flicker", + "On" +]; + +var UDP_PROTOCOL_VERSIONS = [ 5, 6 ]; +var DEFAULT_UDP_PROTOCL_VERSION = 5; + +var selectize; +var aliasesSelectize; +var sniffing = false; +var loadingSettings = false; + +// When true, will not attempt to load group parameters +var updatingGroupId = false; + +// When true, will not attempt to update group parameters +var updatingAlias = false; + +// don't attempt websocket if we are debugging locally +if (location.hostname != "") { + var webSocket = new WebSocket("ws://" + location.hostname + ":81"); + webSocket.onmessage = function(e) { + if (sniffing) { + var message = e.data; + $('#sniffed-traffic').prepend('
' + message + '
'); + } + } +} + +var toHex = function(v) { + return "0x" + (v).toString(16).toUpperCase(); +} + +var updateGroupId = function(params) { + updatingGroupId = true; + + selectize.setValue(params.deviceId); + setGroupId(params.groupId); + setMode(params.deviceType); + + updatingGroupId = false; + + refreshGroupState(); +} + +var setGroupId = function(value) { + $('#groupId input[data-value="' + value + '"]').click(); +} + +var setMode = function(value) { + $('#mode li[data-value="' + value + '"]').click(); +} + +var getCurrentDeviceId = function() { + // return $('#deviceId option:selected').val(); + return parseInt(selectize.getValue()); +}; + +var getCurrentGroupId = function() { + return $('#groupId .active input').data('value'); +} + +var findAndSelectAlias = function() { + if (!updatingGroupId) { + var params = { + deviceType: getCurrentMode(), + deviceId: getCurrentDeviceId(), + groupId: getCurrentGroupId() + }; + + var foundAlias = Object.entries(aliasesSelectize.options).filter(function(x) { + return _.isEqual(x[1].savedGroupParams, params); + }); + + updatingAlias = true; + if (foundAlias.length > 0) { + aliasesSelectize.setValue(foundAlias[0]); + } else { + aliasesSelectize.clear(); + } + updatingAlias = false; + } +} + +var activeUrl = function() { + var deviceId = getCurrentDeviceId() + , groupId = getCurrentGroupId() + , mode = getCurrentMode(); + + if (deviceId == "") { + throw "Must enter device ID"; + } + + if (! $('#group-option').data('for').split(',').includes(mode)) { + groupId = 0; + } + + if (typeof groupId === "undefined") { + throw "Must enter group ID"; + } + + return "/gateways/" + deviceId + "/" + mode + "/" + groupId; +} + +var refreshGroupState = function() { + if (! updatingGroupId) { + $.getJSON( + activeUrl(), + function(e) { + handleStateUpdate(e); + } + ); + } +} + +var getCurrentMode = function() { + return $('#mode li.active').data('value'); +}; + +var updateGroup = _.throttle( + function(params) { + try { + $.ajax({ + url: activeUrl() + "?blockOnQueue=true", + method: 'PUT', + data: JSON.stringify(params), + contentType: 'application/json', + success: function(e) { + handleStateUpdate(e); + } + }); + } catch (e) { + alert(e); + } + }, + 1000 +); + +var sendCommand = _.throttle( + function(params) { + $.ajax( + '/system', + { + method: 'POST', + data: JSON.stringify(params), + contentType: 'application/json' + } + ); + }, + 1000 +) + +var gatewayServerRow = function(deviceId, port, version) { + var elmt = ''; + elmt += ''; + elmt += ''; + elmt += ''; + elmt += '' + elmt += '';; + elmt += ''; + elmt += ''; + elmt += '
'; + + for (var i = 0; i < UDP_PROTOCOL_VERSIONS.length; i++) { + var val = UDP_PROTOCOL_VERSIONS[i] + , selected = (version == val || (val == DEFAULT_UDP_PROTOCL_VERSION && !UDP_PROTOCOL_VERSIONS.includes(version))); + + elmt += ''; + } + + elmt += '
'; + elmt += ''; + elmt += ''; + elmt += ''; + elmt += ''; + return elmt; +} + +var loadSettings = function() { + $('select.select-init').selectpicker(); + if (location.hostname == "") { + // if deugging locally, don't try get settings + return; + } + $.getJSON('/settings', function(val) { + loadingSettings = true; + + Object.keys(val).forEach(function(k) { + var field = $('#settings input[name="' + k + '"]'); + var selectVal = function(selectVal) { + field.filter('[value="' + selectVal + '"]').click(); + }; + + if (field.length > 0) { + if (field.attr('type') === 'radio' || field.attr('type') === 'checkbox') { + if (Array.isArray(val[k])) { + val[k].forEach(function(x) { + selectVal(x); + }); + } else { + selectVal(val[k]); + } + } else { + field.val(val[k]); + } + } + }); + + if (val.hostname) { + var title = "MiLight Hub: " + val.hostname; + document.title = title; + $('.navbar-brand').html(title); + } + + if (val.group_id_aliases) { + aliasesSelectize.clearOptions(); + Object.entries(val.group_id_aliases).forEach(function(entry) { + var label = entry[0] + , groupParams = entry[1] + , savedParams = { + deviceType: groupParams[0], + deviceId: groupParams[1], + groupId: groupParams[2] + } + ; + + aliasesSelectize.addOption({ + text: label, + value: label, + savedGroupParams: savedParams + }); + + aliasesSelectize.refreshOptions(false); + }); + } + + if (val.device_ids) { + selectize.clearOptions(); + val.device_ids.forEach(function(v) { + selectize.addOption({text: toHex(v), value: v}); + }); + selectize.refreshOptions(false); + } + + if (val.group_state_fields) { + var elmt = $('select[name="group_state_fields"]'); + elmt.selectpicker('val', val.group_state_fields); + } + + if (val.led_mode_wifi_config) { + var elmt = $('select[name="led_mode_wifi_config"]'); + elmt.selectpicker('val', val.led_mode_wifi_config); + } + + if (val.led_mode_wifi_failed) { + var elmt = $('select[name="led_mode_wifi_failed"]'); + elmt.selectpicker('val', val.led_mode_wifi_failed); + } + + if (val.led_mode_operating) { + var elmt = $('select[name="led_mode_operating"]'); + elmt.selectpicker('val', val.led_mode_operating); + } + + if (val.led_mode_packet) { + var elmt = $('select[name="led_mode_packet"]'); + elmt.selectpicker('val', val.led_mode_packet); + } + + var gatewayForm = $('#gateway-server-configs').html(''); + if (val.gateway_configs) { + val.gateway_configs.forEach(function(v) { + gatewayForm.append(gatewayServerRow(toHex(v[0]), v[1], v[2])); + }); + } + + loadingSettings = false; + }); +}; + +var saveGatewayConfigs = function() { + var form = $('#tab-udp-gateways') + , errors = false; + + $('input', form).removeClass('error'); + + var deviceIds = $('input[name="deviceIds[]"]', form).map(function(i, v) { + var val = $(v).val(); + + if (isNaN(val)) { + errors = true; + $(v).addClass('error'); + return null; + } else { + return val; + } + }); + + var ports = $('input[name="ports[]"]', form).map(function(i, v) { + var val = $(v).val(); + + if (isNaN(val)) { + errors = true; + $(v).addClass('error'); + return null; + } else { + return val; + } + }); + + var versions = $('.active input[name="versions[]"]', form).map(function(i, v) { + return $(v).data('value'); + }); + + if (!errors) { + var data = []; + for (var i = 0; i < deviceIds.length; i++) { + data[i] = [deviceIds[i], ports[i], versions[i]]; + } + $.ajax( + '/settings', + { + method: 'put', + contentType: 'application/json', + data: JSON.stringify({gateway_configs: data}) + } + ) + } +}; + +var patchSettings = function(patch) { + if (!loadingSettings) { + $.ajax( + "/settings", + { + method: 'put', + contentType: 'application/json', + data: JSON.stringify(patch) + } + ); + } +}; + +var saveDeviceIds = function() { + if (!loadingSettings) { + var deviceIds = _.map( + $('#deviceId')[0].selectize.options, + function(option) { + return option.value; + } + ); + + patchSettings({device_ids: deviceIds}); + } +}; + +var saveDeviceAliases = function() { + if (!loadingSettings) { + var deviceAliases = Object.entries(aliasesSelectize.options).reduce( + function(aggregate, x) { + var params = x[1].savedGroupParams; + + aggregate[x[0]] = [ + params.deviceType, + params.deviceId, + params.groupId + ] + + return aggregate; + }, + {} + ); + + patchSettings({group_id_aliases: deviceAliases}); + } +}; + +var deleteDeviceId = function() { + selectize.removeOption($(this).data('value')); + selectize.refreshOptions(); + saveDeviceIds(); +}; + +var deleteDeviceAlias = function() { + aliasesSelectize.removeOption($(this).data('value')); + aliasesSelectize.refreshOptions(); + saveDeviceAliases(); +}; + +var deviceIdError = function(v) { + if (!v) { + $('#device-id-label').removeClass('error'); + } else { + $('#device-id-label').addClass('error'); + $('#device-id-label .error-info').html(v); + } +}; + +var updateModeOptions = function() { + var currentMode = getCurrentMode() + , modeLabel = $('#mode li[data-value="' + currentMode + '"] a').html(); + + $('label', $('#mode').closest('.dropdown')).html(modeLabel); + + $('.mode-option').map(function() { + if ($(this).data('for').split(',').includes(currentMode)) { + $(this).show(); + } else { + $(this) + // De-select unselectable group + .removeClass('active') + .hide(); + } + }); +}; + +var parseVersion = function(v) { + var matches = v.match(/(\d+)\.(\d+)\.(\d+)(-(.*))?/); + + return { + major: matches[1], + minor: matches[2], + patch: matches[3], + revision: matches[5], + parts: [matches[1], matches[2], matches[3], matches[5]] + }; +}; + +var isNewerVersion = function(a, b) { + var va = parseVersion(a) + , vb = parseVersion(b); + + return va.parts > vb.parts; +}; + +var handleCheckForUpdates = function() { + var currentVersion = null + , latestRelease = null; + + var handleReceiveData = function() { + if (currentVersion != null) { + $('#current-version').html(currentVersion.version + " (" + currentVersion.variant + ")"); + } + + if (latestRelease != null) { + $('#latest-version .info-key').each(function() { + var value = latestRelease[$(this).data('key')]; + var prop = $(this).data('prop'); + + if (prop) { + $(this).prop(prop, value); + } else { + $(this).html(value); + } + }); + } + + if (currentVersion != null && latestRelease != null) { + $('#latest-version .status').html(''); + $('#latest-version-info').show(); + + var summary; + if (isNewerVersion(latestRelease.tag_name, currentVersion.version)) { + summary = "New version available!"; + } else { + summary = "You're on the most recent version."; + } + $('#version-summary').html(summary); + + var releaseAsset = latestRelease.assets.filter(function(x) { + return x.name.indexOf(currentVersion.variant) != -1; + }); + + if (releaseAsset.length > 0) { + $('#firmware-link').prop('href', releaseAsset[0].browser_download_url); + } + } + } + + var handleError = function(e, d) { + console.log(e); + console.log(d); + } + + $('#current-version,#latest-version .status').html(''); + $('#version-summary').html(''); + $('#latest-version-info').hide(); + + $.ajax( + '/about', + { + success: function(data) { + currentVersion = data; + handleReceiveData(); + }, + failure: handleError + } + ); + + $.ajax( + 'https://api.github.com/repos/sidoh/esp8266_milight_hub/releases/latest', + { + success: function(data) { + latestRelease = data; + handleReceiveData(); + }, + failure: handleError + } + ); +}; + +var handleStateUpdate = function(state) { + if (state.state) { + // Set without firing an event + $('input[name="status"]') + .prop('checked', state.state == 'ON') + .bootstrapToggle('destroy') + .bootstrapToggle(); + } + if (state.color) { + // Browsers don't support HSV, but saturation from HSL doesn't match + // saturation from bulb state. + var hsl = rgbToHsl(state.color.r, state.color.g, state.color.b); + var hsv = RGBtoHSV(state.color.r, state.color.g, state.color.b); + + $('input[name="saturation"]').slider('setValue', hsv.s*100); + updatePreviewColor(hsl.h*360,hsl.s*100,hsl.l*100); + } + if (state.color_temp) { + var scaledTemp + = 100*(state.color_temp - UNIT_PARAMS.minMireds) / (UNIT_PARAMS.maxMireds - UNIT_PARAMS.minMireds); + $('input[name="temperature"]').slider('setValue', scaledTemp); + } + if (state.brightness) { + var scaledBrightness = state.brightness * (100 / UNIT_PARAMS.maxBrightness); + $('input[name="level"]').slider('setValue', scaledBrightness); + } +}; + +var updatePreviewColor = function(hue, saturation, lightness) { + if (! saturation) { + saturation = 100; + } + if (! lightness) { + lightness = 50; + } + $('.hue-value-display').css({ + backgroundColor: "hsl(" + hue + "," + saturation + "%," + lightness + "%)" + }); +}; + +var stopSniffing = function() { + var elmt = $('#sniff'); + + sniffing = false; + $('i', elmt) + .removeClass('glyphicon-stop') + .addClass('glyphicon-play'); + $('span', elmt).html('Start Sniffing'); +}; + +var startSniffing = function() { + var elmt = $('#sniff'); + + sniffing = true; + $('i', elmt) + .removeClass('glyphicon-play') + .addClass('glyphicon-stop'); + $('span', elmt).html('Stop Sniffing'); + $("#traffic-sniff").show(); +}; + +var generateDropdownField = function(fieldName, options, settings) { + var s = '
'; + var inputType = settings.multiple ? 'checkbox' : 'radio'; + + Object.keys(options).forEach(function(optionValue) { + var optionLabel = options[optionValue]; + s += ''; + }); + + s += '
'; + + return s; +}; + +$(function() { + $('.radio-option').click(function() { + $(this).prev().prop('checked', true); + }); + + var hueDragging = false; + var colorUpdated = function(e) { + var x = e.pageX - $(this).offset().left + , pct = x/(1.0*$(this).width()) + , hue = Math.round(360*pct) + ; + + updatePreviewColor(hue); + + updateGroup({hue: hue}); + }; + + $('.hue-picker-inner') + .mousedown(function(e) { + hueDragging = true; + colorUpdated.call(this, e); + }) + .mouseup(function(e) { + hueDragging = false; + }) + .mouseout(function(e) { + hueDragging = false; + }) + .mousemove(function(e) { + if (hueDragging) { + colorUpdated.call(this, e); + } + }); + + $('.slider').slider(); + + $('.raw-update').change(function() { + var data = {} + , val = $(this).attr('type') == 'checkbox' ? ($(this).is(':checked') ? 'on' : 'off') : $(this).val() + ; + + data[$(this).attr('name')] = val; + updateGroup(data); + }); + + $('.command-btn').click(function() { + updateGroup({command: $(this).data('command')}); + }); + + $('.system-btn').click(function() { + sendCommand({command: $(this).data('command')}); + }); + + $('#sniff').click(function(e) { + e.preventDefault(); + + if (sniffing) { + stopSniffing(); + } else { + startSniffing(); + } + }); + + $('#traffic-sniff-close').click(function() { + stopSniffing(); + $('#traffic-sniff').hide(); + }); + + $('body').on('click', '#add-server-btn', function(e) { + e.preventDefault(); + $('#gateway-server-configs').append(gatewayServerRow('', '')); + }); + + $('#mode li').click(function(e) { + e.preventDefault(); + + $('li', $(this).parent()).removeClass('active'); + $(this).addClass('active'); + + updateModeOptions.bind(this)(); + }); + + $('body').on('click', '.remove-gateway-server', function() { + $(this).closest('tr').remove(); + }); + + for (var i = 0; i < 9; i++) { + $('.mode-dropdown').append('
  • ' + i + '
  • '); + } + + $('body').on('click', '.mode-dropdown li a', function(e) { + updateGroup({mode: $(this).data('mode-value')}); + e.preventDefault(); + return false; + }); + + var onGroupParamsChange = function(e) { + findAndSelectAlias(); + try { + refreshGroupState(); + } catch (e) { + // Skip + } + }; + + $('input[name="options"],#deviceId').change(onGroupParamsChange); + $('#mode li').click(onGroupParamsChange); + + aliasesSelectize = $('#deviceAliases').selectize({ + create: true, + allowEmptyOption: true, + openOnFocus: true, + createOnBlur: true, + render: { + option: function(data, escape) { + // Mousedown selects an option -- prevent event from bubbling up to select option + // when delete button is clicked. + var deleteBtn = $('') + .mousedown(function(e) { + e.preventDefault(); + return false; + }) + .click(function(e) { + deleteDeviceAlias.call($(this).closest('.c-selectize-item')); + e.preventDefault(); + return false; + }); + + var elmt = $('
    '); + elmt.append('' + data.text + ''); + elmt.append(deleteBtn); + + return elmt; + } + }, + onOptionAdd: function(v, item) { + if (!item.savedGroupParams) { + item.savedGroupParams = { + deviceId: getCurrentDeviceId(), + groupId: getCurrentGroupId(), + deviceType: getCurrentMode() + }; + } + + saveDeviceAliases(); + } + })[0].selectize; + + selectize = $('#deviceId').selectize({ + create: true, + sortField: 'value', + allowEmptyOption: true, + createOnBlur: true, + render: { + option: function(data, escape) { + // Mousedown selects an option -- prevent event from bubbling up to select option + // when delete button is clicked. + var deleteBtn = $('') + .mousedown(function(e) { + e.preventDefault(); + return false; + }) + .click(function(e) { + deleteDeviceId.call($(this).closest('.c-selectize-item')); + e.preventDefault(); + return false; + }); + + var elmt = $('
    '); + elmt.append('' + data.text + ''); + elmt.append(deleteBtn); + + return elmt; + } + }, + onOptionAdd: function(v, item) { + var unparsedValue = item.value; + item.value = parseInt(unparsedValue); + selectize.updateOption(unparsedValue, item); + selectize.addItem(item.value); + + saveDeviceIds(); + }, + createFilter: function(v) { + if (! v.match(/^(0x[a-fA-F0-9]{1,4}|[0-9]{1,5})$/)) { + deviceIdError("Must be an integer between 0x0000 and 0xFFFF"); + return false; + } + + var value = parseInt(v); + + if (! (0 <= v && v <= 0xFFFF)) { + deviceIdError("Must be an integer between 0x0000 and 0xFFFF"); + return false; + } + + deviceIdError(false); + + return true; + } + }); + selectize = selectize[0].selectize; + + var settings = '"; + + settings += '
    '; + + tabClass = 'active in'; + + UI_TABS.forEach(function(t) { + settings += '
    '; + tabClass = ''; + UI_FIELDS.forEach(function(k) { + if (k.tab == t.tag) { + var elmt = '
    '; + elmt += '
    '; + elmt += ''; + + if (k.help) { + elmt += '
    '; + } + + elmt += '
    '; + + if (k.type == 'group_state_fields') { + elmt += ''; + } else if (k.type == 'led_mode') { + elmt += ''; + } else if (k.type == 'option_buttons') { + elmt += generateDropdownField(k.tag, k.options, k.settings || {}); + } else { + elmt += ''; + } + elmt += '
    '; + + settings += elmt; + } + }); + settings += "
    "; + }); + + // UDP gateways tab + settings += '
    '; + settings += $('#gateway-servers-modal .modal-body').remove().html(); + settings += '
    '; + + settings += "
    "; + + $('#settings').prepend(settings); + + function saveSettings(settingsEntries) { + var entries = settingsEntries.slice(0) + + function saveBatch() { + if (entries.length > 0) { + var batch = Object.fromEntries(entries.splice(0, 30)) + $.ajax( + "/settings", + { + method: "PUT", + contentType: "application/json", + data: JSON.stringify(batch) + } + ) + .done(saveBatch) + } + } + + saveBatch() + } + + $('#settings').submit(function(e) { + e.preventDefault(); + + // Save UDP settings separately from the rest of the stuff since input is handled differently + if ($('#tab-udp-gateways').hasClass('active')) { + saveGatewayConfigs(); + } else { + var obj = $('#settings').serializeArray(); + + obj = obj + .reduce(function(a, x) { + var val = a[x.name]; + + if (! val) { + a[x.name] = x.value; + } else if (! Array.isArray(val)) { + a[x.name] = [val, x.value]; + } else { + val.push(x.value); + } + + return a; + }, + { + // 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). + obj = $.extend({group_state_fields: []}, obj); + saveSettings(Object.entries(obj)) + } + + $('#settings-modal').modal('hide'); + + return false; + }); + + $('#gateway-server-form').submit(function(e) { + saveGatewayConfigs(); + e.preventDefault(); + $('#gateway-servers-modal').modal('hide'); + return false; + }); + + $('.field-help').each(function() { + var elmt = $('') + .addClass('glyphicon glyphicon-question-sign') + .tooltip({ + placement: 'top', + title: $(this).data('help-text'), + container: 'body' + }); + $(this).append(elmt); + }); + + $('#updates-btn').click(handleCheckForUpdates); + + 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 diff --git a/web/src/js/theme-select.js b/web/src/js/theme-select.js new file mode 100644 index 0000000..fe221fb --- /dev/null +++ b/web/src/js/theme-select.js @@ -0,0 +1,81 @@ +// Change current theme +// Adapted from : https://wdtz.org/bootswatch-theme-selector.html + +$.getJSON("https://bootswatch.com/api/3.json", function (data) { + var themes = data.themes; + var list = $("#theme-list"); + themes.forEach(function(value, index){ + var elmt = '
  • '+value.name+'
  • ' + list.append(elmt); + }); +}); + +var supports_storage = supports_html5_storage(); + +if (supports_storage) { + var theme = localStorage.theme; + if ( typeof theme != 'undefined' ) { + console.log("Changing theme to " + theme); + $('link[title="main"]').attr('href', localStorage.theme_url) + setTimeout(function() { + $('#theme-label').html("Theme : " + theme); + }, 1000) + newThemeLoaded(); + } +} + +// New theme selected +jQuery(function($){ + $('body').on('click', '.change-style-menu-item', function() { + var theme_name = $(this).html(); + $('#theme-label').html("Theme : " + theme_name); + console.log("Theme set to " + theme_name); + var url_theme = ""; + if ( theme_name === "Bootstrap (Default)" ) { + url_theme = "https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css"; + } else { + url_theme = $(this).attr('url'); + } + if (supports_storage) { + // Save into the local database the selected theme + localStorage.theme = theme_name; + localStorage.theme_url = url_theme; + } + console.log("URL theme : " + url_theme); + $('link[title="main"]').attr('href', url_theme); + newThemeLoaded(); + }); +}); + +// Local storage available ? +function supports_html5_storage(){ + try { + return 'localStorage' in window && window['localStorage'] !== null; + } catch (e) { + return false; + } +} + +function newThemeLoaded() { + var btnCssHeight = $("#btnHeight").css("height"); + var interval = setInterval(check, 500); + var times = 0; + var dbCheck = 0; + function check() { + var newCssHeight = $("#btnHeight").css("height"); + var raw = newCssHeight.replace("px", "") + if ((btnCssHeight != "22px" && newCssHeight != "22px") && btnCssHeight != newCssHeight) { + btnCssHeight = newCssHeight + $(".plus-minus-group .title").css("height", btnCssHeight); + $('input[name="status"]') + .bootstrapToggle('destroy') + .bootstrapToggle({ + height: raw + }); + } if (times == 10) { + clearInterval(interval); + } else { + times++; + } + } +} \ No newline at end of file