First commit

This commit is contained in:
Carsten Schmiemann 2021-01-15 22:49:01 +01:00
commit 9ed0e801b7
165 changed files with 21035 additions and 0 deletions

45
.build_web.py Normal file
View file

@ -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()

31
.get_version.py Executable file
View file

@ -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:])))

41
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

@ -0,0 +1,41 @@
---
name: Bug report
about: Report an issue or unexpected behavior
title: ''
labels: bug
assignees: ''
---
### Describe the bug
<!-- A clear and concise description of what the bug is. -->
### Steps to reproduce
<!-- If you're reporting a bug, please provide a way to reliably reproduce the issue. -->
### Expected behavior
<!-- A clear and concise description of what you expected to happen. -->
### Setup information
#### Firmware version
<!-- e.g. 1.10.0-rc.1, etc. include all versions you've tried -->
#### Output of http://milight-hub.local/about
```json
"... /about output. put between the ```s"
```
#### Output of http://milight-hub.local/settings
<!-- MAKE SURE TO SENSOR ANY PASSWORDS! -->
```json
"... /settings output. put between the ```s"
```
### Additional context

View file

@ -0,0 +1,10 @@
---
name: Feature request or general question
about: Suggest a new idea or ask a question
title: ''
labels: ''
assignees: ''
---

View file

@ -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: ''
---
<!--
-- !!! PLEASE READ THIS FIRST !!!
--
-- Before opening an issue for a setup-related question, please make sure you've run through
-- the troubleshooting guide, found here:
--
-- https://github.com/sidoh/esp8266_milight_hub/wiki/Troubleshooting
--
-- If you're still having trouble, please make sure you provide all of the requested information below!
-->
### What is the model number of the device you're trying to control?
<!--
-- Product catalog here:
-- https://github.com/sidoh/esp8266_milight_hub/files/1379131/MiLight-ProductCatalog-2017.pdf
-->
### 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?
<!-- Please provide the model number of the remote if the device you're trying to control works with a remote. -->
### Output of http://milight-hub.local/about and http://milight-hub.local/settings
<!-- MAKE SURE TO SENSOR ANY PASSWORDS IN /settings !!! -->
#### /about
```json
"... /about output. put between the ```s"
```
#### /settings
```json
"... /settings output. put between the ```s"
```

17
.gitignore vendored Normal file
View file

@ -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

72
.prepare_docs Executable file
View file

@ -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/<branch>/... - 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"

31
.prepare_release Executable file
View file

@ -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

41
.travis.yml Normal file
View file

@ -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

21
LICENSE Normal file
View file

@ -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.

325
README.md Normal file
View file

@ -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|<ol><li>FUT014</li><li>FUT016</li><li>FUT103</li>|
|FUT005<br/>FUT006<br/>FUT007</li></ol>|CCT|<ol><li>FUT011</li><li>FUT017</li><li>FUT019</li></ol>|
|FUT098|RGB|Most RGB LED Strip Controlers|
|FUT020|RGB|Some other RGB LED strip controllers|
|FUT092|RGB/CCT|<ol><li>FUT012</li><li>FUT013</li><li>FUT014</li><li>FUT015</li><li>FUT103</li><li>FUT104</li><li>FUT105</li><li>Many RGB/CCT LED Strip Controllers</li></ol>|
|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.
<img src="https://user-images.githubusercontent.com/40266/47967518-67556f00-e05e-11e8-857d-1173a9da955c.png" align="left" width="32%" />
<img src="https://user-images.githubusercontent.com/40266/47967520-691f3280-e05e-11e8-838a-83706df2edb0.png" align="left" width="22%" />
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://<ip_of_esp>`, 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/)

2
dist/index.html.gz.h vendored Normal file

File diff suppressed because one or more lines are too long

73
docs/gh-pages/index.html Normal file
View file

@ -0,0 +1,73 @@
<!DOCTYPE html>
<!--[if lt IE 7]> <html class="no-js lt-ie9 lt-ie8 lt-ie7"> <![endif]-->
<!--[if IE 7]> <html class="no-js lt-ie9 lt-ie8"> <![endif]-->
<!--[if IE 8]> <html class="no-js lt-ie9"> <![endif]-->
<!--[if gt IE 8]><!--> <html class="no-js"> <!--<![endif]-->
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title></title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width">
<style>
thead th {
text-align: left;
border-bottom: 1px solid #000;
}
th, td {
padding: 0.5em 1em 0 1em;
font-family: 'Courier New', Courier, monospace;
}
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script>
<script>
var generateDocsRow = function(x) {
var html = '<tr><td>' + x + '</td>';
html += '<td><a href="branches/' + x + '">Docs</a></td>';
html += '<td><a href="branches/' + x + '/openapi.yaml">OpenAPI spec</a></td></tr>';
return html;
};
$(function() {
$.getJSON(
'branches.json',
function(data) {
var list = $('#version-list');
var html = '<thead><th>Version</th><th></th><th></th></thead><tbody>';
html += generateDocsRow('latest');
data.forEach(function(x) {
if (x != 'latest') {
html += generateDocsRow(x);
}
});
html += '</tbody>'
list.append(html);
$('#loading').hide();
},
function(err) {
console.log(err);
}
);
});
</script>
</head>
<body>
<!--[if lt IE 7]>
<p class="chromeframe">You are using an <strong>outdated</strong> browser. Please <a href="http://browsehappy.com/">upgrade your browser</a> or <a href="http://www.google.com/chromeframe/?redirect=true">activate Google Chrome Frame</a> to improve your experience.</p>
<![endif]-->
<h2>MiLight Hub REST API Documentation</h2>
<hr />
<table id="version-list"></table>
<i id="loading">Loading...</i>
</body>
</html>

1057
docs/openapi.yaml Normal file

File diff suppressed because it is too large Load diff

View file

@ -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 <stddef.h>
template<class T>
struct ListNode {
T data;
ListNode<T> *next;
ListNode<T> *prev;
};
template <typename T>
class LinkedList {
protected:
size_t _size;
ListNode<T> *root;
ListNode<T> *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<T>* 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<T>* getNode(int index);
virtual void spliceToFront(ListNode<T>* node);
ListNode<T>* getHead() { return root; }
T getLast() const { return last == NULL ? T() : last->data; }
};
template<typename T>
void LinkedList<T>::spliceToFront(ListNode<T>* 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<typename T>
LinkedList<T>::LinkedList()
{
root=NULL;
last=NULL;
_size=0;
}
// Clear Nodes and free Memory
template<typename T>
LinkedList<T>::~LinkedList()
{
ListNode<T>* tmp;
while(root!=NULL)
{
tmp=root;
root=root->next;
delete tmp;
}
last = NULL;
_size=0;
}
/*
Actualy "logic" coding
*/
template<typename T>
ListNode<T>* LinkedList<T>::getNode(int index){
int _pos = 0;
ListNode<T>* current = root;
while(_pos < index && current){
current = current->next;
_pos++;
}
return false;
}
template<typename T>
size_t LinkedList<T>::size() const{
return _size;
}
template<typename T>
bool LinkedList<T>::add(int index, T _t){
if(index >= _size)
return add(_t);
if(index == 0)
return unshift(_t);
ListNode<T> *tmp = new ListNode<T>(),
*_prev = getNode(index-1);
tmp->data = _t;
tmp->next = _prev->next;
_prev->next = tmp;
_size++;
return true;
}
template<typename T>
bool LinkedList<T>::add(T _t){
ListNode<T> *tmp = new ListNode<T>();
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<typename T>
bool LinkedList<T>::unshift(T _t){
if(_size == 0)
return add(_t);
ListNode<T> *tmp = new ListNode<T>();
tmp->next = root;
root->prev = tmp;
tmp->data = _t;
root = tmp;
_size++;
return true;
}
template<typename T>
bool LinkedList<T>::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<typename T>
T LinkedList<T>::pop(){
if(_size <= 0)
return T();
if(_size >= 2){
ListNode<T> *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<typename T>
T LinkedList<T>::shift(){
if(_size <= 0)
return T();
if(_size > 1){
ListNode<T> *_next = root->next;
T ret = root->data;
delete(root);
root = _next;
_size --;
return ret;
}else{
// Only one left, then pop()
return pop();
}
}
template<typename T>
void LinkedList<T>::remove(ListNode<T>* node){
if (node == root) {
shift();
} else if (node == last) {
pop();
} else {
ListNode<T>* prev = node->prev;
ListNode<T>* next = node->next;
prev->next = next;
next->prev = prev;
delete node;
--_size;
}
}
template<typename T>
T LinkedList<T>::remove(int index){
if (index < 0 || index >= _size)
{
return T();
}
if(index == 0)
return shift();
if (index == _size-1)
{
return pop();
}
ListNode<T> *tmp = getNode(index - 1);
ListNode<T> *toDelete = tmp->next;
T ret = toDelete->data;
tmp->next = tmp->next->next;
delete(toDelete);
_size--;
return ret;
}
template<typename T>
T LinkedList<T>::get(int index){
ListNode<T> *tmp = getNode(index);
return (tmp ? tmp->data : T());
}
template<typename T>
void LinkedList<T>::clear(){
while(size() > 0)
shift();
}
#endif

73
lib/Helpers/IntParsing.h Normal file
View file

@ -0,0 +1,73 @@
#ifndef _INTPARSING_H
#define _INTPARSING_H
#include <Arduino.h>
template <typename T>
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 <typename T>
const T strToHex(const String& s) {
return strToHex<T>(s.c_str(), s.length());
}
template <typename T>
const T parseInt(const String& s) {
if (s.startsWith("0x")) {
return strToHex<T>(s.substring(2));
} else {
return s.toInt();
}
}
template <typename T>
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<T>(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<size_t>(p - buffer) < (maxLen - 3); i++) {
p += sprintf(p, "%02X", bytes[i]);
if (i < (len - 1)) {
p += sprintf(p, " ");
}
}
}
};
#endif

51
lib/Helpers/JsonHelpers.h Normal file
View file

@ -0,0 +1,51 @@
#include <ArduinoJson.h>
#include <vector>
#include <functional>
#include <algorithm>
#ifndef _JSON_HELPERS_H
#define _JSON_HELPERS_H
class JsonHelpers {
public:
template<typename T>
static void copyFrom(JsonArray arr, std::vector<T> vec) {
for (typename std::vector<T>::const_iterator it = vec.begin(); it != vec.end(); ++it) {
arr.add(*it);
}
}
template<typename T>
static void copyTo(JsonArray arr, std::vector<T> vec) {
for (size_t i = 0; i < arr.size(); ++i) {
JsonVariant val = arr[i];
vec.push_back(val.as<T>());
}
}
template<typename T, typename StrType>
static std::vector<T> jsonArrToVector(JsonArray& arr, std::function<T (const StrType)> converter, const bool unique = true) {
std::vector<T> 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<typename T, typename StrType>
static void vectorToJsonArr(JsonArray& arr, const std::vector<T>& vec, std::function<StrType (const T&)> converter) {
for (typename std::vector<T>::const_iterator it = vec.begin(); it != vec.end(); ++it) {
arr.add(converter(*it));
}
}
};
#endif

11
lib/Helpers/Size.h Normal file
View file

@ -0,0 +1,11 @@
#include <Arduino.h>
#ifndef _SIZE_H
#define _SIZE_H
template<typename T, size_t sz>
size_t size(T(&)[sz]) {
return sz;
}
#endif

32
lib/Helpers/Units.h Normal file
View file

@ -0,0 +1,32 @@
#include <Arduino.h>
#include <inttypes.h>
#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 <typename T, typename V>
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<uint16_t, uint16_t>(
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<uint16_t, uint16_t>(value, (COLOR_TEMP_MAX_MIREDS - COLOR_TEMP_MIN_MIREDS), maxValue);
return COLOR_TEMP_MIN_MIREDS + scaled;
}
};
#endif

225
lib/LEDStatus/LEDStatus.cpp Normal file
View file

@ -0,0 +1,225 @@
#include <LEDStatus.h>
// 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;
}

49
lib/LEDStatus/LEDStatus.h Normal file
View file

@ -0,0 +1,49 @@
#include <Arduino.h>
#include <string.h>
#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

View file

@ -0,0 +1,59 @@
#include <BulbStateUpdater.h>
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<JsonObject>();
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));
}

View file

@ -0,0 +1,35 @@
/**
* Enqueues updated bulb states and flushes them at the configured interval.
*/
#include <stddef.h>
#include <MqttClient.h>
#include <CircularBuffer.h>
#include <Settings.h>
#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<BulbId, MILIGHT_MAX_STALE_MQTT_GROUPS> staleGroups;
unsigned long lastFlush;
unsigned long lastQueue;
bool enabled;
inline void flushGroup(BulbId bulbId, GroupState& state);
inline bool canFlush() const;
};
#endif

View file

@ -0,0 +1,177 @@
#include <HomeAssistantDiscoveryClient.h>
#include <MiLightCommands.h>
HomeAssistantDiscoveryClient::HomeAssistantDiscoveryClient(Settings& settings, MqttClient* mqttClient)
: settings(settings)
, mqttClient(mqttClient)
{ }
void HomeAssistantDiscoveryClient::sendDiscoverableDevices(const std::map<String, BulbId>& 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<uint32_t, BulbId>& 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:
// <discovery_prefix>/<component>/[<node_id>/]<object_id>/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));
}
}

View file

@ -0,0 +1,24 @@
#pragma once
#include <BulbId.h>
#include <MqttClient.h>
#include <map>
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<String, BulbId>& aliases);
void removeOldDevices(const std::map<uint32_t, BulbId>& 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);
};

320
lib/MQTT/MqttClient.cpp Normal file
View file

@ -0,0 +1,320 @@
#include <stddef.h>
#include <MqttClient.h>
#include <TokenIterator.h>
#include <UrlTokenBindings.h>
#include <IntParsing.h>
#include <ArduinoJson.h>
#include <WiFiClient.h>
#include <MiLightRadioConfig.h>
#include <AboutHelper.h>
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<const uint8_t*>(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<size_t>(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<uint16_t>(tokenBindings.get(GroupStateFieldNames::DEVICE_ID));
} else if (tokenBindings.hasBinding("hex_device_id")) {
deviceId = parseInt<uint16_t>(tokenBindings.get("hex_device_id"));
} else if (tokenBindings.hasBinding("dec_device_id")) {
deviceId = parseInt<uint16_t>(tokenBindings.get("dec_device_id"));
}
if (tokenBindings.hasBinding(GroupStateFieldNames::GROUP_ID)) {
groupId = parseInt<uint16_t>(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<JsonObject>();
#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;
}
}

61
lib/MQTT/MqttClient.h Normal file
View file

@ -0,0 +1,61 @@
#include <MiLightClient.h>
#include <Settings.h>
#include <PubSubClient.h>
#include <WiFiClient.h>
#include <MiLightRadioConfig.h>
#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<void()>;
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

View file

@ -0,0 +1,225 @@
#include <CctPacketFormatter.h>
#include <MiLightCommands.h>
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);
}

View file

@ -0,0 +1,56 @@
#include <PacketFormatter.h>
#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

View file

@ -0,0 +1,88 @@
#include <FUT020PacketFormatter.h>
#include <Units.h>
void FUT020PacketFormatter::updateColorRaw(uint8_t color) {
command(static_cast<uint8_t>(FUT020Command::COLOR), color);
}
void FUT020PacketFormatter::updateHue(uint16_t hue) {
uint16_t remapped = Units::rescale<uint16_t, uint16_t>(hue, 255.0, 360.0);
remapped = (remapped + 0xB0) % 0x100;
updateColorRaw(remapped);
}
void FUT020PacketFormatter::updateColorWhite() {
command(static_cast<uint8_t>(FUT020Command::COLOR_WHITE_TOGGLE), 0);
}
void FUT020PacketFormatter::nextMode() {
command(static_cast<uint8_t>(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<uint8_t>(FUT020Command::BRIGHTNESS_UP), 0);
}
void FUT020PacketFormatter::decreaseBrightness() {
command(static_cast<uint8_t>(FUT020Command::BRIGHTNESS_DOWN), 0);
}
void FUT020PacketFormatter::updateStatus(MiLightStatus status, uint8_t groupId) {
command(static_cast<uint8_t>(FUT020Command::ON_OFF), 0);
}
BulbId FUT020PacketFormatter::parsePacket(const uint8_t* packet, JsonObject result) {
FUT020Command command = static_cast<FUT020Command>(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<uint16_t, uint16_t>(packet[FUT02xPacketFormatter::FUT02X_ARGUMENT_INDEX], 360.0, 255.0);
remappedColor = (remappedColor + 113) % 360;
result[GroupStateFieldNames::HUE] = remappedColor;
break;
}
return bulbId;
}

View file

@ -0,0 +1,30 @@
#include <FUT02xPacketFormatter.h>
#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;
};

View file

@ -0,0 +1,50 @@
#include <FUT02xPacketFormatter.h>
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]);
}

View file

@ -0,0 +1,24 @@
#include <PacketFormatter.h>
#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;
};

View file

@ -0,0 +1,156 @@
#include <FUT089PacketFormatter.h>
#include <V2RFEncoding.h>
#include <Units.h>
#include <MiLightCommands.h>
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<uint16_t, uint16_t>(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<uint8_t, uint8_t>(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;
}

View file

@ -0,0 +1,45 @@
#include <V2PacketFormatter.h>
#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

View file

@ -0,0 +1,58 @@
#include <FUT091PacketFormatter.h>
#include <V2RFEncoding.h>
#include <Units.h>
#include <MiLightCommands.h>
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<uint8_t>(FUT091Command::BRIGHTNESS), V2PacketFormatter::tov2scale(value, BRIGHTNESS_SCALE_MAX, 2));
}
void FUT091PacketFormatter::updateTemperature(uint8_t value) {
command(static_cast<uint8_t>(FUT091Command::KELVIN), V2PacketFormatter::tov2scale(value, KELVIN_SCALE_MAX, 2, false));
}
void FUT091PacketFormatter::enableNightMode() {
uint8_t arg = groupCommandArg(OFF, groupId);
command(static_cast<uint8_t>(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<uint8_t, uint8_t>(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;
}

View file

@ -0,0 +1,25 @@
#include <V2PacketFormatter.h>
#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

View file

@ -0,0 +1,669 @@
#include <MiLightClient.h>
#include <MiLightRadioConfig.h>
#include <Arduino.h>
#include <RGBConverter.h>
#include <Units.h>
#include <TokenIterator.h>
#include <ParsedColor.h>
#include <MiLightCommands.h>
#include <functional>
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<const char*, std::function<void(MiLightClient*, JsonVariant)>, 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<uint16_t, uint16_t>(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<float>()) {
transition = jsonTransition.as<float>();
} else if (jsonTransition.is<size_t>()) {
transition = jsonTransition.as<size_t>();
} 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>()) {
JsonObject cmdObj = command.as<JsonObject>();
cmdName = cmdObj[GroupStateFieldNames::COMMAND].as<const char*>();
args = cmdObj["args"];
} else if (command.is<const char*>()) {
cmdName = command.as<const char*>();
}
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<Transition::Builder> 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<Transition::Builder> 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<uint16_t>(),
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;
}

147
lib/MiLight/MiLightClient.h Normal file
View file

@ -0,0 +1,147 @@
#include <functional>
#include <Arduino.h>
#include <MiLightRadio.h>
#include <MiLightRadioFactory.h>
#include <MiLightRemoteConfig.h>
#include <Settings.h>
#include <GroupStateStore.h>
#include <PacketSender.h>
#include <TransitionController.h>
#include <cstring>
#include <map>
#include <set>
#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<const __FlashStringHelper*>(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<void(void)> 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<MiLightRadio> switchRadio(size_t radioIx);
std::shared_ptr<MiLightRadio> 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<const char*, std::function<void(MiLightClient*, JsonVariant)>, cmp_str> FIELD_SETTERS;
static const char* FIELD_ORDERINGS[];
RadioSwitchboard& radioSwitchboard;
std::vector<std::shared_ptr<MiLightRadio>> radios;
std::shared_ptr<MiLightRadio> 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

View file

@ -0,0 +1,108 @@
#include <MiLightRemoteConfig.h>
#include <MiLightRemoteType.h>
/**
* 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
);

View file

@ -0,0 +1,53 @@
#include <MiLightRadioConfig.h>
#include <PacketFormatter.h>
#include <RgbwPacketFormatter.h>
#include <RgbPacketFormatter.h>
#include <RgbCctPacketFormatter.h>
#include <CctPacketFormatter.h>
#include <FUT089PacketFormatter.h>
#include <FUT091PacketFormatter.h>
#include <FUT020PacketFormatter.h>
#include <PacketFormatter.h>
#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

View file

@ -0,0 +1,188 @@
#include <PacketFormatter.h>
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);
}

View file

@ -0,0 +1,120 @@
#include <Arduino.h>
#include <inttypes.h>
#include <functional>
#include <MiLightRemoteType.h>
#include <ArduinoJson.h>
#include <GroupState.h>
#include <GroupStateStore.h>
#include <Settings.h>
#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

View file

@ -0,0 +1,42 @@
#include <PacketQueue.h>
PacketQueue::PacketQueue()
: droppedPackets(0)
{ }
void PacketQueue::push(const uint8_t* packet, const MiLightRemoteConfig* remoteConfig, const size_t repeatsOverride) {
std::shared_ptr<QueuedPacket> 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<QueuedPacket> PacketQueue::pop() {
return queue.shift();
}
std::shared_ptr<QueuedPacket> PacketQueue::checkoutPacket() {
if (queue.size() == MILIGHT_MAX_QUEUED_PACKETS) {
++droppedPackets;
return queue.getLast();
} else {
std::shared_ptr<QueuedPacket> packet = std::make_shared<QueuedPacket>();
queue.add(packet);
return packet;
}
}
void PacketQueue::checkinPacket(std::shared_ptr<QueuedPacket> packet) {
}
size_t PacketQueue::size() const {
return queue.size();
}

36
lib/MiLight/PacketQueue.h Normal file
View file

@ -0,0 +1,36 @@
#pragma once
#include <memory>
#include <CircularBuffer.h>
#include <MiLightRadioConfig.h>
#include <MiLightRemoteConfig.h>
#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<QueuedPacket> pop();
bool isEmpty() const;
size_t size() const;
size_t getDroppedPacketCount() const;
private:
size_t droppedPackets;
std::shared_ptr<QueuedPacket> checkoutPacket();
void checkinPacket(std::shared_ptr<QueuedPacket> packet);
LinkedList<std::shared_ptr<QueuedPacket>> queue;
};

View file

@ -0,0 +1,125 @@
#include <PacketSender.h>
#include <MiLightRadioConfig.h>
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<int>(this->currentResendCount) + delta;
if (signedResends < static_cast<int>(settings.packetRepeatMinimum)) {
signedResends = settings.packetRepeatMinimum;
} else if (signedResends > static_cast<int>(settings.packetRepeats)) {
signedResends = settings.packetRepeats;
}
this->currentResendCount = signedResends;
this->lastSend = now;
}

View file

@ -0,0 +1,74 @@
#pragma once
#include <MiLightRadioFactory.h>
#include <MiLightRemoteConfig.h>
#include <PacketQueue.h>
#include <RadioSwitchboard.h>
class PacketSender {
public:
typedef std::function<void(uint8_t* packet, const MiLightRemoteConfig& config)> 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<QueuedPacket> 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();
};

View file

@ -0,0 +1,74 @@
#include <RadioSwitchboard.h>
RadioSwitchboard::RadioSwitchboard(
std::shared_ptr<MiLightRadioFactory> radioFactory,
GroupStateStore* stateStore,
Settings& settings
) {
for (size_t i = 0; i < MiLightRadioConfig::NUM_CONFIGS; i++) {
std::shared_ptr<MiLightRadio> 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<MiLightRadio> 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<MiLightRadio> RadioSwitchboard::switchRadio(const MiLightRemoteConfig* remote) {
std::shared_ptr<MiLightRadio> 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();
}

View file

@ -0,0 +1,27 @@
#pragma once
#include <MiLightRadio.h>
#include <MiLightRemoteConfig.h>
#include <MiLightRadioConfig.h>
#include <MiLightRadioFactory.h>
class RadioSwitchboard {
public:
RadioSwitchboard(
std::shared_ptr<MiLightRadioFactory> radioFactory,
GroupStateStore* stateStore,
Settings& settings
);
std::shared_ptr<MiLightRadio> switchRadio(const MiLightRemoteConfig* remote);
std::shared_ptr<MiLightRadio> 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<std::shared_ptr<MiLightRadio>> radios;
std::shared_ptr<MiLightRadio> currentRadio;
};

View file

@ -0,0 +1,159 @@
#include <RgbCctPacketFormatter.h>
#include <V2RFEncoding.h>
#include <Units.h>
#include <MiLightCommands.h>
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<uint16_t, uint16_t>(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<uint8_t, uint8_t>(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;
}

View file

@ -0,0 +1,60 @@
#include <V2PacketFormatter.h>
#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

View file

@ -0,0 +1,129 @@
#include <RgbPacketFormatter.h>
#include <Units.h>
#include <MiLightCommands.h>
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<uint16_t, uint16_t>(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]);
}

View file

@ -0,0 +1,47 @@
#include <PacketFormatter.h>
#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

View file

@ -0,0 +1,160 @@
#include <RgbwPacketFormatter.h>
#include <Units.h>
#include <MiLightCommands.h>
#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<uint8_t, uint8_t>(brightness, 255, 25);
} else if (command == RGBW_COLOR) {
uint16_t remappedColor = Units::rescale<uint16_t, uint16_t>(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);
}

View file

@ -0,0 +1,82 @@
#include <PacketFormatter.h>
#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

View file

@ -0,0 +1,144 @@
#include <V2PacketFormatter.h>
#include <V2RFEncoding.h>
#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;
}

View file

@ -0,0 +1,55 @@
#include <inttypes.h>
#include <PacketFormatter.h>
#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

View file

@ -0,0 +1,66 @@
#include <V2RFEncoding.h>
#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));
}

View file

@ -0,0 +1,21 @@
#include <Arduino.h>
#include <inttypes.h>
#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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,225 @@
#include <stddef.h>
#include <inttypes.h>
#include <MiLightRemoteType.h>
#include <MiLightStatus.h>
#include <MiLightRadioConfig.h>
#include <GroupStateField.h>
#include <ArduinoJson.h>
#include <BulbId.h>
#include <ParsedColor.h>
#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<GroupStateField>& 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

View file

@ -0,0 +1,72 @@
#include <GroupStateCache.h>
GroupStateCache::GroupStateCache(const size_t maxSize)
: maxSize(maxSize)
{ }
GroupStateCache::~GroupStateCache() {
ListNode<GroupCacheNode*>* 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<GroupCacheNode*>* GroupStateCache::getHead() {
return cache.getHead();
}
GroupState* GroupStateCache::getInternal(const BulbId& id) {
ListNode<GroupCacheNode*>* 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;
}

View file

@ -0,0 +1,34 @@
#include <GroupState.h>
#include <LinkedList.h>
#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<GroupCacheNode*>* getHead();
private:
LinkedList<GroupCacheNode*> cache;
const size_t maxSize;
GroupState* getInternal(const BulbId& id);
};
#endif

View file

@ -0,0 +1,40 @@
#include <GroupStatePersistence.h>
#include <FS.h>
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);
}

View file

@ -0,0 +1,18 @@
#include <GroupState.h>
#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

View file

@ -0,0 +1,150 @@
#include <GroupStateStore.h>
#include <MiLightRemoteConfig.h>
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<GroupCacheNode*>* 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;
}
}
}

View file

@ -0,0 +1,53 @@
#include <GroupState.h>
#include <GroupStateCache.h>
#include <GroupStatePersistence.h>
#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<BulbId> evictedIds;
const size_t flushRate;
unsigned long lastFlush;
void trackEviction();
};
#endif

View file

@ -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 <SPI.h>
/**************************************************************************/
// 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;
}

View file

@ -0,0 +1,90 @@
#ifdef ARDUINO
#include "Arduino.h"
#else
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#endif
#include <MiLightRadioConfig.h>
#include <MiLightRadio.h>
//#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

30
lib/Radio/MiLightRadio.h Normal file
View file

@ -0,0 +1,30 @@
#ifdef ARDUINO
#include "Arduino.h"
#else
#include <stdint.h>
#include <stdlib.h>
#endif
#include <MiLightRadioConfig.h>
#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

View file

@ -0,0 +1,9 @@
#include <MiLightRadioConfig.h>
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
};

View file

@ -0,0 +1,83 @@
#include <Arduino.h>
#include <MiLightRemoteType.h>
#include <Size.h>
#include <RadioUtils.h>
#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

View file

@ -0,0 +1,48 @@
#include <MiLightRadioFactory.h>
std::shared_ptr<MiLightRadioFactory> MiLightRadioFactory::fromSettings(const Settings& settings) {
switch (settings.radioInterfaceType) {
case nRF24:
return std::make_shared<NRF24Factory>(
settings.csnPin,
settings.cePin,
settings.rf24PowerLevel,
settings.rf24Channels,
settings.rf24ListenChannel
);
case LT8900:
return std::make_shared<LT8900Factory>(settings.csnPin, settings.resetPin, settings.cePin);
default:
return NULL;
}
}
NRF24Factory::NRF24Factory(
uint8_t csnPin,
uint8_t cePin,
RF24PowerLevel rF24PowerLevel,
const std::vector<RF24Channel>& channels,
RF24Channel listenChannel
)
: rf24(RF24(cePin, csnPin)),
channels(channels),
listenChannel(listenChannel)
{
rf24.setPALevel(RF24PowerLevelHelpers::rf24ValueFromValue(rF24PowerLevel));
}
std::shared_ptr<MiLightRadio> NRF24Factory::create(const MiLightRadioConfig &config) {
return std::make_shared<NRF24MiLightRadio>(rf24, config, channels, listenChannel);
}
LT8900Factory::LT8900Factory(uint8_t csPin, uint8_t resetPin, uint8_t pktFlag)
: _csPin(csPin),
_resetPin(resetPin),
_pktFlag(pktFlag)
{ }
std::shared_ptr<MiLightRadio> LT8900Factory::create(const MiLightRadioConfig& config) {
return std::make_shared<LT8900MiLightRadio>(_csPin, _resetPin, _pktFlag, config);
}

View file

@ -0,0 +1,62 @@
#include <RF24.h>
#include <PL1167_nRF24.h>
#include <MiLightRadioConfig.h>
#include <MiLightRadio.h>
#include <NRF24MiLightRadio.h>
#include <LT8900MiLightRadio.h>
#include <RF24PowerLevel.h>
#include <RF24Channel.h>
#include <Settings.h>
#include <vector>
#include <memory>
#ifndef _MILIGHT_RADIO_FACTORY_H
#define _MILIGHT_RADIO_FACTORY_H
class MiLightRadioFactory {
public:
virtual ~MiLightRadioFactory() { };
virtual std::shared_ptr<MiLightRadio> create(const MiLightRadioConfig& config) = 0;
static std::shared_ptr<MiLightRadioFactory> fromSettings(const Settings& settings);
};
class NRF24Factory : public MiLightRadioFactory {
public:
NRF24Factory(
uint8_t cePin,
uint8_t csnPin,
RF24PowerLevel rF24PowerLevel,
const std::vector<RF24Channel>& channels,
RF24Channel listenChannel
);
virtual std::shared_ptr<MiLightRadio> create(const MiLightRadioConfig& config);
protected:
RF24 rf24;
const std::vector<RF24Channel>& channels;
const RF24Channel listenChannel;
};
class LT8900Factory : public MiLightRadioFactory {
public:
LT8900Factory(uint8_t csPin, uint8_t resetPin, uint8_t pktFlag);
virtual std::shared_ptr<MiLightRadio> create(const MiLightRadioConfig& config);
protected:
uint8_t _csPin;
uint8_t _resetPin;
uint8_t _pktFlag;
};
#endif

View file

@ -0,0 +1,139 @@
// Adapated from code from henryk
#include <PL1167_nRF24.h>
#include <NRF24MiLightRadio.h>
#define PACKET_ID(packet, packet_length) ( (packet[1] << 8) | packet[packet_length - 1] )
NRF24MiLightRadio::NRF24MiLightRadio(
RF24& rf24,
const MiLightRadioConfig& config,
const std::vector<RF24Channel>& channels,
RF24Channel listenChannel
)
: channels(channels),
listenChannelIx(static_cast<size_t>(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<RF24Channel>::const_iterator it = channels.begin(); it != channels.end(); ++it) {
size_t channelIx = static_cast<uint8_t>(*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;
}

View file

@ -0,0 +1,53 @@
#ifdef ARDUINO
#include "Arduino.h"
#else
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#endif
#include <RF24.h>
#include <PL1167_nRF24.h>
#include <MiLightRadioConfig.h>
#include <MiLightRadio.h>
#include <RF24Channel.h>
#include <vector>
#ifndef _NRF24_MILIGHT_RADIO_H_
#define _NRF24_MILIGHT_RADIO_H_
class NRF24MiLightRadio : public MiLightRadio {
public:
NRF24MiLightRadio(
RF24& rf,
const MiLightRadioConfig& config,
const std::vector<RF24Channel>& 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<RF24Channel>& 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

261
lib/Radio/PL1167_nRF24.cpp Normal file
View file

@ -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 <RadioUtils.h>
#include <MiLightRadioConfig.h>
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;
}

56
lib/Radio/PL1167_nRF24.h Normal file
View file

@ -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_ */

18
lib/Radio/RadioUtils.cpp Normal file
View file

@ -0,0 +1,18 @@
#include <RadioUtils.h>
#include <stdint.h>
#include <stddef.h>
#include <Arduino.h>
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;
}

8
lib/Radio/RadioUtils.h Normal file
View file

@ -0,0 +1,8 @@
#pragma once
#include <stdint.h>
/**
* Reverse the bits of a given byte
*/
uint8_t reverseBits(uint8_t byte);

View file

@ -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 <functional>
#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"
"<?xml version=\"1.0\"?>"
"<root xmlns=\"urn:schemas-upnp-org:device-1-0\">"
"<specVersion>"
"<major>1</major>"
"<minor>0</minor>"
"</specVersion>"
"<URLBase>http://%u.%u.%u.%u:%u/</URLBase>" // WiFi.localIP(), _port
"<device>"
"<deviceType>%s</deviceType>"
"<friendlyName>%s</friendlyName>"
"<presentationURL>%s</presentationURL>"
"<serialNumber>%s</serialNumber>"
"<modelName>%s</modelName>"
"<modelNumber>%s</modelNumber>"
"<modelURL>%s</modelURL>"
"<manufacturer>%s</manufacturer>"
"<manufacturerURL>%s</manufacturerURL>"
"<UDN>uuid:%s</UDN>"
"</device>"
// "<iconList>"
// "<icon>"
// "<mimetype>image/png</mimetype>"
// "<height>48</height>"
// "<width>48</width>"
// "<depth>24</depth>"
// "<url>icon48.png</url>"
// "</icon>"
// "<icon>"
// "<mimetype>image/png</mimetype>"
// "<height>120</height>"
// "<width>120</width>"
// "<depth>24</depth>"
// "<url>icon120.png</url>"
// "</icon>"
// "</iconList>"
"</root>\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<ETSTimerFunc*>(&SSDPClass::_onTimerStatic), reinterpret_cast<void*>(this));
os_timer_arm(tm, interval, 1 /* repeat */);
}
#if !defined(NO_GLOBAL_INSTANCES) && !defined(NO_GLOBAL_SSDP)
SSDPClass SSDP;
#endif

128
lib/SSDP/New_ESP8266SSDP.h Normal file
View file

@ -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 <Arduino.h>
#include <ESP8266WiFi.h>
#include <WiFiUdp.h>
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

View file

@ -0,0 +1,28 @@
#include <AboutHelper.h>
#include <ArduinoJson.h>
#include <Settings.h>
#include <ESP8266WiFi.h>
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();
}
}

View file

@ -0,0 +1,13 @@
#include <Arduino.h>
#include <ArduinoJson.h>
#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

383
lib/Settings/Settings.cpp Normal file
View file

@ -0,0 +1,383 @@
#include <Settings.h>
#include <ArduinoJson.h>
#include <FS.h>
#include <IntParsing.h>
#include <algorithm>
#include <JsonHelpers.h>
#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<size_t>(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<GatewayConfig> ptr = std::make_shared<GatewayConfig>(parseInt<uint16_t>(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<RF24Channel, String>(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<GroupStateField, const char*>(arr, GroupStateFieldHelpers::getFieldByName);
}
if (parsedSettings.containsKey("group_id_aliases")) {
parseGroupIdAliases(parsedSettings);
}
}
std::map<String, BulbId>::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<uint16_t>(),
bulbIdProps[2].as<uint8_t>(),
MiLightRemoteTypeHelpers::remoteTypeFromString(bulbIdProps[0].as<String>())
};
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<String, BulbId>::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<JsonObject>();
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<RF24Channel, String>(channelArr, rf24Channels, RF24ChannelHelpers::nameFromValue);
JsonArray deviceIdsArr = root.createNestedArray("device_ids");
JsonHelpers::copyFrom<uint16_t>(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<GroupStateField, const char*>(groupStateFieldArr, groupStateFields, GroupStateFieldHelpers::getFieldName);
dumpGroupIdAliases(root.as<JsonObject>());
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";
}
}

219
lib/Settings/Settings.h Normal file
View file

@ -0,0 +1,219 @@
#include <Arduino.h>
#include <StringStream.h>
#include <ArduinoJson.h>
#include <GroupStateField.h>
#include <RF24PowerLevel.h>
#include <RF24Channel.h>
#include <Size.h>
#include <LEDStatus.h>
#include <AuthProviders.h>
#include <MiLightRemoteType.h>
#include <BulbId.h>
#include <vector>
#include <memory>
#include <map>
#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<GroupStateField> 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<RF24Channel> 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<String, BulbId>::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<uint16_t> deviceIds;
std::vector<RF24Channel> rf24Channels;
std::vector<GroupStateField> groupStateFields;
std::vector<std::shared_ptr<GatewayConfig>> gatewayConfigs;
RF24Channel rf24ListenChannel;
String wifiStaticIP;
String wifiStaticIPNetmask;
String wifiStaticIPGateway;
size_t packetRepeatsPerLoop;
std::map<String, BulbId> groupIdAliases;
std::map<uint32_t, BulbId> 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 <typename T>
void setIfPresent(JsonObject obj, const char* key, T& var) {
if (obj.containsKey(key)) {
JsonVariant val = obj[key];
var = val.as<T>();
}
}
};
#endif

View file

@ -0,0 +1,29 @@
/*
* Adapated from https://gist.github.com/cmaglie/5883185
*/
#ifndef _STRING_STREAM_H_INCLUDED_
#define _STRING_STREAM_H_INCLUDED_
#include <Stream.h>
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_

View file

@ -0,0 +1,60 @@
#include <ChangeFieldOnFinishTransition.h>
#include <MiLightStatus.h>
ChangeFieldOnFinishTransition::Builder::Builder(
size_t id,
GroupStateField field,
uint16_t arg,
std::shared_ptr<Transition::Builder> delegate
)
: Transition::Builder(delegate->id, delegate->defaultPeriod, delegate->bulbId, delegate->callback, delegate->getMaxSteps())
, delegate(delegate)
, field(field)
, arg(arg)
{ }
std::shared_ptr<Transition> ChangeFieldOnFinishTransition::Builder::_build() const {
delegate->setDurationRaw(this->getOrComputeDuration());
delegate->setPeriod(this->getOrComputePeriod());
return std::make_shared<ChangeFieldOnFinishTransition>(
delegate->build(),
field,
arg,
delegate->getPeriod()
);
}
ChangeFieldOnFinishTransition::ChangeFieldOnFinishTransition(
std::shared_ptr<Transition> 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);
}

View file

@ -0,0 +1,37 @@
#include <Transition.h>
#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<Transition::Builder> delgate);
virtual std::shared_ptr<Transition> _build() const override;
private:
const std::shared_ptr<Transition::Builder> delegate;
const GroupStateField field;
const uint16_t arg;
};
ChangeFieldOnFinishTransition(
std::shared_ptr<Transition> delegate,
GroupStateField field,
uint16_t arg,
size_t period
);
virtual bool isFinished() override;
private:
std::shared_ptr<Transition> delegate;
const GroupStateField field;
const uint16_t arg;
bool changeSent;
virtual void step() override;
virtual void childSerialize(JsonObject& json) override;
};

View file

@ -0,0 +1,156 @@
#include <ColorTransition.h>
#include <Arduino.h>
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<Transition> 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<ColorTransition>(
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<double>(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);
}

View file

@ -0,0 +1,59 @@
#include <Transition.h>
#include <ParsedColor.h>
#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<Transition> _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);
};

View file

@ -0,0 +1,85 @@
#include <FieldTransition.h>
#include <cmath>
#include <algorithm>
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<size_t>(1),
static_cast<size_t>(std::abs(static_cast<int16_t>(end) - static_cast<uint16_t>(start)))
)
)
, stepSize(0)
, field(field)
, start(start)
, end(end)
{ }
std::shared_ptr<Transition> 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<float>(numPeriods)));
if (end < start) {
stepSize = -stepSize;
}
if (stepSize == 0) {
stepSize = end > start ? 1 : -1;
}
return std::make_shared<FieldTransition>(
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;
}

View file

@ -0,0 +1,48 @@
#include <GroupStateField.h>
#include <stdint.h>
#include <stddef.h>
#include <Arduino.h>
#include <functional>
#include <Transition.h>
#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<Transition> _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;
};

View file

@ -0,0 +1,179 @@
#include <Transition.h>
#include <Arduino.h>
#include <cmath>
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<float>(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<float>(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<float>(period));
return max(static_cast<size_t>(1), _numPeriods);
} else {
return 0;
}
}
std::shared_ptr<Transition> 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<float>(stepSize)))
: 0;
return static_cast<size_t>(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);
}

View file

@ -0,0 +1,99 @@
#include <BulbId.h>
#include <ArduinoJson.h>
#include <GroupStateField.h>
#include <stdint.h>
#include <stddef.h>
#include <functional>
#include <memory>
#pragma once
class Transition {
public:
using TransitionFn = std::function<void(const BulbId& bulbId, GroupStateField field, uint16_t value)>;
// 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<Transition> 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<Transition> _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);
};

View file

@ -0,0 +1,142 @@
#include <Transition.h>
#include <FieldTransition.h>
#include <ColorTransition.h>
#include <ChangeFieldOnFinishTransition.h>
#include <GroupStateField.h>
#include <MiLightStatus.h>
#include <TransitionController.h>
#include <LinkedList.h>
#include <functional>
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<Transition::Builder> TransitionController::buildColorTransition(const BulbId& bulbId, const ParsedColor& start, const ParsedColor& end) {
return std::make_shared<ColorTransition::Builder>(
currentId++,
defaultPeriod,
bulbId,
callback,
start,
end
);
}
std::shared_ptr<Transition::Builder> TransitionController::buildFieldTransition(const BulbId& bulbId, GroupStateField field, uint16_t start, uint16_t end) {
return std::make_shared<FieldTransition::Builder>(
currentId++,
defaultPeriod,
bulbId,
callback,
field,
start,
end
);
}
std::shared_ptr<Transition::Builder> TransitionController::buildStatusTransition(const BulbId& bulbId, MiLightStatus status, uint8_t startLevel) {
std::shared_ptr<Transition::Builder> 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<ChangeFieldOnFinishTransition::Builder>(
currentId++,
GroupStateField::STATUS,
OFF,
buildFieldTransition(bulbId, GroupStateField::LEVEL, startLevel, 0)
);
}
return transition;
}
void TransitionController::addTransition(std::shared_ptr<Transition> 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<std::shared_ptr<Transition>>* TransitionController::getTransitions() {
return activeTransitions.getHead();
}
ListNode<std::shared_ptr<Transition>>* 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;
}
}

View file

@ -0,0 +1,39 @@
#include <Transition.h>
#include <LinkedList.h>
#include <ParsedColor.h>
#include <GroupStateField.h>
#include <memory>
#include <vector>
#pragma once
class TransitionController {
public:
TransitionController();
void clearListeners();
void addListener(Transition::TransitionFn fn);
void setDefaultPeriod(uint16_t period);
std::shared_ptr<Transition::Builder> buildColorTransition(const BulbId& bulbId, const ParsedColor& start, const ParsedColor& end);
std::shared_ptr<Transition::Builder> buildFieldTransition(const BulbId& bulbId, GroupStateField field, uint16_t start, uint16_t end);
std::shared_ptr<Transition::Builder> buildStatusTransition(const BulbId& bulbId, MiLightStatus toStatus, uint8_t startLevel);
void addTransition(std::shared_ptr<Transition> transition);
void clear();
void loop();
ListNode<std::shared_ptr<Transition>>* getTransitions();
Transition* getTransition(size_t id);
ListNode<std::shared_ptr<Transition>>* findTransition(size_t id);
bool deleteTransition(size_t id);
private:
Transition::TransitionFn callback;
LinkedList<std::shared_ptr<Transition>> activeTransitions;
std::vector<Transition::TransitionFn> observers;
size_t currentId;
uint16_t defaultPeriod;
void transitionCallback(const BulbId& bulbId, GroupStateField field, uint16_t arg);
};

60
lib/Types/BulbId.cpp Normal file
View file

@ -0,0 +1,60 @@
#include <BulbId.h>
#include <GroupStateField.h>
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);
}

22
lib/Types/BulbId.h Normal file
View file

@ -0,0 +1,22 @@
#pragma once
#include <stdint.h>
#include <MiLightRemoteType.h>
#include <ArduinoJson.h>
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;
};

View file

@ -0,0 +1,52 @@
#include <GroupStateField.h>
#include <Size.h>
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<GroupStateField>(i);
}
}
return GroupStateField::UNKNOWN;
}
const char* GroupStateFieldHelpers::getFieldName(GroupStateField field) {
for (size_t i = 0; i < size(STATE_NAMES); i++) {
if (field == static_cast<GroupStateField>(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;
}
}

Some files were not shown because too many files have changed in this diff Show more