├── test └── remote │ ├── .ruby-version │ ├── .rspec │ ├── Gemfile │ ├── helpers │ ├── state_helpers.rb │ ├── mqtt_helpers.rb │ └── transition_helpers.rb │ ├── espmh.env.example │ ├── Gemfile.lock │ ├── spec │ ├── environment_spec.rb │ └── udp_spec.rb │ ├── settings.json.example │ ├── lib │ ├── mqtt_client.rb │ └── api_client.rb │ └── README.md ├── lib ├── Radio │ ├── RadioUtils.h │ ├── RadioUtils.cpp │ ├── MiLightRadioConfig.cpp │ ├── MiLightRadio.h │ ├── PL1167_nRF24.h │ ├── NRF24MiLightRadio.h │ ├── MiLightRadioFactory.cpp │ ├── MiLightRadioFactory.h │ ├── LT8900MiLightRadio.h │ ├── MiLightRadioConfig.h │ └── NRF24MiLightRadio.cpp ├── Types │ ├── MiLightStatus.h │ ├── ParsedColor.h │ ├── MiLightStatus.cpp │ ├── RF24Channel.h │ ├── MiLightRemoteType.h │ ├── BulbId.h │ ├── RF24PowerLevel.h │ ├── MiLightCommands.h │ ├── RF24PowerLevel.cpp │ ├── RF24Channel.cpp │ ├── GroupStateField.cpp │ ├── GroupStateField.h │ ├── ParsedColor.cpp │ ├── BulbId.cpp │ └── MiLightRemoteType.cpp ├── Helpers │ ├── Size.h │ ├── Units.h │ ├── JsonHelpers.h │ └── IntParsing.h ├── Settings │ ├── AboutHelper.h │ ├── AboutHelper.cpp │ └── StringStream.h ├── MiLightState │ ├── GroupStatePersistence.h │ ├── GroupStateCache.h │ ├── GroupStatePersistence.cpp │ ├── GroupStateStore.h │ ├── GroupStateCache.cpp │ └── GroupStateStore.cpp ├── MiLight │ ├── V2RFEncoding.h │ ├── FUT091PacketFormatter.h │ ├── FUT02xPacketFormatter.h │ ├── RadioSwitchboard.h │ ├── FUT020PacketFormatter.h │ ├── PacketQueue.h │ ├── PacketQueue.cpp │ ├── FUT089PacketFormatter.h │ ├── RgbPacketFormatter.h │ ├── RgbCctPacketFormatter.h │ ├── FUT02xPacketFormatter.cpp │ ├── MiLightRemoteConfig.h │ ├── CctPacketFormatter.h │ ├── RadioSwitchboard.cpp │ ├── V2PacketFormatter.h │ ├── V2RFEncoding.cpp │ ├── PacketSender.h │ ├── FUT091PacketFormatter.cpp │ ├── RgbwPacketFormatter.h │ ├── FUT020PacketFormatter.cpp │ ├── MiLightRemoteConfig.cpp │ ├── PacketFormatter.h │ ├── PacketSender.cpp │ └── RgbPacketFormatter.cpp ├── Udp │ ├── MiLightDiscoveryServer.h │ ├── V6CctCommandHandler.h │ ├── V6RgbCommandHandler.h │ ├── V6RgbwCommandHandler.h │ ├── V6RgbCctCommandHandler.h │ ├── MiLightUdpServer.h │ ├── V6CctCommandHandler.cpp │ ├── MiLightUdpServer.cpp │ ├── V6RgbCommandHandler.cpp │ ├── V6RgbwCommandHandler.cpp │ ├── V6ComamndHandler.cpp │ ├── V6CommandHandler.h │ ├── V5MiLightUdpServer.h │ ├── V6MiLightUdpServer.h │ ├── MiLightDiscoveryServer.cpp │ ├── V6RgbCctCommandHandler.cpp │ └── V5MiLightUdpServer.cpp ├── MQTT │ ├── HomeAssistantDiscoveryClient.h │ ├── BulbStateUpdater.h │ ├── BulbStateUpdater.cpp │ └── MqttClient.h ├── readme.txt ├── Transitions │ ├── ChangeFieldOnFinishTransition.h │ ├── FieldTransition.h │ ├── TransitionController.h │ ├── ColorTransition.h │ ├── ChangeFieldOnFinishTransition.cpp │ ├── FieldTransition.cpp │ ├── Transition.h │ ├── TransitionController.cpp │ └── Transition.cpp ├── LEDStatus │ └── LEDStatus.h ├── WebServer │ └── MiLightHttpServer.h └── SSDP │ └── New_ESP8266SSDP.h ├── .gitignore ├── web ├── package.json ├── src │ ├── js │ │ └── rgb2hsv.js │ └── css │ │ └── style.css └── gulpfile.js ├── .prepare_release ├── .get_version.py ├── LICENSE ├── .travis.yml ├── .build_web.py ├── .prepare_docs ├── docs └── gh-pages │ └── index.html └── platformio.ini /test/remote/.ruby-version: -------------------------------------------------------------------------------- 1 | 2.6.0 2 | -------------------------------------------------------------------------------- /test/remote/.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /lib/Radio/RadioUtils.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | /** 6 | * Reverse the bits of a given byte 7 | */ 8 | uint8_t reverseBits(uint8_t byte); -------------------------------------------------------------------------------- /lib/Types/MiLightStatus.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | enum MiLightStatus { 6 | ON = 0, 7 | OFF = 1 8 | }; 9 | 10 | MiLightStatus parseMilightStatus(JsonVariant s); -------------------------------------------------------------------------------- /lib/Helpers/Size.h: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #ifndef _SIZE_H 4 | #define _SIZE_H 5 | 6 | template 7 | size_t size(T(&)[sz]) { 8 | return sz; 9 | } 10 | 11 | #endif 12 | -------------------------------------------------------------------------------- /test/remote/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gem 'rspec' 6 | gem 'mqtt', '~> 0.5' 7 | gem 'dotenv', '~> 2.6' 8 | gem 'multipart-post' 9 | gem 'net-ping' 10 | gem 'milight-easybulb', '~> 1.0' 11 | gem 'chroma', '~> 0.2.x' -------------------------------------------------------------------------------- /lib/Types/ParsedColor.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #pragma once 5 | 6 | struct ParsedColor { 7 | bool success; 8 | uint16_t hue, r, g, b; 9 | uint8_t saturation; 10 | 11 | static ParsedColor fromRgb(uint16_t r, uint16_t g, uint16_t b); 12 | static ParsedColor fromJson(JsonVariant json); 13 | }; -------------------------------------------------------------------------------- /test/remote/helpers/state_helpers.rb: -------------------------------------------------------------------------------- 1 | module StateHelpers 2 | ALL_REMOTE_TYPES = %w(rgb rgbw rgb_cct cct fut089 fut091) 3 | def states_are_equal(desired_state, retrieved_state) 4 | expect(retrieved_state).to include(*desired_state.keys) 5 | expect(retrieved_state.select { |x| desired_state.include?(x) } ).to eq(desired_state) 6 | end 7 | end -------------------------------------------------------------------------------- /lib/Settings/AboutHelper.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #ifndef _ABOUT_STRING_HELPER_H 5 | #define _ABOUT_STRING_HELPER_H 6 | 7 | class AboutHelper { 8 | public: 9 | static String generateAboutString(bool abbreviated = false); 10 | static void generateAboutObject(JsonDocument& obj, bool abbreviated = false); 11 | }; 12 | 13 | #endif -------------------------------------------------------------------------------- /lib/Radio/RadioUtils.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | uint8_t reverseBits(uint8_t byte) { 8 | uint8_t result = byte; 9 | uint8_t i = 7; 10 | 11 | for (byte >>= 1; byte; byte >>= 1) { 12 | result <<= 1; 13 | result |= byte & 1; 14 | --i; 15 | } 16 | 17 | return result << i; 18 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .pioenvs 2 | .piolibdeps 3 | .pio 4 | .clang_complete 5 | .gcc-flags.json 6 | .sconsign.dblite 7 | /web/node_modules 8 | /web/build 9 | /web/package-lock.json 10 | /dist/*.bin 11 | /dist/docs 12 | .vscode/ 13 | .vscode/.browse.c_cpp.db* 14 | .vscode/c_cpp_properties.json 15 | .vscode/launch.json 16 | /test/remote/settings.json 17 | /test/remote/espmh.env 18 | 19 | web/package-lock\.json 20 | -------------------------------------------------------------------------------- /lib/MiLightState/GroupStatePersistence.h: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #ifndef _GROUP_STATE_PERSISTENCE_H 4 | #define _GROUP_STATE_PERSISTENCE_H 5 | 6 | class GroupStatePersistence { 7 | public: 8 | void get(const BulbId& id, GroupState& state); 9 | void set(const BulbId& id, const GroupState& state); 10 | 11 | void clear(const BulbId& id); 12 | 13 | private: 14 | 15 | static char* buildFilename(const BulbId& id, char* buffer); 16 | }; 17 | 18 | #endif 19 | -------------------------------------------------------------------------------- /lib/Types/MiLightStatus.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | MiLightStatus parseMilightStatus(JsonVariant val) { 5 | if (val.is()) { 6 | return val.as() ? ON : OFF; 7 | } else if (val.is()) { 8 | return static_cast(val.as()); 9 | } else { 10 | String strStatus(val.as()); 11 | return (strStatus.equalsIgnoreCase("on") || strStatus.equalsIgnoreCase("true")) ? ON : OFF; 12 | } 13 | } -------------------------------------------------------------------------------- /lib/Types/RF24Channel.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #ifndef _RF24_CHANNELS_H 5 | #define _RF24_CHANNELS_H 6 | 7 | enum class RF24Channel { 8 | RF24_LOW = 0, 9 | RF24_MID = 1, 10 | RF24_HIGH = 2 11 | }; 12 | 13 | class RF24ChannelHelpers { 14 | public: 15 | static String nameFromValue(const RF24Channel& value); 16 | static RF24Channel valueFromName(const String& name); 17 | static RF24Channel defaultValue(); 18 | static std::vector allValues(); 19 | }; 20 | 21 | #endif -------------------------------------------------------------------------------- /lib/Radio/MiLightRadioConfig.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | MiLightRadioConfig MiLightRadioConfig::ALL_CONFIGS[] = { 4 | MiLightRadioConfig(0x147A, 0x258B, 7, 9, 40, 71, 0xAA, 0x05), // rgbw 5 | MiLightRadioConfig(0x050A, 0x55AA, 7, 4, 39, 74, 0xAA, 0x05), // cct 6 | MiLightRadioConfig(0x7236, 0x1809, 9, 8, 39, 70, 0xAA, 0x05), // rgb+cct, fut089 7 | MiLightRadioConfig(0x9AAB, 0xBCCD, 6, 3, 38, 73, 0x55, 0x0A), // rgb 8 | MiLightRadioConfig(0x50A0, 0xAA55, 6, 6, 41, 76, 0xAA, 0x0A) // FUT020 9 | }; 10 | -------------------------------------------------------------------------------- /lib/Types/MiLightRemoteType.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | enum MiLightRemoteType { 6 | REMOTE_TYPE_UNKNOWN = 255, 7 | REMOTE_TYPE_RGBW = 0, 8 | REMOTE_TYPE_CCT = 1, 9 | REMOTE_TYPE_RGB_CCT = 2, 10 | REMOTE_TYPE_RGB = 3, 11 | REMOTE_TYPE_FUT089 = 4, 12 | REMOTE_TYPE_FUT091 = 5, 13 | REMOTE_TYPE_FUT020 = 6 14 | }; 15 | 16 | class MiLightRemoteTypeHelpers { 17 | public: 18 | static const MiLightRemoteType remoteTypeFromString(const String& type); 19 | static const String remoteTypeToString(const MiLightRemoteType type); 20 | }; -------------------------------------------------------------------------------- /test/remote/espmh.env.example: -------------------------------------------------------------------------------- 1 | ESPMH_HOSTNAME=milight-hub-test 2 | 3 | # Used to test states, etc. 4 | ESPMH_TEST_DEVICE_ID_BASE=0x2200 5 | 6 | # MQTT server/auth. Used for MQTT tests 7 | ESPMH_MQTT_SERVER=my-mqtt-server 8 | ESPMH_MQTT_USERNAME=username 9 | ESPMH_MQTT_PASSWORD=password 10 | ESPMH_MQTT_TOPIC_PREFIX=milight_test/ 11 | 12 | # Settings to test static IP 13 | ESPMH_STATIC_IP=192.168.1.200 14 | ESPMH_STATIC_IP_NETMASK=255.255.255.0 15 | ESPMH_STATIC_IP_GATEWAY=192.168.1.1 16 | 17 | # Settings to test UDP server 18 | ESPMH_V5_UDP_PORT=8888 19 | ESPMH_V6_UDP_PORT=8889 20 | ESPMH_DISCOVERY_PORT=8877 -------------------------------------------------------------------------------- /lib/MiLight/V2RFEncoding.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #ifndef _V2_RF_ENCODING_H 5 | #define _V2_RF_ENCODING_H 6 | 7 | #define V2_OFFSET_JUMP_START 0x54 8 | 9 | class V2RFEncoding { 10 | public: 11 | static void encodeV2Packet(uint8_t* packet); 12 | static void decodeV2Packet(uint8_t* packet); 13 | static uint8_t xorKey(uint8_t key); 14 | static uint8_t encodeByte(uint8_t byte, uint8_t s1, uint8_t xorKey, uint8_t s2); 15 | static uint8_t decodeByte(uint8_t byte, uint8_t s1, uint8_t xorKey, uint8_t s2); 16 | 17 | private: 18 | static uint8_t const V2_OFFSETS[][4]; 19 | }; 20 | 21 | #endif 22 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "esp8266-milight-hub-web", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "gulpfile.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "del": "^2.2.1", 14 | "gulp": "^3.9.1", 15 | "gulp-base64-favicon": "^1.0.2", 16 | "gulp-clean-css": "^3.4.2", 17 | "gulp-css-base64": "^1.3.4", 18 | "gulp-gzip": "^1.4.0", 19 | "gulp-htmlmin": "^2.0.0", 20 | "gulp-inline": "^0.1.1", 21 | "gulp-uglify": "^1.5.3" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/Types/BulbId.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | struct BulbId { 8 | uint16_t deviceId; 9 | uint8_t groupId; 10 | MiLightRemoteType deviceType; 11 | 12 | BulbId(); 13 | BulbId(const BulbId& other); 14 | BulbId(const uint16_t deviceId, const uint8_t groupId, const MiLightRemoteType deviceType); 15 | bool operator==(const BulbId& other); 16 | void operator=(const BulbId& other); 17 | 18 | uint32_t getCompactId() const; 19 | String getHexDeviceId() const; 20 | void serialize(JsonObject json) const; 21 | void serialize(JsonArray json) const; 22 | }; -------------------------------------------------------------------------------- /lib/Radio/MiLightRadio.h: -------------------------------------------------------------------------------- 1 | 2 | #ifdef ARDUINO 3 | #include "Arduino.h" 4 | #else 5 | #include 6 | #include 7 | #endif 8 | 9 | #include 10 | 11 | #ifndef _MILIGHT_RADIO_H_ 12 | #define _MILIGHT_RADIO_H_ 13 | 14 | class MiLightRadio { 15 | public: 16 | 17 | virtual int begin(); 18 | virtual bool available(); 19 | virtual int read(uint8_t frame[], size_t &frame_length); 20 | virtual int write(uint8_t frame[], size_t frame_length); 21 | virtual int resend(); 22 | virtual int configure(); 23 | virtual const MiLightRadioConfig& config(); 24 | 25 | }; 26 | 27 | 28 | 29 | 30 | #endif 31 | -------------------------------------------------------------------------------- /lib/Udp/MiLightDiscoveryServer.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #ifndef MILIGHT_DISCOVERY_SERVER_H 5 | #define MILIGHT_DISCOVERY_SERVER_H 6 | 7 | class MiLightDiscoveryServer { 8 | public: 9 | MiLightDiscoveryServer(Settings& settings); 10 | MiLightDiscoveryServer(MiLightDiscoveryServer&); 11 | MiLightDiscoveryServer& operator=(MiLightDiscoveryServer other); 12 | ~MiLightDiscoveryServer(); 13 | 14 | void begin(); 15 | void handleClient(); 16 | 17 | private: 18 | Settings& settings; 19 | WiFiUDP socket; 20 | 21 | void handleDiscovery(uint8_t version); 22 | void sendResponse(char* buffer); 23 | }; 24 | 25 | #endif 26 | -------------------------------------------------------------------------------- /lib/Types/RF24PowerLevel.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #ifndef _RF24_POWER_LEVEL_H 5 | #define _RF24_POWER_LEVEL_H 6 | 7 | enum class RF24PowerLevel { 8 | RF24_MIN = RF24_PA_MIN, // -18 dBm 9 | RF24_LOW = RF24_PA_LOW, // -12 dBm 10 | RF24_HIGH = RF24_PA_HIGH, // -6 dBm 11 | RF24_MAX = RF24_PA_MAX // 0 dBm 12 | }; 13 | 14 | class RF24PowerLevelHelpers { 15 | public: 16 | static String nameFromValue(const RF24PowerLevel& value); 17 | static RF24PowerLevel valueFromName(const String& name); 18 | static RF24PowerLevel defaultValue(); 19 | static uint8_t rf24ValueFromValue(const RF24PowerLevel& vlaue); 20 | }; 21 | 22 | #endif -------------------------------------------------------------------------------- /.prepare_release: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eo pipefail 4 | 5 | prepare_log() { 6 | echo "[prepare release] -- $@" 7 | } 8 | 9 | if [ -z "$(git tag -l --points-at HEAD)" ]; then 10 | prepare_log "Skipping non-tagged commit." 11 | exit 0 12 | fi 13 | 14 | VERSION=$(git describe) 15 | 16 | prepare_log "Preparing release for tagged version: $VERSION" 17 | 18 | mkdir -p dist 19 | 20 | if [ -d .pio/build ]; then 21 | firmware_prefix=".pio/build" 22 | else 23 | firmware_prefix=".pioenvs" 24 | fi 25 | 26 | for file in $(ls ${firmware_prefix}/**/firmware.bin); do 27 | env_dir=$(dirname "$file") 28 | env=$(basename "$env_dir") 29 | 30 | cp "$file" "dist/esp8266_milight_hub_${env}-${VERSION}.bin" 31 | done 32 | -------------------------------------------------------------------------------- /lib/MiLight/FUT091PacketFormatter.h: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #ifndef _FUT091_PACKET_FORMATTER_H 4 | #define _FUT091_PACKET_FORMATTER_H 5 | 6 | enum class FUT091Command { 7 | ON_OFF = 0x01, 8 | BRIGHTNESS = 0x2, 9 | KELVIN = 0x03 10 | }; 11 | 12 | class FUT091PacketFormatter : public V2PacketFormatter { 13 | public: 14 | FUT091PacketFormatter() 15 | : V2PacketFormatter(REMOTE_TYPE_FUT091, 0x21, 4) // protocol is 0x21, and there are 4 groups 16 | { } 17 | 18 | virtual void updateBrightness(uint8_t value); 19 | virtual void updateTemperature(uint8_t value); 20 | virtual void enableNightMode(); 21 | 22 | virtual BulbId parsePacket(const uint8_t* packet, JsonObject result); 23 | }; 24 | 25 | #endif 26 | -------------------------------------------------------------------------------- /lib/MiLight/FUT02xPacketFormatter.h: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #pragma once 4 | 5 | class FUT02xPacketFormatter : public PacketFormatter { 6 | public: 7 | static const uint8_t FUT02X_COMMAND_INDEX = 4; 8 | static const uint8_t FUT02X_ARGUMENT_INDEX = 3; 9 | static const uint8_t NUM_BRIGHTNESS_INTERVALS = 8; 10 | 11 | FUT02xPacketFormatter(MiLightRemoteType type) 12 | : PacketFormatter(type, 6, 10) 13 | { } 14 | 15 | virtual bool canHandle(const uint8_t* packet, const size_t len) override; 16 | 17 | virtual void command(uint8_t command, uint8_t arg) override; 18 | 19 | virtual void pair() override; 20 | virtual void unpair() override; 21 | 22 | virtual void initializePacket(uint8_t* packet) override; 23 | virtual void format(uint8_t const* packet, char* buffer) override; 24 | }; -------------------------------------------------------------------------------- /lib/MiLight/RadioSwitchboard.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | class RadioSwitchboard { 9 | public: 10 | RadioSwitchboard( 11 | std::shared_ptr radioFactory, 12 | GroupStateStore* stateStore, 13 | Settings& settings 14 | ); 15 | 16 | std::shared_ptr switchRadio(const MiLightRemoteConfig* remote); 17 | std::shared_ptr switchRadio(size_t index); 18 | size_t getNumRadios() const; 19 | 20 | bool available(); 21 | void write(uint8_t* packet, size_t length); 22 | size_t read(uint8_t* packet); 23 | 24 | private: 25 | std::vector> radios; 26 | std::shared_ptr currentRadio; 27 | }; -------------------------------------------------------------------------------- /lib/Types/MiLightCommands.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | namespace MiLightCommandNames { 4 | static const char UNPAIR[] = "unpair"; 5 | static const char PAIR[] = "pair"; 6 | static const char SET_WHITE[] = "set_white"; 7 | static const char NIGHT_MODE[] = "night_mode"; 8 | static const char LEVEL_UP[] = "level_up"; 9 | static const char LEVEL_DOWN[] = "level_down"; 10 | static const char TEMPERATURE_UP[] = "temperature_up"; 11 | static const char TEMPERATURE_DOWN[] = "temperature_down"; 12 | static const char NEXT_MODE[] = "next_mode"; 13 | static const char PREVIOUS_MODE[] = "previous_mode"; 14 | static const char MODE_SPEED_DOWN[] = "mode_speed_down"; 15 | static const char MODE_SPEED_UP[] = "mode_speed_up"; 16 | static const char TOGGLE[] = "toggle"; 17 | static const char TRANSITION[] = "transition"; 18 | }; -------------------------------------------------------------------------------- /lib/MQTT/HomeAssistantDiscoveryClient.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | class HomeAssistantDiscoveryClient { 8 | public: 9 | HomeAssistantDiscoveryClient(Settings& settings, MqttClient* mqttClient); 10 | 11 | void addConfig(const char* alias, const BulbId& bulbId); 12 | void removeConfig(const BulbId& bulbId); 13 | 14 | void sendDiscoverableDevices(const std::map& aliases); 15 | void removeOldDevices(const std::map& aliases); 16 | 17 | private: 18 | Settings& settings; 19 | MqttClient* mqttClient; 20 | 21 | String buildTopic(const BulbId& bulbId); 22 | String bindTopicVariables(const String& topic, const char* alias, const BulbId& bulbId); 23 | void addNumberedEffects(JsonArray& effectList, uint8_t start, uint8_t end); 24 | }; -------------------------------------------------------------------------------- /lib/MiLightState/GroupStateCache.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #ifndef _GROUP_STATE_CACHE_H 5 | #define _GROUP_STATE_CACHE_H 6 | 7 | struct GroupCacheNode { 8 | GroupCacheNode() {} 9 | GroupCacheNode(const BulbId& id, const GroupState& state) 10 | : id(id), state(state) { } 11 | 12 | BulbId id; 13 | GroupState state; 14 | }; 15 | 16 | class GroupStateCache { 17 | public: 18 | GroupStateCache(const size_t maxSize); 19 | ~GroupStateCache(); 20 | 21 | GroupState* get(const BulbId& id); 22 | GroupState* set(const BulbId& id, const GroupState& state); 23 | BulbId getLru(); 24 | bool isFull() const; 25 | ListNode* getHead(); 26 | 27 | private: 28 | LinkedList cache; 29 | const size_t maxSize; 30 | 31 | GroupState* getInternal(const BulbId& id); 32 | }; 33 | 34 | #endif 35 | -------------------------------------------------------------------------------- /lib/Settings/AboutHelper.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | String AboutHelper::generateAboutString(bool abbreviated) { 7 | DynamicJsonDocument buffer(1024); 8 | 9 | generateAboutObject(buffer, abbreviated); 10 | 11 | String body; 12 | serializeJson(buffer, body); 13 | 14 | return body; 15 | } 16 | 17 | void AboutHelper::generateAboutObject(JsonDocument& obj, bool abbreviated) { 18 | obj["firmware"] = QUOTE(FIRMWARE_NAME); 19 | obj["version"] = QUOTE(MILIGHT_HUB_VERSION); 20 | obj["ip_address"] = WiFi.localIP().toString(); 21 | obj["reset_reason"] = ESP.getResetReason(); 22 | 23 | if (! abbreviated) { 24 | obj["variant"] = QUOTE(FIRMWARE_VARIANT); 25 | obj["free_heap"] = ESP.getFreeHeap(); 26 | obj["arduino_version"] = ESP.getCoreVersion(); 27 | } 28 | } -------------------------------------------------------------------------------- /lib/Settings/StringStream.h: -------------------------------------------------------------------------------- 1 | /* 2 | * Adapated from https://gist.github.com/cmaglie/5883185 3 | */ 4 | 5 | #ifndef _STRING_STREAM_H_INCLUDED_ 6 | #define _STRING_STREAM_H_INCLUDED_ 7 | 8 | #include 9 | 10 | class StringStream : public Stream 11 | { 12 | public: 13 | StringStream(String &s) : string(s), position(0) { } 14 | 15 | // Stream methods 16 | virtual int available() { return string.length() - position; } 17 | virtual int read() { return position < string.length() ? string[position++] : -1; } 18 | virtual int peek() { return position < string.length() ? string[position] : -1; } 19 | virtual void flush() { }; 20 | // Print methods 21 | virtual size_t write(uint8_t c) { string += (char)c; return 1; }; 22 | 23 | private: 24 | String &string; 25 | unsigned int length; 26 | unsigned int position; 27 | }; 28 | 29 | #endif // _STRING_STREAM_H_INCLUDED_ -------------------------------------------------------------------------------- /test/remote/Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | chroma (0.2.0) 5 | diff-lcs (1.3) 6 | dotenv (2.6.0) 7 | milight-easybulb (1.0.0) 8 | mqtt (0.5.0) 9 | multipart-post (2.0.0) 10 | net-ping (2.0.5) 11 | rspec (3.8.0) 12 | rspec-core (~> 3.8.0) 13 | rspec-expectations (~> 3.8.0) 14 | rspec-mocks (~> 3.8.0) 15 | rspec-core (3.8.0) 16 | rspec-support (~> 3.8.0) 17 | rspec-expectations (3.8.2) 18 | diff-lcs (>= 1.2.0, < 2.0) 19 | rspec-support (~> 3.8.0) 20 | rspec-mocks (3.8.0) 21 | diff-lcs (>= 1.2.0, < 2.0) 22 | rspec-support (~> 3.8.0) 23 | rspec-support (3.8.0) 24 | 25 | PLATFORMS 26 | ruby 27 | 28 | DEPENDENCIES 29 | chroma (~> 0.2.x) 30 | dotenv (~> 2.6) 31 | milight-easybulb (~> 1.0) 32 | mqtt (~> 0.5) 33 | multipart-post 34 | net-ping 35 | rspec 36 | 37 | BUNDLED WITH 38 | 1.17.2 39 | -------------------------------------------------------------------------------- /test/remote/spec/environment_spec.rb: -------------------------------------------------------------------------------- 1 | require 'api_client' 2 | 3 | RSpec.describe 'Environment' do 4 | before(:each) do 5 | @host = ENV.fetch('ESPMH_HOSTNAME') 6 | @client = ApiClient.new(ENV.fetch('ESPMH_HOSTNAME'), ENV.fetch('ESPMH_TEST_DEVICE_ID_BASE')) 7 | end 8 | 9 | context 'environment' do 10 | it 'should have a host defined' do 11 | expect(@host).to_not be_nil 12 | end 13 | 14 | it 'should respond to /about' do 15 | response = @client.get('/about') 16 | 17 | expect(response).to_not be_nil 18 | expect(response.keys).to include('version') 19 | end 20 | end 21 | 22 | context 'client' do 23 | it 'should return IDs' do 24 | id = @client.generate_id 25 | 26 | expect(@client.generate_id).to equal(id + 1) 27 | end 28 | end 29 | 30 | it 'needs to have a settings.json file' do 31 | expect(File.exists?('settings.json')).to be(true) 32 | end 33 | end -------------------------------------------------------------------------------- /.get_version.py: -------------------------------------------------------------------------------- 1 | from subprocess import check_output 2 | import sys 3 | import os 4 | import platform 5 | import subprocess 6 | 7 | dir_path = os.path.dirname(os.path.realpath(__file__)) 8 | os.chdir(dir_path) 9 | 10 | # http://stackoverflow.com/questions/11210104/check-if-a-program-exists-from-a-python-script 11 | def is_tool(name): 12 | cmd = "where" if platform.system() == "Windows" else "which" 13 | try: 14 | check_output([cmd, "git"]) 15 | return True 16 | except: 17 | return False; 18 | 19 | version = "UNKNOWN" 20 | 21 | if is_tool("git"): 22 | try: 23 | version = check_output(["git", "describe", "--always"]).rstrip() 24 | except: 25 | try: 26 | version = check_output(["git", "rev-parse", "--short", "HEAD"]).rstrip() 27 | except: 28 | pass 29 | pass 30 | 31 | sys.stdout.write("-DMILIGHT_HUB_VERSION=%s %s" % (version, ' '.join(sys.argv[1:]))) 32 | -------------------------------------------------------------------------------- /lib/MQTT/BulbStateUpdater.h: -------------------------------------------------------------------------------- 1 | /** 2 | * Enqueues updated bulb states and flushes them at the configured interval. 3 | */ 4 | 5 | #include 6 | #include 7 | #include 8 | #include 9 | 10 | #ifndef BULB_STATE_UPDATER 11 | #define BULB_STATE_UPDATER 12 | 13 | class BulbStateUpdater { 14 | public: 15 | BulbStateUpdater(Settings& settings, MqttClient& mqttClient, GroupStateStore& stateStore); 16 | 17 | void enqueueUpdate(BulbId bulbId, GroupState& groupState); 18 | void loop(); 19 | void enable(); 20 | void disable(); 21 | 22 | private: 23 | Settings& settings; 24 | MqttClient& mqttClient; 25 | GroupStateStore& stateStore; 26 | CircularBuffer staleGroups; 27 | unsigned long lastFlush; 28 | bool enabled; 29 | 30 | inline void flushGroup(BulbId bulbId, GroupState& state); 31 | inline bool canFlush() const; 32 | }; 33 | 34 | #endif 35 | -------------------------------------------------------------------------------- /lib/Udp/V6CctCommandHandler.h: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #ifndef _V6_CCT_COMMAND_HANDLER_H 4 | #define _V6_CCT_COMMAND_HANDLER_H 5 | 6 | enum CctCommandIds { 7 | V2_CCT_COMMAND_PREFIX = 0x01, 8 | 9 | V2_CCT_BRIGHTNESS_UP = 0x01, 10 | V2_CCT_BRIGHTNESS_DOWN = 0x02, 11 | V2_CCT_TEMPERATURE_UP = 0x03, 12 | V2_CCT_TEMPERATURE_DOWN = 0x04, 13 | V2_CCT_NIGHT_LIGHT = 0x06, 14 | V2_CCT_ON = 0x07, 15 | V2_CCT_OFF = 0x08 16 | }; 17 | 18 | class V6CctCommandHandler : public V6CommandHandler { 19 | public: 20 | V6CctCommandHandler() 21 | : V6CommandHandler(0x0100, FUT007Config) 22 | { } 23 | 24 | virtual bool handleCommand( 25 | MiLightClient* client, 26 | uint32_t command, 27 | uint32_t commandArg 28 | ); 29 | 30 | virtual bool handlePreset( 31 | MiLightClient* client, 32 | uint8_t commandLsb, 33 | uint32_t commandArg 34 | ); 35 | 36 | }; 37 | 38 | #endif 39 | -------------------------------------------------------------------------------- /lib/MiLight/FUT020PacketFormatter.h: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #pragma once 4 | 5 | enum class FUT020Command { 6 | ON_OFF = 0x04, 7 | MODE_SWITCH = 0x02, 8 | COLOR_WHITE_TOGGLE = 0x05, 9 | BRIGHTNESS_DOWN = 0x01, 10 | BRIGHTNESS_UP = 0x03, 11 | COLOR = 0x00 12 | }; 13 | 14 | class FUT020PacketFormatter : public FUT02xPacketFormatter { 15 | public: 16 | FUT020PacketFormatter() 17 | : FUT02xPacketFormatter(REMOTE_TYPE_FUT020) 18 | { } 19 | 20 | virtual void updateStatus(MiLightStatus status, uint8_t groupId); 21 | virtual void updateHue(uint16_t value); 22 | virtual void updateColorRaw(uint8_t value); 23 | virtual void updateColorWhite(); 24 | virtual void nextMode(); 25 | virtual void updateBrightness(uint8_t value); 26 | virtual void increaseBrightness(); 27 | virtual void decreaseBrightness(); 28 | 29 | virtual BulbId parsePacket(const uint8_t* packet, JsonObject result) override; 30 | }; -------------------------------------------------------------------------------- /lib/MiLight/PacketQueue.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | #include 6 | #include 7 | #include 8 | 9 | #ifndef MILIGHT_MAX_QUEUED_PACKETS 10 | #define MILIGHT_MAX_QUEUED_PACKETS 20 11 | #endif 12 | 13 | struct QueuedPacket { 14 | uint8_t packet[MILIGHT_MAX_PACKET_LENGTH]; 15 | const MiLightRemoteConfig* remoteConfig; 16 | size_t repeatsOverride; 17 | }; 18 | 19 | class PacketQueue { 20 | public: 21 | PacketQueue(); 22 | 23 | void push(const uint8_t* packet, const MiLightRemoteConfig* remoteConfig, const size_t repeatsOverride); 24 | std::shared_ptr pop(); 25 | bool isEmpty() const; 26 | size_t size() const; 27 | size_t getDroppedPacketCount() const; 28 | 29 | private: 30 | size_t droppedPackets; 31 | 32 | std::shared_ptr checkoutPacket(); 33 | void checkinPacket(std::shared_ptr packet); 34 | 35 | LinkedList> queue; 36 | }; -------------------------------------------------------------------------------- /lib/Udp/V6RgbCommandHandler.h: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #ifndef _V6_RGB_COMMAND_HANDLER_H 4 | #define _V6_RGB_COMMAND_HANDLER_H 5 | 6 | enum RgbCommandIds { 7 | V2_RGB_COMMAND_PREFIX = 0x02, 8 | V2_RGB_COLOR_PREFIX = 0x01, 9 | V2_RGB_BRIGHTNESS_DOWN = 0x01, 10 | V2_RGB_BRIGHTNESS_UP = 0x02, 11 | V2_RGB_SPEED_DOWN = 0x03, 12 | V2_RGB_SPEED_UP = 0x04, 13 | V2_RGB_MODE_DOWN = 0x05, 14 | V2_RGB_MODE_UP = 0x06, 15 | V2_RGB_ON = 0x09, 16 | V2_RGB_OFF = 0x0A 17 | }; 18 | 19 | class V6RgbCommandHandler : public V6CommandHandler { 20 | public: 21 | V6RgbCommandHandler() 22 | : V6CommandHandler(0x0500, FUT098Config) 23 | { } 24 | 25 | virtual bool handleCommand( 26 | MiLightClient* client, 27 | uint32_t command, 28 | uint32_t commandArg 29 | ); 30 | 31 | virtual bool handlePreset( 32 | MiLightClient* client, 33 | uint8_t commandLsb, 34 | uint32_t commandArg 35 | ); 36 | 37 | }; 38 | 39 | #endif 40 | -------------------------------------------------------------------------------- /lib/readme.txt: -------------------------------------------------------------------------------- 1 | 2 | This directory is intended for the project specific (private) libraries. 3 | PlatformIO will compile them to static libraries and link to executable file. 4 | 5 | The source code of each library should be placed in separate directory, like 6 | "lib/private_lib/[here are source files]". 7 | 8 | For example, see how can be organized `Foo` and `Bar` libraries: 9 | 10 | |--lib 11 | | |--Bar 12 | | | |--docs 13 | | | |--examples 14 | | | |--src 15 | | | |- Bar.c 16 | | | |- Bar.h 17 | | |--Foo 18 | | | |- Foo.c 19 | | | |- Foo.h 20 | | |- readme.txt --> THIS FILE 21 | |- platformio.ini 22 | |--src 23 | |- main.c 24 | 25 | Then in `src/main.c` you should use: 26 | 27 | #include 28 | #include 29 | 30 | // rest H/C/CPP code 31 | 32 | PlatformIO will find your libraries automatically, configure preprocessor's 33 | include paths and build them. 34 | 35 | More information about PlatformIO Library Dependency Finder 36 | - http://docs.platformio.org/page/librarymanager/ldf.html 37 | -------------------------------------------------------------------------------- /lib/Transitions/ChangeFieldOnFinishTransition.h: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #pragma once 4 | 5 | class ChangeFieldOnFinishTransition : public Transition { 6 | public: 7 | 8 | class Builder : public Transition::Builder { 9 | public: 10 | Builder(size_t id, GroupStateField field, uint16_t arg, std::shared_ptr delgate); 11 | 12 | virtual std::shared_ptr _build() const override; 13 | 14 | private: 15 | const std::shared_ptr delegate; 16 | const GroupStateField field; 17 | const uint16_t arg; 18 | }; 19 | 20 | ChangeFieldOnFinishTransition( 21 | std::shared_ptr delegate, 22 | GroupStateField field, 23 | uint16_t arg, 24 | size_t period 25 | ); 26 | 27 | virtual bool isFinished() override; 28 | 29 | private: 30 | std::shared_ptr delegate; 31 | const GroupStateField field; 32 | const uint16_t arg; 33 | bool changeSent; 34 | 35 | virtual void step() override; 36 | virtual void childSerialize(JsonObject& json) override; 37 | }; -------------------------------------------------------------------------------- /lib/Udp/V6RgbwCommandHandler.h: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #ifndef _V6_RGBW_COMMAND_HANDLER_H 4 | #define _V6_RGBW_COMMAND_HANDLER_H 5 | 6 | enum RgbwCommandIds { 7 | V2_RGBW_COLOR_PREFIX = 0x01, 8 | V2_RGBW_BRIGHTNESS_PREFIX = 0x02, 9 | V2_RGBW_COMMAND_PREFIX = 0x03, 10 | V2_RGBW_MODE_PREFIX = 0x04, 11 | 12 | V2_RGBW_ON = 0x01, 13 | V2_RGBW_OFF = 0x02, 14 | V2_RGBW_SPEED_DOWN = 0x03, 15 | V2_RGBW_SPEED_UP = 0x04, 16 | V2_RGBW_WHITE_ON = 0x05, 17 | V2_RGBW_NIGHT_LIGHT = 0x06 18 | }; 19 | 20 | class V6RgbwCommandHandler : public V6CommandHandler { 21 | public: 22 | V6RgbwCommandHandler() 23 | : V6CommandHandler(0x0700, FUT096Config) 24 | { } 25 | 26 | virtual bool handleCommand( 27 | MiLightClient* client, 28 | uint32_t command, 29 | uint32_t commandArg 30 | ); 31 | 32 | virtual bool handlePreset( 33 | MiLightClient* client, 34 | uint8_t commandLsb, 35 | uint32_t commandArg 36 | ); 37 | 38 | }; 39 | 40 | #endif 41 | -------------------------------------------------------------------------------- /lib/Helpers/Units.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #ifndef _UNITS_H 5 | #define _UNITS_H 6 | 7 | // MiLight CCT bulbs range from 2700K-6500K, or ~370.3-153.8 mireds. 8 | #define COLOR_TEMP_MAX_MIREDS 370 9 | #define COLOR_TEMP_MIN_MIREDS 153 10 | 11 | class Units { 12 | public: 13 | template 14 | static T rescale(T value, V newMax, float oldMax = 255.0) { 15 | return round(value * (newMax / oldMax)); 16 | } 17 | 18 | static uint8_t miredsToWhiteVal(uint16_t mireds, uint8_t maxValue = 255) { 19 | return rescale( 20 | constrain(mireds, COLOR_TEMP_MIN_MIREDS, COLOR_TEMP_MAX_MIREDS) - COLOR_TEMP_MIN_MIREDS, 21 | maxValue, 22 | (COLOR_TEMP_MAX_MIREDS - COLOR_TEMP_MIN_MIREDS) 23 | ); 24 | } 25 | 26 | static uint16_t whiteValToMireds(uint8_t value, uint8_t maxValue = 255) { 27 | uint16_t scaled = rescale(value, (COLOR_TEMP_MAX_MIREDS - COLOR_TEMP_MIN_MIREDS), maxValue); 28 | return COLOR_TEMP_MIN_MIREDS + scaled; 29 | } 30 | }; 31 | 32 | #endif 33 | -------------------------------------------------------------------------------- /lib/Udp/V6RgbCctCommandHandler.h: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #ifndef _V6_RGB_CCT_COMMAND_HANDLER_H 4 | #define _V6_RGB_CCT_COMMAND_HANDLER_H 5 | 6 | enum V2CommandIds { 7 | V2_COLOR = 0x01, 8 | V2_SATURATION = 0x02, 9 | V2_BRIGHTNESS = 0x03, 10 | V2_STATUS = 0x04, 11 | V2_KELVIN = 0x05, 12 | V2_MODE = 0x06 13 | }; 14 | 15 | enum V2CommandArgIds { 16 | V2_RGB_CCT_ON = 0x01, 17 | V2_RGB_CCT_OFF = 0x02, 18 | V2_RGB_CCT_SPEED_UP = 0x03, 19 | V2_RGB_CCT_SPEED_DOWN = 0x04, 20 | V2_RGB_NIGHT_MODE = 0x05 21 | }; 22 | 23 | class V6RgbCctCommandHandler : public V6CommandHandler { 24 | public: 25 | V6RgbCctCommandHandler() 26 | : V6CommandHandler(0x0800, FUT092Config) 27 | { } 28 | 29 | virtual bool handleCommand( 30 | MiLightClient* client, 31 | uint32_t command, 32 | uint32_t commandArg 33 | ); 34 | 35 | virtual bool handlePreset( 36 | MiLightClient* client, 37 | uint8_t commandLsb, 38 | uint32_t commandArg 39 | ); 40 | 41 | void handleUpdateColor(MiLightClient* client, uint32_t color); 42 | 43 | }; 44 | 45 | #endif 46 | -------------------------------------------------------------------------------- /test/remote/settings.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "admin_username": "", 3 | "admin_password": "", 4 | "ce_pin": 16, 5 | "csn_pin": 15, 6 | "reset_pin": 0, 7 | "led_pin": -2, 8 | "radio_interface_type": "nRF24", 9 | "packet_repeats": 50, 10 | "http_repeat_factor": 1, 11 | "auto_restart_period": 0, 12 | "discovery_port": 0, 13 | "listen_repeats": 3, 14 | "state_flush_interval": 2000, 15 | "mqtt_state_rate_limit": 1000, 16 | "packet_repeat_throttle_sensitivity": 0, 17 | "packet_repeat_throttle_threshold": 200, 18 | "packet_repeat_minimum": 3, 19 | "enable_automatic_mode_switching": false, 20 | "led_mode_wifi_config": "Fast toggle", 21 | "led_mode_wifi_failed": "On", 22 | "led_mode_operating": "Off", 23 | "led_mode_packet": "Flicker", 24 | "led_mode_packet_count": 3, 25 | "hostname": "milight-hub-test", 26 | "rf24_power_level": "MAX", 27 | "device_ids": [ 28 | ], 29 | "group_state_fields": [ 30 | "status", 31 | "level", 32 | "color_temp", 33 | "kelvin", 34 | "bulb_mode", 35 | "hue", 36 | "saturation", 37 | "effect" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /lib/MiLightState/GroupStatePersistence.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | static const char FILE_PREFIX[] = "group_states/"; 5 | 6 | void GroupStatePersistence::get(const BulbId &id, GroupState& state) { 7 | char path[30]; 8 | memset(path, 0, 30); 9 | buildFilename(id, path); 10 | 11 | if (SPIFFS.exists(path)) { 12 | File f = SPIFFS.open(path, "r"); 13 | state.load(f); 14 | f.close(); 15 | } 16 | } 17 | 18 | void GroupStatePersistence::set(const BulbId &id, const GroupState& state) { 19 | char path[30]; 20 | memset(path, 0, 30); 21 | buildFilename(id, path); 22 | 23 | File f = SPIFFS.open(path, "w"); 24 | state.dump(f); 25 | f.close(); 26 | } 27 | 28 | void GroupStatePersistence::clear(const BulbId &id) { 29 | char path[30]; 30 | buildFilename(id, path); 31 | 32 | if (SPIFFS.exists(path)) { 33 | SPIFFS.remove(path); 34 | } 35 | } 36 | 37 | char* GroupStatePersistence::buildFilename(const BulbId &id, char *buffer) { 38 | uint32_t compactId = id.getCompactId(); 39 | return buffer + sprintf(buffer, "%s%x", FILE_PREFIX, compactId); 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Chris Mullins 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/remote/helpers/mqtt_helpers.rb: -------------------------------------------------------------------------------- 1 | require 'mqtt_client' 2 | 3 | module MqttHelpers 4 | def mqtt_topic_prefix 5 | ENV.fetch('ESPMH_MQTT_TOPIC_PREFIX') 6 | end 7 | 8 | def mqtt_parameters(overrides = {}) 9 | topic_prefix = mqtt_topic_prefix() 10 | 11 | { 12 | mqtt_server: ENV.fetch('ESPMH_MQTT_SERVER'), 13 | mqtt_username: ENV.fetch('ESPMH_MQTT_USERNAME'), 14 | mqtt_password: ENV.fetch('ESPMH_MQTT_PASSWORD'), 15 | mqtt_topic_pattern: "#{topic_prefix}commands/:device_id/:device_type/:group_id", 16 | mqtt_state_topic_pattern: "#{topic_prefix}state/:device_id/:device_type/:group_id", 17 | mqtt_update_topic_pattern: "#{topic_prefix}updates/:device_id/:device_type/:group_id" 18 | }.merge(overrides) 19 | end 20 | 21 | def create_mqtt_client(overrides = {}) 22 | params = 23 | mqtt_parameters 24 | .merge({topic_prefix: mqtt_topic_prefix()}) 25 | .merge(overrides) 26 | 27 | MqttClient.new( 28 | ENV['ESPMH_LOCAL_MQTT_SERVER'] || params[:mqtt_server], 29 | params[:mqtt_username], 30 | params[:mqtt_password], 31 | params[:topic_prefix] 32 | ) 33 | end 34 | end -------------------------------------------------------------------------------- /lib/Udp/MiLightUdpServer.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #include 6 | 7 | // This protocol is documented here: 8 | // http://www.limitlessled.com/dev/ 9 | 10 | #define MILIGHT_PACKET_BUFFER_SIZE 30 11 | 12 | // Uncomment to enable Serial printing of packets 13 | // #define MILIGHT_UDP_DEBUG 14 | 15 | #ifndef _MILIGHT_UDP_SERVER 16 | #define _MILIGHT_UDP_SERVER 17 | 18 | class MiLightUdpServer { 19 | public: 20 | MiLightUdpServer(MiLightClient*& client, uint16_t port, uint16_t deviceId); 21 | virtual ~MiLightUdpServer(); 22 | 23 | void stop(); 24 | void begin(); 25 | void handleClient(); 26 | 27 | static std::shared_ptr fromVersion(uint8_t version, MiLightClient*&, uint16_t port, uint16_t deviceId); 28 | 29 | protected: 30 | WiFiUDP socket; 31 | MiLightClient*& client; 32 | uint16_t port; 33 | uint16_t deviceId; 34 | uint8_t lastGroup; 35 | uint8_t packetBuffer[MILIGHT_PACKET_BUFFER_SIZE]; 36 | uint8_t responseBuffer[MILIGHT_PACKET_BUFFER_SIZE]; 37 | 38 | // Should return size of the response packet 39 | virtual void handlePacket(uint8_t* packet, size_t packetSize) = 0; 40 | }; 41 | 42 | #endif -------------------------------------------------------------------------------- /lib/Transitions/FieldTransition.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | #pragma once 9 | 10 | class FieldTransition : public Transition { 11 | public: 12 | 13 | class Builder : public Transition::Builder { 14 | public: 15 | Builder(size_t id, const BulbId& bulbId, TransitionFn callback, GroupStateField field, uint16_t start, uint16_t end); 16 | 17 | virtual std::shared_ptr _build() const override; 18 | 19 | private: 20 | size_t stepSize; 21 | GroupStateField field; 22 | uint16_t start; 23 | uint16_t end; 24 | }; 25 | 26 | FieldTransition( 27 | size_t id, 28 | const BulbId& bulbId, 29 | GroupStateField field, 30 | uint16_t startValue, 31 | uint16_t endValue, 32 | int16_t stepSize, 33 | size_t period, 34 | TransitionFn callback 35 | ); 36 | 37 | virtual bool isFinished() override; 38 | 39 | private: 40 | const GroupStateField field; 41 | int16_t currentValue; 42 | const int16_t endValue; 43 | const int16_t stepSize; 44 | bool finished; 45 | 46 | virtual void step() override; 47 | virtual void childSerialize(JsonObject& json) override; 48 | }; -------------------------------------------------------------------------------- /lib/Types/RF24PowerLevel.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | static const char* RF24_POWER_LEVEL_NAMES[] = { 5 | "MIN", 6 | "LOW", 7 | "HIGH", 8 | "MAX" 9 | }; 10 | 11 | String RF24PowerLevelHelpers::nameFromValue(const RF24PowerLevel& value) { 12 | const size_t ix = static_cast(value); 13 | 14 | if (ix >= size(RF24_POWER_LEVEL_NAMES)) { 15 | Serial.println(F("ERROR: unknown RF24 power level label - this is a bug!")); 16 | return nameFromValue(defaultValue()); 17 | } 18 | 19 | return RF24_POWER_LEVEL_NAMES[ix]; 20 | } 21 | 22 | RF24PowerLevel RF24PowerLevelHelpers::valueFromName(const String& name) { 23 | for (size_t i = 0; i < size(RF24_POWER_LEVEL_NAMES); ++i) { 24 | if (name == RF24_POWER_LEVEL_NAMES[i]) { 25 | return static_cast(i); 26 | } 27 | } 28 | 29 | Serial.printf_P(PSTR("WARN: tried to fetch unknown RF24 power level: %s, using default.\n"), name.c_str()); 30 | 31 | return defaultValue(); 32 | } 33 | 34 | uint8_t RF24PowerLevelHelpers::rf24ValueFromValue(const RF24PowerLevel& rF24PowerLevel) { 35 | return static_cast(rF24PowerLevel); 36 | } 37 | 38 | RF24PowerLevel RF24PowerLevelHelpers::defaultValue() { 39 | return RF24PowerLevel::RF24_MAX; 40 | } -------------------------------------------------------------------------------- /lib/MiLight/PacketQueue.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | PacketQueue::PacketQueue() 4 | : droppedPackets(0) 5 | { } 6 | 7 | void PacketQueue::push(const uint8_t* packet, const MiLightRemoteConfig* remoteConfig, const size_t repeatsOverride) { 8 | std::shared_ptr qp = checkoutPacket(); 9 | memcpy(qp->packet, packet, remoteConfig->packetFormatter->getPacketLength()); 10 | qp->remoteConfig = remoteConfig; 11 | qp->repeatsOverride = repeatsOverride; 12 | } 13 | 14 | bool PacketQueue::isEmpty() const { 15 | return queue.size() == 0; 16 | } 17 | 18 | size_t PacketQueue::getDroppedPacketCount() const { 19 | return droppedPackets; 20 | } 21 | 22 | std::shared_ptr PacketQueue::pop() { 23 | return queue.shift(); 24 | } 25 | 26 | std::shared_ptr PacketQueue::checkoutPacket() { 27 | if (queue.size() == MILIGHT_MAX_QUEUED_PACKETS) { 28 | ++droppedPackets; 29 | return queue.getLast(); 30 | } else { 31 | std::shared_ptr packet = std::make_shared(); 32 | queue.add(packet); 33 | return packet; 34 | } 35 | } 36 | 37 | void PacketQueue::checkinPacket(std::shared_ptr packet) { 38 | } 39 | 40 | size_t PacketQueue::size() const { 41 | return queue.size(); 42 | } -------------------------------------------------------------------------------- /lib/Types/RF24Channel.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | static const char* RF24_CHANNEL_NAMES[] = { 5 | "LOW", 6 | "MID", 7 | "HIGH" 8 | }; 9 | 10 | String RF24ChannelHelpers::nameFromValue(const RF24Channel& value) { 11 | const size_t ix = static_cast(value); 12 | 13 | if (ix >= size(RF24_CHANNEL_NAMES)) { 14 | Serial.println(F("ERROR: unknown RF24 channel label - this is a bug!")); 15 | return nameFromValue(defaultValue()); 16 | } 17 | 18 | return RF24_CHANNEL_NAMES[ix]; 19 | } 20 | 21 | RF24Channel RF24ChannelHelpers::valueFromName(const String& name) { 22 | for (size_t i = 0; i < size(RF24_CHANNEL_NAMES); ++i) { 23 | if (name == RF24_CHANNEL_NAMES[i]) { 24 | return static_cast(i); 25 | } 26 | } 27 | 28 | Serial.printf_P(PSTR("WARN: tried to fetch unknown RF24 channel: %s, using default.\n"), name.c_str()); 29 | 30 | return defaultValue(); 31 | } 32 | 33 | RF24Channel RF24ChannelHelpers::defaultValue() { 34 | return RF24Channel::RF24_HIGH; 35 | } 36 | 37 | std::vector RF24ChannelHelpers::allValues() { 38 | std::vector vec; 39 | 40 | for (size_t i = 0; i < size(RF24_CHANNEL_NAMES); ++i) { 41 | vec.push_back(valueFromName(RF24_CHANNEL_NAMES[i])); 42 | } 43 | 44 | return vec; 45 | } -------------------------------------------------------------------------------- /lib/Radio/PL1167_nRF24.h: -------------------------------------------------------------------------------- 1 | /* 2 | * PL1167_nRF24.h 3 | * 4 | * Created on: 29 May 2015 5 | * Author: henryk 6 | */ 7 | 8 | #ifdef ARDUINO 9 | #include "Arduino.h" 10 | #endif 11 | 12 | #include "RF24.h" 13 | 14 | // #define DEBUG_PRINTF 15 | 16 | #ifndef PL1167_NRF24_H_ 17 | #define PL1167_NRF24_H_ 18 | 19 | class PL1167_nRF24 { 20 | public: 21 | PL1167_nRF24(RF24& radio); 22 | int open(); 23 | 24 | int setSyncword(const uint8_t syncword[], size_t syncwordLength); 25 | int setMaxPacketLength(uint8_t maxPacketLength); 26 | 27 | int writeFIFO(const uint8_t data[], size_t data_length); 28 | int transmit(uint8_t channel); 29 | int receive(uint8_t channel); 30 | int readFIFO(uint8_t data[], size_t &data_length); 31 | 32 | private: 33 | RF24 &_radio; 34 | 35 | const uint8_t* _syncwordBytes = nullptr; 36 | uint8_t _syncwordLength = 4; 37 | uint8_t _maxPacketLength = 8; 38 | 39 | uint8_t _channel = 0; 40 | 41 | uint8_t _nrf_pipe[5]; 42 | uint8_t _nrf_pipe_length; 43 | 44 | uint8_t _packet_length = 0; 45 | uint8_t _receive_length = 0; 46 | uint8_t _preamble = 0; 47 | uint8_t _packet[32]; 48 | bool _received = false; 49 | 50 | int recalc_parameters(); 51 | int internal_receive(); 52 | 53 | }; 54 | 55 | 56 | #endif /* PL1167_NRF24_H_ */ 57 | -------------------------------------------------------------------------------- /web/src/js/rgb2hsv.js: -------------------------------------------------------------------------------- 1 | // https://gist.github.com/mjackson/5311256 2 | function rgbToHsl(r, g, b) { 3 | r /= 255, g /= 255, b /= 255; 4 | 5 | var max = Math.max(r, g, b), min = Math.min(r, g, b); 6 | var h, s, l = (max + min) / 2; 7 | 8 | if (max == min) { 9 | h = s = 0; // achromatic 10 | } else { 11 | var d = max - min; 12 | s = l > 0.5 ? d / (2 - max - min) : d / (max + min); 13 | 14 | switch (max) { 15 | case r: h = (g - b) / d + (g < b ? 6 : 0); break; 16 | case g: h = (b - r) / d + 2; break; 17 | case b: h = (r - g) / d + 4; break; 18 | } 19 | 20 | h /= 6; 21 | } 22 | 23 | return {h: h, s: s, l: l }; 24 | } 25 | 26 | function RGBtoHSV(r, g, b) { 27 | if (arguments.length === 1) { 28 | g = r.g, b = r.b, r = r.r; 29 | } 30 | var max = Math.max(r, g, b), min = Math.min(r, g, b), 31 | d = max - min, 32 | h, 33 | s = (max === 0 ? 0 : d / max), 34 | v = max / 255; 35 | 36 | switch (max) { 37 | case min: h = 0; break; 38 | case r: h = (g - b) + d * (g < b ? 6: 0); h /= 6 * d; break; 39 | case g: h = (b - r) + d * 2; h /= 6 * d; break; 40 | case b: h = (r - g) + d * 4; h /= 6 * d; break; 41 | } 42 | 43 | return { 44 | h: h, 45 | s: s, 46 | v: v 47 | }; 48 | }; 49 | -------------------------------------------------------------------------------- /lib/Radio/NRF24MiLightRadio.h: -------------------------------------------------------------------------------- 1 | #ifdef ARDUINO 2 | #include "Arduino.h" 3 | #else 4 | #include 5 | #include 6 | #include 7 | #endif 8 | 9 | #include 10 | #include 11 | #include 12 | #include 13 | #include 14 | #include 15 | 16 | #ifndef _NRF24_MILIGHT_RADIO_H_ 17 | #define _NRF24_MILIGHT_RADIO_H_ 18 | 19 | class NRF24MiLightRadio : public MiLightRadio { 20 | public: 21 | NRF24MiLightRadio( 22 | RF24& rf, 23 | const MiLightRadioConfig& config, 24 | const std::vector& channels, 25 | RF24Channel listenChannel 26 | ); 27 | 28 | int begin(); 29 | bool available(); 30 | int read(uint8_t frame[], size_t &frame_length); 31 | int dupesReceived(); 32 | int write(uint8_t frame[], size_t frame_length); 33 | int resend(); 34 | int configure(); 35 | const MiLightRadioConfig& config(); 36 | 37 | private: 38 | const std::vector& channels; 39 | const size_t listenChannelIx; 40 | 41 | PL1167_nRF24 _pl1167; 42 | const MiLightRadioConfig& _config; 43 | uint32_t _prev_packet_id; 44 | 45 | uint8_t _packet[10]; 46 | uint8_t _out_packet[10]; 47 | bool _waiting; 48 | int _dupes_received; 49 | }; 50 | 51 | 52 | 53 | #endif 54 | -------------------------------------------------------------------------------- /lib/LEDStatus/LEDStatus.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #ifndef _LED_STATUS_H 5 | #define _LED_STATUS_H 6 | 7 | class LEDStatus { 8 | public: 9 | enum class LEDMode { 10 | Off, 11 | SlowToggle, 12 | FastToggle, 13 | SlowBlip, 14 | FastBlip, 15 | Flicker, 16 | On, 17 | Unknown 18 | }; 19 | LEDStatus(int8_t ledPin); 20 | void changePin(int8_t ledPin); 21 | void continuous(LEDMode mode); 22 | void continuous(uint16_t ledOffMs, uint16_t ledOnMs); 23 | void oneshot(LEDMode mode, uint8_t count = 1); 24 | void oneshot(uint16_t ledOffMs, uint16_t ledOnMs, uint8_t count = 1); 25 | 26 | static String LEDModeToString(LEDMode mode); 27 | static LEDMode stringToLEDMode(String mode); 28 | 29 | void handle(); 30 | 31 | private: 32 | void _modeToTime(LEDMode mode, uint16_t& ledOffMs, uint16_t& ledOnMs); 33 | uint8_t _pinState(uint8_t val); 34 | uint8_t _ledPin; 35 | bool _inverse; 36 | 37 | uint16_t _continuousOffMs = 1000; 38 | uint16_t _continuousOnMs = 0; 39 | bool _continuousCurrentlyOn = false; 40 | 41 | uint16_t _oneshotOffMs; 42 | uint16_t _oneshotOnMs; 43 | uint8_t _oneshotCountRemaining = 0; 44 | bool _oneshotCurrentlyOn = false; 45 | 46 | unsigned long _timer = 0; 47 | }; 48 | 49 | #endif -------------------------------------------------------------------------------- /lib/Udp/V6CctCommandHandler.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | bool V6CctCommandHandler::handlePreset( 4 | MiLightClient* client, 5 | uint8_t commandLsb, 6 | uint32_t commandArg) 7 | { 8 | return false; 9 | } 10 | 11 | bool V6CctCommandHandler::handleCommand( 12 | MiLightClient* client, 13 | uint32_t command, 14 | uint32_t commandArg) 15 | { 16 | const uint8_t cmd = command & 0x7F; 17 | const uint8_t arg = commandArg >> 24; 18 | 19 | client->setHeld((command & 0x80) == 0x80); 20 | 21 | if (cmd == V2_CCT_COMMAND_PREFIX) { 22 | switch (arg) { 23 | case V2_CCT_ON: 24 | client->updateStatus(ON); 25 | break; 26 | 27 | case V2_CCT_OFF: 28 | client->updateStatus(OFF); 29 | break; 30 | 31 | case V2_CCT_BRIGHTNESS_DOWN: 32 | client->decreaseBrightness(); 33 | break; 34 | 35 | case V2_CCT_BRIGHTNESS_UP: 36 | client->increaseBrightness(); 37 | break; 38 | 39 | case V2_CCT_TEMPERATURE_DOWN: 40 | client->decreaseTemperature(); 41 | break; 42 | 43 | case V2_CCT_TEMPERATURE_UP: 44 | client->increaseTemperature(); 45 | break; 46 | 47 | case V2_CCT_NIGHT_LIGHT: 48 | client->enableNightMode(); 49 | break; 50 | 51 | default: 52 | return false; 53 | } 54 | 55 | return true; 56 | } 57 | 58 | return false; 59 | } 60 | -------------------------------------------------------------------------------- /lib/Transitions/TransitionController.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | #pragma once 9 | 10 | class TransitionController { 11 | public: 12 | TransitionController(); 13 | 14 | void clearListeners(); 15 | void addListener(Transition::TransitionFn fn); 16 | 17 | std::shared_ptr buildColorTransition(const BulbId& bulbId, const ParsedColor& start, const ParsedColor& end); 18 | std::shared_ptr buildFieldTransition(const BulbId& bulbId, GroupStateField field, uint16_t start, uint16_t end); 19 | std::shared_ptr buildStatusTransition(const BulbId& bulbId, MiLightStatus toStatus, uint8_t startLevel); 20 | 21 | void addTransition(std::shared_ptr transition); 22 | void clear(); 23 | void loop(); 24 | 25 | ListNode>* getTransitions(); 26 | Transition* getTransition(size_t id); 27 | ListNode>* findTransition(size_t id); 28 | bool deleteTransition(size_t id); 29 | 30 | private: 31 | Transition::TransitionFn callback; 32 | LinkedList> activeTransitions; 33 | std::vector observers; 34 | size_t currentId; 35 | 36 | void transitionCallback(const BulbId& bulbId, GroupStateField field, uint16_t arg); 37 | }; -------------------------------------------------------------------------------- /lib/MiLight/FUT089PacketFormatter.h: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #ifndef _FUT089_PACKET_FORMATTER_H 4 | #define _FUT089_PACKET_FORMATTER_H 5 | 6 | #define FUT089_COLOR_OFFSET 0 7 | 8 | enum MiLightFUT089Command { 9 | FUT089_ON = 0x01, 10 | FUT089_OFF = 0x01, 11 | FUT089_COLOR = 0x02, 12 | FUT089_BRIGHTNESS = 0x05, 13 | FUT089_MODE = 0x06, 14 | FUT089_KELVIN = 0x07, // Controls Kelvin when in White mode 15 | FUT089_SATURATION = 0x07 // Controls Saturation when in Color mode 16 | }; 17 | 18 | enum MiLightFUT089Arguments { 19 | FUT089_MODE_SPEED_UP = 0x12, 20 | FUT089_MODE_SPEED_DOWN = 0x13, 21 | FUT089_WHITE_MODE = 0x14 22 | }; 23 | 24 | class FUT089PacketFormatter : public V2PacketFormatter { 25 | public: 26 | FUT089PacketFormatter() 27 | : V2PacketFormatter(REMOTE_TYPE_FUT089, 0x25, 8) // protocol is 0x25, and there are 8 groups 28 | { } 29 | 30 | virtual void updateBrightness(uint8_t value); 31 | virtual void updateHue(uint16_t value); 32 | virtual void updateColorRaw(uint8_t value); 33 | virtual void updateColorWhite(); 34 | virtual void updateTemperature(uint8_t value); 35 | virtual void updateSaturation(uint8_t value); 36 | virtual void enableNightMode(); 37 | 38 | virtual void modeSpeedDown(); 39 | virtual void modeSpeedUp(); 40 | virtual void updateMode(uint8_t mode); 41 | 42 | virtual BulbId parsePacket(const uint8_t* packet, JsonObject result); 43 | }; 44 | 45 | #endif 46 | -------------------------------------------------------------------------------- /lib/Udp/MiLightUdpServer.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | MiLightUdpServer::MiLightUdpServer(MiLightClient*& client, uint16_t port, uint16_t deviceId) 7 | : client(client), 8 | port(port), 9 | deviceId(deviceId), 10 | lastGroup(0) 11 | { } 12 | 13 | MiLightUdpServer::~MiLightUdpServer() { 14 | stop(); 15 | } 16 | 17 | void MiLightUdpServer::begin() { 18 | socket.begin(port); 19 | } 20 | 21 | void MiLightUdpServer::stop() { 22 | socket.stop(); 23 | } 24 | 25 | void MiLightUdpServer::handleClient() { 26 | const size_t packetSize = socket.parsePacket(); 27 | 28 | if (packetSize) { 29 | socket.read(packetBuffer, packetSize); 30 | 31 | #ifdef MILIGHT_UDP_DEBUG 32 | printf("[MiLightUdpServer port %d] - Handling packet: ", port); 33 | for (size_t i = 0; i < packetSize; i++) { 34 | printf("%02X ", packetBuffer[i]); 35 | } 36 | printf("\n"); 37 | #endif 38 | 39 | handlePacket(packetBuffer, packetSize); 40 | } 41 | } 42 | 43 | std::shared_ptr MiLightUdpServer::fromVersion(uint8_t version, MiLightClient*& client, uint16_t port, uint16_t deviceId) { 44 | if (version == 0 || version == 5) { 45 | return std::make_shared(client, port, deviceId); 46 | } else if (version == 6) { 47 | return std::make_shared(client, port, deviceId); 48 | } 49 | 50 | return NULL; 51 | } 52 | -------------------------------------------------------------------------------- /lib/MiLight/RgbPacketFormatter.h: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #ifndef _RGB_PACKET_FORMATTER_H 4 | #define _RGB_PACKET_FORMATTER_H 5 | 6 | #define RGB_COMMAND_INDEX 4 7 | #define RGB_COLOR_INDEX 3 8 | #define RGB_INTERVALS 10 9 | 10 | enum MiLightRgbButton { 11 | RGB_OFF = 0x01, 12 | RGB_ON = 0x02, 13 | RGB_BRIGHTNESS_UP = 0x03, 14 | RGB_BRIGHTNESS_DOWN = 0x04, 15 | RGB_SPEED_UP = 0x05, 16 | RGB_SPEED_DOWN = 0x06, 17 | RGB_MODE_UP = 0x07, 18 | RGB_MODE_DOWN = 0x08, 19 | RGB_PAIR = RGB_SPEED_UP 20 | }; 21 | 22 | class RgbPacketFormatter : public PacketFormatter { 23 | public: 24 | RgbPacketFormatter() 25 | : PacketFormatter(REMOTE_TYPE_RGB, 6, 20) 26 | { } 27 | 28 | virtual void updateStatus(MiLightStatus status, uint8_t groupId); 29 | virtual void updateBrightness(uint8_t value); 30 | virtual void increaseBrightness(); 31 | virtual void decreaseBrightness(); 32 | virtual void command(uint8_t command, uint8_t arg); 33 | virtual void updateHue(uint16_t value); 34 | virtual void updateColorRaw(uint8_t value); 35 | virtual void format(uint8_t const* packet, char* buffer); 36 | virtual void pair(); 37 | virtual void unpair(); 38 | virtual void modeSpeedDown(); 39 | virtual void modeSpeedUp(); 40 | virtual void nextMode(); 41 | virtual void previousMode(); 42 | virtual BulbId parsePacket(const uint8_t* packet, JsonObject result); 43 | 44 | virtual void initializePacket(uint8_t* packet); 45 | }; 46 | 47 | #endif 48 | -------------------------------------------------------------------------------- /lib/Udp/V6RgbCommandHandler.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | bool V6RgbCommandHandler::handlePreset( 4 | MiLightClient* client, 5 | uint8_t commandLsb, 6 | uint32_t commandArg) 7 | { return true; } 8 | 9 | bool V6RgbCommandHandler::handleCommand( 10 | MiLightClient* client, 11 | uint32_t command, 12 | uint32_t commandArg) 13 | { 14 | const uint8_t cmd = command & 0x7F; 15 | const uint8_t arg = commandArg >> 24; 16 | 17 | client->setHeld((command & 0x80) == 0x80); 18 | 19 | if (cmd == V2_RGB_COMMAND_PREFIX) { 20 | switch (arg) { 21 | case V2_RGB_ON: 22 | client->updateStatus(ON); 23 | break; 24 | 25 | case V2_RGB_OFF: 26 | client->updateStatus(OFF); 27 | break; 28 | 29 | case V2_RGB_BRIGHTNESS_DOWN: 30 | client->decreaseBrightness(); 31 | break; 32 | 33 | case V2_RGB_BRIGHTNESS_UP: 34 | client->increaseBrightness(); 35 | break; 36 | 37 | case V2_RGB_MODE_DOWN: 38 | client->previousMode(); 39 | break; 40 | 41 | case V2_RGB_MODE_UP: 42 | client->nextMode(); 43 | break; 44 | 45 | case V2_RGB_SPEED_DOWN: 46 | client->modeSpeedDown(); 47 | break; 48 | 49 | case V2_RGB_SPEED_UP: 50 | client->modeSpeedUp(); 51 | break; 52 | 53 | default: 54 | return false; 55 | } 56 | 57 | return true; 58 | } else if (cmd == V2_RGB_COLOR_PREFIX) { 59 | client->updateColorRaw(arg); 60 | return true; 61 | } 62 | 63 | return false; 64 | } 65 | -------------------------------------------------------------------------------- /lib/Radio/MiLightRadioFactory.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | std::shared_ptr MiLightRadioFactory::fromSettings(const Settings& settings) { 4 | switch (settings.radioInterfaceType) { 5 | case nRF24: 6 | return std::make_shared( 7 | settings.csnPin, 8 | settings.cePin, 9 | settings.rf24PowerLevel, 10 | settings.rf24Channels, 11 | settings.rf24ListenChannel 12 | ); 13 | 14 | case LT8900: 15 | return std::make_shared(settings.csnPin, settings.resetPin, settings.cePin); 16 | 17 | default: 18 | return NULL; 19 | } 20 | } 21 | 22 | NRF24Factory::NRF24Factory( 23 | uint8_t csnPin, 24 | uint8_t cePin, 25 | RF24PowerLevel rF24PowerLevel, 26 | const std::vector& channels, 27 | RF24Channel listenChannel 28 | ) 29 | : rf24(RF24(cePin, csnPin)), 30 | channels(channels), 31 | listenChannel(listenChannel) 32 | { 33 | rf24.setPALevel(RF24PowerLevelHelpers::rf24ValueFromValue(rF24PowerLevel)); 34 | } 35 | 36 | std::shared_ptr NRF24Factory::create(const MiLightRadioConfig &config) { 37 | return std::make_shared(rf24, config, channels, listenChannel); 38 | } 39 | 40 | LT8900Factory::LT8900Factory(uint8_t csPin, uint8_t resetPin, uint8_t pktFlag) 41 | : _csPin(csPin), 42 | _resetPin(resetPin), 43 | _pktFlag(pktFlag) 44 | { } 45 | 46 | std::shared_ptr LT8900Factory::create(const MiLightRadioConfig& config) { 47 | return std::make_shared(_csPin, _resetPin, _pktFlag, config); 48 | } 49 | -------------------------------------------------------------------------------- /lib/Radio/MiLightRadioFactory.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | 13 | #ifndef _MILIGHT_RADIO_FACTORY_H 14 | #define _MILIGHT_RADIO_FACTORY_H 15 | 16 | class MiLightRadioFactory { 17 | public: 18 | 19 | virtual ~MiLightRadioFactory() { }; 20 | virtual std::shared_ptr create(const MiLightRadioConfig& config) = 0; 21 | 22 | static std::shared_ptr fromSettings(const Settings& settings); 23 | 24 | }; 25 | 26 | class NRF24Factory : public MiLightRadioFactory { 27 | public: 28 | 29 | NRF24Factory( 30 | uint8_t cePin, 31 | uint8_t csnPin, 32 | RF24PowerLevel rF24PowerLevel, 33 | const std::vector& channels, 34 | RF24Channel listenChannel 35 | ); 36 | 37 | virtual std::shared_ptr create(const MiLightRadioConfig& config); 38 | 39 | protected: 40 | 41 | RF24 rf24; 42 | const std::vector& channels; 43 | const RF24Channel listenChannel; 44 | 45 | }; 46 | 47 | class LT8900Factory : public MiLightRadioFactory { 48 | public: 49 | 50 | LT8900Factory(uint8_t csPin, uint8_t resetPin, uint8_t pktFlag); 51 | 52 | virtual std::shared_ptr create(const MiLightRadioConfig& config); 53 | 54 | protected: 55 | 56 | uint8_t _csPin; 57 | uint8_t _resetPin; 58 | uint8_t _pktFlag; 59 | 60 | }; 61 | 62 | #endif 63 | -------------------------------------------------------------------------------- /lib/Helpers/JsonHelpers.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | #ifndef _JSON_HELPERS_H 7 | #define _JSON_HELPERS_H 8 | 9 | class JsonHelpers { 10 | public: 11 | template 12 | static void copyFrom(JsonArray arr, std::vector vec) { 13 | for (typename std::vector::const_iterator it = vec.begin(); it != vec.end(); ++it) { 14 | arr.add(*it); 15 | } 16 | } 17 | 18 | template 19 | static void copyTo(JsonArray arr, std::vector vec) { 20 | for (size_t i = 0; i < arr.size(); ++i) { 21 | JsonVariant val = arr[i]; 22 | vec.push_back(val.as()); 23 | } 24 | } 25 | 26 | template 27 | static std::vector jsonArrToVector(JsonArray& arr, std::function converter, const bool unique = true) { 28 | std::vector vec; 29 | 30 | for (size_t i = 0; i < arr.size(); ++i) { 31 | StrType strVal = arr[i]; 32 | T convertedVal = converter(strVal); 33 | 34 | // inefficient, but everything using this is tiny, so doesn't matter 35 | if (!unique || std::find(vec.begin(), vec.end(), convertedVal) == vec.end()) { 36 | vec.push_back(convertedVal); 37 | } 38 | } 39 | 40 | return vec; 41 | } 42 | 43 | template 44 | static void vectorToJsonArr(JsonArray& arr, const std::vector& vec, std::function converter) { 45 | for (typename std::vector::const_iterator it = vec.begin(); it != vec.end(); ++it) { 46 | arr.add(converter(*it)); 47 | } 48 | } 49 | }; 50 | 51 | #endif -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - '2.7' 4 | sudo: false 5 | cache: 6 | directories: 7 | - "~/.platformio" 8 | env: 9 | - NODE_VERSION="10" 10 | before_install: 11 | - nvm install $NODE_VERSION 12 | install: 13 | - pip install -U platformio 14 | - platformio lib install 15 | - cd web && npm install && cd .. 16 | - npm install -g swagger-cli redoc-cli 17 | script: 18 | - swagger-cli validate ./docs/openapi.yaml 19 | - platformio run 20 | before_deploy: 21 | - ./.prepare_release 22 | - ./.prepare_docs 23 | deploy: 24 | - provider: releases 25 | prerelease: true 26 | api_key: 27 | 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= 28 | file_glob: true 29 | skip_cleanup: true 30 | file: dist/*.bin 31 | on: 32 | repo: sidoh/esp8266_milight_hub 33 | tags: true 34 | - provider: pages 35 | skip_cleanup: true 36 | local_dir: dist/docs 37 | github_token: $GITHUB_TOKEN 38 | keep_history: true 39 | on: 40 | repo: sidoh/esp8266_milight_hub 41 | tags: true -------------------------------------------------------------------------------- /lib/MiLightState/GroupStateStore.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #ifndef _GROUP_STATE_STORE_H 6 | #define _GROUP_STATE_STORE_H 7 | 8 | class GroupStateStore { 9 | public: 10 | GroupStateStore(const size_t maxSize, const size_t flushRate); 11 | 12 | /* 13 | * Returns the state for the given BulbId. If accessing state for a valid device 14 | * (i.e., NOT group 0) and no state exists, its state will be initialized with a 15 | * default. 16 | * 17 | * Otherwise, we return NULL. 18 | */ 19 | GroupState* get(const BulbId& id); 20 | GroupState* get(const uint16_t deviceId, const uint8_t groupId, const MiLightRemoteType deviceType); 21 | 22 | /* 23 | * Sets the state for the given BulbId. State will be marked as dirty and 24 | * flushed to persistent storage. 25 | */ 26 | GroupState* set(const BulbId& id, const GroupState& state); 27 | GroupState* set(const uint16_t deviceId, const uint8_t groupId, const MiLightRemoteType deviceType, const GroupState& state); 28 | 29 | void clear(const BulbId& id); 30 | 31 | /* 32 | * Flushes all states to persistent storage. Returns true iff anything was 33 | * flushed. 34 | */ 35 | bool flush(); 36 | 37 | /* 38 | * Flushes at most one dirty state to persistent storage. Rate limit 39 | * specified by Settings. 40 | */ 41 | void limitedFlush(); 42 | 43 | private: 44 | GroupStateCache cache; 45 | GroupStatePersistence persistence; 46 | LinkedList evictedIds; 47 | const size_t flushRate; 48 | unsigned long lastFlush; 49 | 50 | void trackEviction(); 51 | }; 52 | 53 | #endif 54 | -------------------------------------------------------------------------------- /lib/Types/GroupStateField.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | static const char* STATE_NAMES[] = { 5 | GroupStateFieldNames::UNKNOWN, 6 | GroupStateFieldNames::STATE, 7 | GroupStateFieldNames::STATUS, 8 | GroupStateFieldNames::BRIGHTNESS, 9 | GroupStateFieldNames::LEVEL, 10 | GroupStateFieldNames::HUE, 11 | GroupStateFieldNames::SATURATION, 12 | GroupStateFieldNames::COLOR, 13 | GroupStateFieldNames::MODE, 14 | GroupStateFieldNames::KELVIN, 15 | GroupStateFieldNames::COLOR_TEMP, 16 | GroupStateFieldNames::BULB_MODE, 17 | GroupStateFieldNames::COMPUTED_COLOR, 18 | GroupStateFieldNames::EFFECT, 19 | GroupStateFieldNames::DEVICE_ID, 20 | GroupStateFieldNames::GROUP_ID, 21 | GroupStateFieldNames::DEVICE_TYPE, 22 | GroupStateFieldNames::OH_COLOR, 23 | GroupStateFieldNames::HEX_COLOR 24 | }; 25 | 26 | GroupStateField GroupStateFieldHelpers::getFieldByName(const char* name) { 27 | for (size_t i = 0; i < size(STATE_NAMES); i++) { 28 | if (0 == strcmp(name, STATE_NAMES[i])) { 29 | return static_cast(i); 30 | } 31 | } 32 | return GroupStateField::UNKNOWN; 33 | } 34 | 35 | const char* GroupStateFieldHelpers::getFieldName(GroupStateField field) { 36 | for (size_t i = 0; i < size(STATE_NAMES); i++) { 37 | if (field == static_cast(i)) { 38 | return STATE_NAMES[i]; 39 | } 40 | } 41 | return STATE_NAMES[0]; 42 | } 43 | 44 | bool GroupStateFieldHelpers::isBrightnessField(GroupStateField field) { 45 | switch (field) { 46 | case GroupStateField::BRIGHTNESS: 47 | case GroupStateField::LEVEL: 48 | return true; 49 | default: 50 | return false; 51 | } 52 | } -------------------------------------------------------------------------------- /lib/MiLight/RgbCctPacketFormatter.h: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #ifndef _RGB_CCT_PACKET_FORMATTER_H 4 | #define _RGB_CCT_PACKET_FORMATTER_H 5 | 6 | #define RGB_CCT_NUM_MODES 9 7 | 8 | #define RGB_CCT_COLOR_OFFSET 0x5F 9 | #define RGB_CCT_BRIGHTNESS_OFFSET 0x8F 10 | #define RGB_CCT_SATURATION_OFFSET 0xD 11 | #define RGB_CCT_KELVIN_OFFSET 0x94 12 | 13 | // Remotes have a larger range 14 | #define RGB_CCT_KELVIN_REMOTE_START 0x94 15 | #define RGB_CCT_KELVIN_REMOTE_END 0xCC 16 | 17 | enum MiLightRgbCctCommand { 18 | RGB_CCT_ON = 0x01, 19 | RGB_CCT_OFF = 0x01, 20 | RGB_CCT_COLOR = 0x02, 21 | RGB_CCT_KELVIN = 0x03, 22 | RGB_CCT_BRIGHTNESS = 0x04, 23 | RGB_CCT_SATURATION = 0x04, 24 | RGB_CCT_MODE = 0x05 25 | }; 26 | 27 | enum MiLightRgbCctArguments { 28 | RGB_CCT_MODE_SPEED_UP = 0x0A, 29 | RGB_CCT_MODE_SPEED_DOWN = 0x0B 30 | }; 31 | 32 | class RgbCctPacketFormatter : public V2PacketFormatter { 33 | public: 34 | RgbCctPacketFormatter() 35 | : V2PacketFormatter(REMOTE_TYPE_RGB_CCT, 0x20, 4), 36 | lastMode(0) 37 | { } 38 | 39 | virtual void updateBrightness(uint8_t value); 40 | virtual void updateHue(uint16_t value); 41 | virtual void updateColorRaw(uint8_t value); 42 | virtual void updateColorWhite(); 43 | virtual void updateTemperature(uint8_t value); 44 | virtual void updateSaturation(uint8_t value); 45 | virtual void enableNightMode(); 46 | 47 | virtual void modeSpeedDown(); 48 | virtual void modeSpeedUp(); 49 | virtual void updateMode(uint8_t mode); 50 | virtual void nextMode(); 51 | virtual void previousMode(); 52 | 53 | virtual BulbId parsePacket(const uint8_t* packet, JsonObject result); 54 | 55 | protected: 56 | 57 | uint8_t lastMode; 58 | }; 59 | 60 | #endif 61 | -------------------------------------------------------------------------------- /.build_web.py: -------------------------------------------------------------------------------- 1 | from shutil import copyfile 2 | from subprocess import check_output, CalledProcessError 3 | import sys 4 | import os 5 | import platform 6 | import subprocess 7 | 8 | Import("env") 9 | 10 | def is_tool(name): 11 | cmd = "where" if platform.system() == "Windows" else "which" 12 | try: 13 | check_output([cmd, name]) 14 | return True 15 | except: 16 | return False; 17 | 18 | def build_web(): 19 | if is_tool("npm"): 20 | os.chdir("web") 21 | print("Attempting to build webpage...") 22 | try: 23 | if platform.system() == "Windows": 24 | print check_output(["npm.cmd", "install", "--only=dev"]) 25 | print check_output(["node_modules\\.bin\\gulp.cmd"]) 26 | else: 27 | print check_output(["npm", "install"]) 28 | print check_output(["node_modules/.bin/gulp"]) 29 | copyfile("build/index.html.gz.h", "../dist/index.html.gz.h") 30 | except OSError as e: 31 | print "Encountered error OSError building webpage:", e 32 | if e.filename: 33 | print "Filename is", e.filename 34 | print "WARNING: Failed to build web package. Using pre-built page." 35 | except CalledProcessError as e: 36 | print e.output 37 | print "Encountered error CalledProcessError building webpage:", e 38 | print "WARNING: Failed to build web package. Using pre-built page." 39 | except Exception as e: 40 | print "Encountered error", type(e).__name__, "building webpage:", e 41 | print "WARNING: Failed to build web package. Using pre-built page." 42 | finally: 43 | os.chdir(".."); 44 | 45 | build_web() 46 | -------------------------------------------------------------------------------- /lib/MiLight/FUT02xPacketFormatter.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | static const uint8_t FUT02X_PACKET_HEADER = 0xA5; 4 | 5 | static const uint8_t FUT02X_PAIR_COMMAND = 0x03; 6 | static const uint8_t FUT02X_UNPAIR_COMMAND = 0x03; 7 | 8 | void FUT02xPacketFormatter::initializePacket(uint8_t *packet) { 9 | size_t packetPtr = 0; 10 | 11 | packet[packetPtr++] = 0xA5; 12 | packet[packetPtr++] = deviceId >> 8; 13 | packet[packetPtr++] = deviceId & 0xFF; 14 | packet[packetPtr++] = 0; // arg 15 | packet[packetPtr++] = 0; // command 16 | packet[packetPtr++] = sequenceNum++; 17 | } 18 | 19 | bool FUT02xPacketFormatter::canHandle(const uint8_t* packet, const size_t len) { 20 | return len == packetLength && packet[0] == FUT02X_PACKET_HEADER; 21 | } 22 | 23 | void FUT02xPacketFormatter::command(uint8_t command, uint8_t arg) { 24 | pushPacket(); 25 | if (held) { 26 | command |= 0x10; 27 | } 28 | currentPacket[FUT02X_COMMAND_INDEX] = command; 29 | currentPacket[FUT02X_ARGUMENT_INDEX] = arg; 30 | } 31 | 32 | void FUT02xPacketFormatter::pair() { 33 | for (size_t i = 0; i < 5; i++) { 34 | command(FUT02X_PAIR_COMMAND, 0); 35 | } 36 | } 37 | 38 | void FUT02xPacketFormatter::unpair() { 39 | for (size_t i = 0; i < 5; i++) { 40 | command(FUT02X_PAIR_COMMAND, 0); 41 | } 42 | } 43 | 44 | void FUT02xPacketFormatter::format(uint8_t const* packet, char* buffer) { 45 | buffer += sprintf_P(buffer, PSTR("b0 : %02X\n"), packet[0]); 46 | buffer += sprintf_P(buffer, PSTR("ID : %02X%02X\n"), packet[1], packet[2]); 47 | buffer += sprintf_P(buffer, PSTR("Arg : %02X\n"), packet[3]); 48 | buffer += sprintf_P(buffer, PSTR("Command : %02X\n"), packet[4]); 49 | buffer += sprintf_P(buffer, PSTR("Sequence : %02X\n"), packet[5]); 50 | } -------------------------------------------------------------------------------- /lib/MQTT/BulbStateUpdater.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | BulbStateUpdater::BulbStateUpdater(Settings& settings, MqttClient& mqttClient, GroupStateStore& stateStore) 4 | : settings(settings), 5 | mqttClient(mqttClient), 6 | stateStore(stateStore), 7 | lastFlush(0), 8 | enabled(true) 9 | { } 10 | 11 | void BulbStateUpdater::enable() { 12 | this->enabled = true; 13 | } 14 | 15 | void BulbStateUpdater::disable() { 16 | this->enabled = false; 17 | } 18 | 19 | void BulbStateUpdater::enqueueUpdate(BulbId bulbId, GroupState& groupState) { 20 | // If can flush immediately, do so (avoids lookup of group state later). 21 | if (canFlush()) { 22 | flushGroup(bulbId, groupState); 23 | } else { 24 | staleGroups.push(bulbId); 25 | } 26 | } 27 | 28 | void BulbStateUpdater::loop() { 29 | while (canFlush() && staleGroups.size() > 0) { 30 | BulbId bulbId = staleGroups.shift(); 31 | GroupState* groupState = stateStore.get(bulbId); 32 | 33 | if (groupState->isMqttDirty()) { 34 | flushGroup(bulbId, *groupState); 35 | groupState->clearMqttDirty(); 36 | } 37 | } 38 | } 39 | 40 | inline void BulbStateUpdater::flushGroup(BulbId bulbId, GroupState& state) { 41 | char buffer[200]; 42 | StaticJsonDocument<200> json; 43 | JsonObject message = json.to(); 44 | 45 | state.applyState(message, bulbId, settings.groupStateFields); 46 | serializeJson(json, buffer); 47 | 48 | mqttClient.sendState( 49 | *MiLightRemoteConfig::fromType(bulbId.deviceType), 50 | bulbId.deviceId, 51 | bulbId.groupId, 52 | buffer 53 | ); 54 | 55 | lastFlush = millis(); 56 | } 57 | 58 | inline bool BulbStateUpdater::canFlush() const { 59 | return enabled && (millis() > (lastFlush + settings.mqttStateRateLimit)); 60 | } 61 | -------------------------------------------------------------------------------- /lib/Helpers/IntParsing.h: -------------------------------------------------------------------------------- 1 | #ifndef _INTPARSING_H 2 | #define _INTPARSING_H 3 | 4 | #include 5 | 6 | template 7 | const T strToHex(const char* s, size_t length) { 8 | T value = 0; 9 | T base = 1; 10 | 11 | for (int i = length-1; i >= 0; i--) { 12 | const char c = s[i]; 13 | 14 | if (c >= '0' && c <= '9') { 15 | value += ((c - '0') * base); 16 | } else if (c >= 'a' && c <= 'f') { 17 | value += ((c - 'a' + 10) * base); 18 | } else if (c >= 'A' && c <= 'F') { 19 | value += ((c - 'A' + 10) * base); 20 | } else { 21 | break; 22 | } 23 | 24 | base <<= 4; 25 | } 26 | 27 | return value; 28 | } 29 | 30 | template 31 | const T strToHex(const String& s) { 32 | return strToHex(s.c_str(), s.length()); 33 | } 34 | 35 | template 36 | const T parseInt(const String& s) { 37 | if (s.startsWith("0x")) { 38 | return strToHex(s.substring(2)); 39 | } else { 40 | return s.toInt(); 41 | } 42 | } 43 | 44 | template 45 | void hexStrToBytes(const char* s, const size_t sLen, T* buffer, size_t maxLen) { 46 | int idx = 0; 47 | 48 | for (int i = 0; i < sLen && idx < maxLen; ) { 49 | buffer[idx++] = strToHex(s+i, 2); 50 | i+= 2; 51 | 52 | while (i < (sLen - 1) && s[i] == ' ') { 53 | i++; 54 | } 55 | } 56 | } 57 | 58 | class IntParsing { 59 | public: 60 | static void bytesToHexStr(const uint8_t* bytes, const size_t len, char* buffer, size_t maxLen) { 61 | char* p = buffer; 62 | 63 | for (size_t i = 0; i < len && static_cast(p - buffer) < (maxLen - 3); i++) { 64 | p += sprintf(p, "%02X", bytes[i]); 65 | 66 | if (i < (len - 1)) { 67 | p += sprintf(p, " "); 68 | } 69 | } 70 | } 71 | }; 72 | 73 | #endif 74 | -------------------------------------------------------------------------------- /lib/Transitions/ColorTransition.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #pragma once 5 | 6 | class ColorTransition : public Transition { 7 | public: 8 | struct RgbColor { 9 | RgbColor(); 10 | RgbColor(const ParsedColor& color); 11 | RgbColor(int16_t r, int16_t g, int16_t b); 12 | bool operator==(const RgbColor& other); 13 | 14 | int16_t r, g, b; 15 | }; 16 | 17 | class Builder : public Transition::Builder { 18 | public: 19 | Builder(size_t id, const BulbId& bulbId, TransitionFn callback, const ParsedColor& start, const ParsedColor& end); 20 | 21 | virtual std::shared_ptr _build() const override; 22 | 23 | private: 24 | const ParsedColor& start; 25 | const ParsedColor& end; 26 | RgbColor stepSizes; 27 | }; 28 | 29 | ColorTransition( 30 | size_t id, 31 | const BulbId& bulbId, 32 | const ParsedColor& startColor, 33 | const ParsedColor& endColor, 34 | RgbColor stepSizes, 35 | size_t duration, 36 | size_t period, 37 | size_t numPeriods, 38 | TransitionFn callback 39 | ); 40 | 41 | static size_t calculateColorPeriod(ColorTransition* t, const ParsedColor& start, const ParsedColor& end, size_t stepSize, size_t duration); 42 | inline static int16_t calculateStepSizePart(int16_t distance, size_t duration, size_t period); 43 | virtual bool isFinished() override; 44 | 45 | protected: 46 | const RgbColor endColor; 47 | RgbColor currentColor; 48 | RgbColor stepSizes; 49 | 50 | // Store these to avoid wasted packets 51 | uint16_t lastHue; 52 | uint16_t lastSaturation; 53 | bool finished; 54 | 55 | virtual void step() override; 56 | virtual void childSerialize(JsonObject& json) override; 57 | static inline void stepPart(uint16_t& current, uint16_t end, int16_t step); 58 | }; -------------------------------------------------------------------------------- /lib/Types/GroupStateField.h: -------------------------------------------------------------------------------- 1 | #ifndef _GROUP_STATE_FIELDS_H 2 | #define _GROUP_STATE_FIELDS_H 3 | 4 | namespace GroupStateFieldNames { 5 | static const char UNKNOWN[] = "unknown"; 6 | static const char STATE[] = "state"; 7 | static const char STATUS[] = "status"; 8 | static const char BRIGHTNESS[] = "brightness"; 9 | static const char LEVEL[] = "level"; 10 | static const char HUE[] = "hue"; 11 | static const char SATURATION[] = "saturation"; 12 | static const char COLOR[] = "color"; 13 | static const char MODE[] = "mode"; 14 | static const char KELVIN[] = "kelvin"; 15 | static const char TEMPERATURE[] = "temperature"; //alias for kelvin 16 | static const char COLOR_TEMP[] = "color_temp"; 17 | static const char BULB_MODE[] = "bulb_mode"; 18 | static const char COMPUTED_COLOR[] = "computed_color"; 19 | static const char EFFECT[] = "effect"; 20 | static const char DEVICE_ID[] = "device_id"; 21 | static const char GROUP_ID[] = "group_id"; 22 | static const char DEVICE_TYPE[] = "device_type"; 23 | static const char OH_COLOR[] = "oh_color"; 24 | static const char HEX_COLOR[] = "hex_color"; 25 | static const char COMMAND[] = "command"; 26 | static const char COMMANDS[] = "commands"; 27 | }; 28 | 29 | enum class GroupStateField { 30 | UNKNOWN, 31 | STATE, 32 | STATUS, 33 | BRIGHTNESS, 34 | LEVEL, 35 | HUE, 36 | SATURATION, 37 | COLOR, 38 | MODE, 39 | KELVIN, 40 | COLOR_TEMP, 41 | BULB_MODE, 42 | COMPUTED_COLOR, 43 | EFFECT, 44 | DEVICE_ID, 45 | GROUP_ID, 46 | DEVICE_TYPE, 47 | OH_COLOR, 48 | HEX_COLOR 49 | }; 50 | 51 | class GroupStateFieldHelpers { 52 | public: 53 | static const char* getFieldName(GroupStateField field); 54 | static GroupStateField getFieldByName(const char* name); 55 | static bool isBrightnessField(GroupStateField field); 56 | }; 57 | 58 | #endif 59 | -------------------------------------------------------------------------------- /lib/MQTT/MqttClient.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | 7 | #ifndef MQTT_CONNECTION_ATTEMPT_FREQUENCY 8 | #define MQTT_CONNECTION_ATTEMPT_FREQUENCY 5000 9 | #endif 10 | 11 | #ifndef MQTT_PACKET_CHUNK_SIZE 12 | #define MQTT_PACKET_CHUNK_SIZE 128 13 | #endif 14 | 15 | #ifndef _MQTT_CLIENT_H 16 | #define _MQTT_CLIENT_H 17 | 18 | class MqttClient { 19 | public: 20 | using OnConnectFn = std::function; 21 | 22 | MqttClient(Settings& settings, MiLightClient*& milightClient); 23 | ~MqttClient(); 24 | 25 | void begin(); 26 | void handleClient(); 27 | void reconnect(); 28 | void sendUpdate(const MiLightRemoteConfig& remoteConfig, uint16_t deviceId, uint16_t groupId, const char* update); 29 | void sendState(const MiLightRemoteConfig& remoteConfig, uint16_t deviceId, uint16_t groupId, const char* update); 30 | void send(const char* topic, const char* message, const bool retain = false); 31 | void onConnect(OnConnectFn fn); 32 | 33 | String bindTopicString(const String& topicPattern, const BulbId& bulbId); 34 | 35 | private: 36 | WiFiClient tcpClient; 37 | PubSubClient mqttClient; 38 | MiLightClient*& milightClient; 39 | Settings& settings; 40 | char* domain; 41 | unsigned long lastConnectAttempt; 42 | OnConnectFn onConnectFn; 43 | bool connected; 44 | 45 | void sendBirthMessage(); 46 | bool connect(); 47 | void subscribe(); 48 | void publishCallback(char* topic, byte* payload, int length); 49 | void publish( 50 | const String& topic, 51 | const MiLightRemoteConfig& remoteConfig, 52 | uint16_t deviceId, 53 | uint16_t groupId, 54 | const char* update, 55 | const bool retain = false 56 | ); 57 | 58 | String generateConnectionStatusMessage(const char* status); 59 | }; 60 | 61 | #endif 62 | -------------------------------------------------------------------------------- /lib/MiLightState/GroupStateCache.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | GroupStateCache::GroupStateCache(const size_t maxSize) 4 | : maxSize(maxSize) 5 | { } 6 | 7 | GroupStateCache::~GroupStateCache() { 8 | ListNode* cur = cache.getHead(); 9 | 10 | while (cur != NULL) { 11 | delete cur->data; 12 | cur = cur->next; 13 | } 14 | } 15 | 16 | GroupState* GroupStateCache::get(const BulbId& id) { 17 | return getInternal(id); 18 | } 19 | 20 | GroupState* GroupStateCache::set(const BulbId& id, const GroupState& state) { 21 | GroupCacheNode* pushedNode = NULL; 22 | if (cache.size() >= maxSize) { 23 | pushedNode = cache.pop(); 24 | } 25 | 26 | GroupState* cachedState = getInternal(id); 27 | 28 | if (cachedState == NULL) { 29 | if (pushedNode == NULL) { 30 | GroupCacheNode* newNode = new GroupCacheNode(id, state); 31 | cachedState = &newNode->state; 32 | cache.unshift(newNode); 33 | } else { 34 | pushedNode->id = id; 35 | pushedNode->state = state; 36 | cachedState = &pushedNode->state; 37 | cache.unshift(pushedNode); 38 | } 39 | } else { 40 | *cachedState = state; 41 | } 42 | 43 | return cachedState; 44 | } 45 | 46 | BulbId GroupStateCache::getLru() { 47 | GroupCacheNode* node = cache.getLast(); 48 | return node->id; 49 | } 50 | 51 | bool GroupStateCache::isFull() const { 52 | return cache.size() >= maxSize; 53 | } 54 | 55 | ListNode* GroupStateCache::getHead() { 56 | return cache.getHead(); 57 | } 58 | 59 | GroupState* GroupStateCache::getInternal(const BulbId& id) { 60 | ListNode* cur = cache.getHead(); 61 | 62 | while (cur != NULL) { 63 | if (cur->data->id == id) { 64 | GroupState* result = &cur->data->state; 65 | cache.spliceToFront(cur); 66 | return result; 67 | } 68 | cur = cur->next; 69 | } 70 | 71 | return NULL; 72 | } 73 | -------------------------------------------------------------------------------- /lib/MiLight/MiLightRemoteConfig.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | 13 | #ifndef _MILIGHT_REMOTE_CONFIG_H 14 | #define _MILIGHT_REMOTE_CONFIG_H 15 | 16 | class MiLightRemoteConfig { 17 | public: 18 | MiLightRemoteConfig( 19 | PacketFormatter* packetFormatter, 20 | MiLightRadioConfig& radioConfig, 21 | const MiLightRemoteType type, 22 | const String name, 23 | const size_t numGroups 24 | ) : packetFormatter(packetFormatter), 25 | radioConfig(radioConfig), 26 | type(type), 27 | name(name), 28 | numGroups(numGroups) 29 | { } 30 | 31 | PacketFormatter* const packetFormatter; 32 | const MiLightRadioConfig& radioConfig; 33 | const MiLightRemoteType type; 34 | const String name; 35 | const size_t numGroups; 36 | 37 | static const MiLightRemoteConfig* fromType(MiLightRemoteType type); 38 | static const MiLightRemoteConfig* fromType(const String& type); 39 | static const MiLightRemoteConfig* fromReceivedPacket(const MiLightRadioConfig& radioConfig, const uint8_t* packet, const size_t len); 40 | 41 | static const size_t NUM_REMOTES; 42 | static const MiLightRemoteConfig* ALL_REMOTES[]; 43 | }; 44 | 45 | extern const MiLightRemoteConfig FUT096Config; //rgbw 46 | extern const MiLightRemoteConfig FUT007Config; //cct 47 | extern const MiLightRemoteConfig FUT092Config; //rgb+cct 48 | extern const MiLightRemoteConfig FUT089Config; //rgb+cct B8 / FUT089 49 | extern const MiLightRemoteConfig FUT098Config; //rgb 50 | extern const MiLightRemoteConfig FUT091Config; //v2 cct 51 | extern const MiLightRemoteConfig FUT020Config; 52 | 53 | #endif 54 | -------------------------------------------------------------------------------- /lib/Udp/V6RgbwCommandHandler.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | bool V6RgbwCommandHandler::handlePreset( 4 | MiLightClient* client, 5 | uint8_t commandLsb, 6 | uint32_t commandArg) 7 | { 8 | if (commandLsb == 0) { 9 | client->updateColorRaw(commandArg >> 24); 10 | client->updateBrightness(commandArg >> 16); 11 | } else if (commandLsb == 1) { 12 | client->updateColorWhite(); 13 | client->updateBrightness(commandArg >> 16); 14 | } else { 15 | return false; 16 | } 17 | 18 | return true; 19 | } 20 | 21 | bool V6RgbwCommandHandler::handleCommand( 22 | MiLightClient* client, 23 | uint32_t command, 24 | uint32_t commandArg) 25 | { 26 | const uint8_t cmd = command & 0x7F; 27 | const uint8_t arg = commandArg >> 24; 28 | 29 | client->setHeld((command & 0x80) == 0x80); 30 | 31 | if (cmd == V2_RGBW_COMMAND_PREFIX) { 32 | switch (arg) { 33 | case V2_RGBW_ON: 34 | client->updateStatus(ON); 35 | break; 36 | 37 | case V2_RGBW_OFF: 38 | client->updateStatus(OFF); 39 | break; 40 | 41 | case V2_RGBW_WHITE_ON: 42 | client->updateColorWhite(); 43 | break; 44 | 45 | case V2_RGBW_NIGHT_LIGHT: 46 | client->enableNightMode(); 47 | break; 48 | 49 | case V2_RGBW_SPEED_DOWN: 50 | client->modeSpeedDown(); 51 | break; 52 | 53 | case V2_RGBW_SPEED_UP: 54 | client->modeSpeedUp(); 55 | break; 56 | 57 | default: 58 | return false; 59 | } 60 | 61 | return true; 62 | } else if (cmd == V2_RGBW_COLOR_PREFIX) { 63 | client->updateColorRaw(arg); 64 | return true; 65 | } else if (cmd == V2_RGBW_BRIGHTNESS_PREFIX) { 66 | client->updateBrightness(arg); 67 | return true; 68 | } else if (cmd == V2_RGBW_MODE_PREFIX) { 69 | client->updateMode(arg); 70 | return true; 71 | } 72 | 73 | return false; 74 | } 75 | -------------------------------------------------------------------------------- /lib/MiLight/CctPacketFormatter.h: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #ifndef _CCT_PACKET_FORMATTER_H 4 | #define _CCT_PACKET_FORMATTER_H 5 | 6 | #define CCT_COMMAND_INDEX 4 7 | #define CCT_INTERVALS 10 8 | 9 | enum MiLightCctButton { 10 | CCT_ALL_ON = 0x05, 11 | CCT_ALL_OFF = 0x09, 12 | CCT_GROUP_1_ON = 0x08, 13 | CCT_GROUP_1_OFF = 0x0B, 14 | CCT_GROUP_2_ON = 0x0D, 15 | CCT_GROUP_2_OFF = 0x03, 16 | CCT_GROUP_3_ON = 0x07, 17 | CCT_GROUP_3_OFF = 0x0A, 18 | CCT_GROUP_4_ON = 0x02, 19 | CCT_GROUP_4_OFF = 0x06, 20 | CCT_BRIGHTNESS_DOWN = 0x04, 21 | CCT_BRIGHTNESS_UP = 0x0C, 22 | CCT_TEMPERATURE_UP = 0x0E, 23 | CCT_TEMPERATURE_DOWN = 0x0F 24 | }; 25 | 26 | class CctPacketFormatter : public PacketFormatter { 27 | public: 28 | CctPacketFormatter() 29 | : PacketFormatter(REMOTE_TYPE_CCT, 7, 20) 30 | { } 31 | 32 | virtual bool canHandle(const uint8_t* packet, const size_t len); 33 | 34 | virtual void updateStatus(MiLightStatus status, uint8_t groupId); 35 | virtual void command(uint8_t command, uint8_t arg); 36 | 37 | virtual void updateTemperature(uint8_t value); 38 | virtual void increaseTemperature(); 39 | virtual void decreaseTemperature(); 40 | 41 | virtual void updateBrightness(uint8_t value); 42 | virtual void increaseBrightness(); 43 | virtual void decreaseBrightness(); 44 | virtual void enableNightMode(); 45 | 46 | virtual void format(uint8_t const* packet, char* buffer); 47 | virtual void initializePacket(uint8_t* packet); 48 | virtual void finalizePacket(uint8_t* packet); 49 | virtual BulbId parsePacket(const uint8_t* packet, JsonObject result); 50 | 51 | static uint8_t getCctStatusButton(uint8_t groupId, MiLightStatus status); 52 | static uint8_t cctCommandIdToGroup(uint8_t command); 53 | static MiLightStatus cctCommandToStatus(uint8_t command); 54 | }; 55 | 56 | #endif 57 | -------------------------------------------------------------------------------- /lib/Transitions/ChangeFieldOnFinishTransition.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | ChangeFieldOnFinishTransition::Builder::Builder( 5 | size_t id, 6 | GroupStateField field, 7 | uint16_t arg, 8 | std::shared_ptr delegate 9 | ) 10 | : Transition::Builder(delegate->id, delegate->bulbId, delegate->callback) 11 | , delegate(delegate) 12 | , field(field) 13 | , arg(arg) 14 | { } 15 | 16 | std::shared_ptr ChangeFieldOnFinishTransition::Builder::_build() const { 17 | delegate->setDurationRaw(this->getOrComputeDuration()); 18 | delegate->setNumPeriods(this->getOrComputeNumPeriods()); 19 | delegate->setPeriod(this->getOrComputePeriod()); 20 | 21 | return std::make_shared( 22 | delegate->build(), 23 | field, 24 | arg, 25 | delegate->getPeriod() 26 | ); 27 | } 28 | 29 | ChangeFieldOnFinishTransition::ChangeFieldOnFinishTransition( 30 | std::shared_ptr delegate, 31 | GroupStateField field, 32 | uint16_t arg, 33 | size_t period 34 | ) : Transition(delegate->id, delegate->bulbId, period, delegate->callback) 35 | , delegate(delegate) 36 | , field(field) 37 | , arg(arg) 38 | , changeSent(false) 39 | { } 40 | 41 | bool ChangeFieldOnFinishTransition::isFinished() { 42 | return delegate->isFinished() && changeSent; 43 | } 44 | 45 | void ChangeFieldOnFinishTransition::step() { 46 | if (! delegate->isFinished()) { 47 | delegate->step(); 48 | } else { 49 | callback(bulbId, field, arg); 50 | changeSent = true; 51 | } 52 | } 53 | 54 | void ChangeFieldOnFinishTransition::childSerialize(JsonObject& json) { 55 | json[F("type")] = F("change_on_finish"); 56 | json[F("field")] = GroupStateFieldHelpers::getFieldName(field); 57 | json[F("value")] = arg; 58 | 59 | JsonObject child = json.createNestedObject(F("child")); 60 | delegate->childSerialize(child); 61 | } -------------------------------------------------------------------------------- /lib/Types/ParsedColor.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | 7 | ParsedColor ParsedColor::fromRgb(uint16_t r, uint16_t g, uint16_t b) { 8 | double hsv[3]; 9 | RGBConverter converter; 10 | converter.rgbToHsv(r, g, b, hsv); 11 | 12 | uint16_t hue = round(hsv[0]*360); 13 | uint8_t saturation = round(hsv[1]*100); 14 | 15 | return ParsedColor{ 16 | .success = true, 17 | .hue = hue, 18 | .r = r, 19 | .g = g, 20 | .b = b, 21 | .saturation = saturation 22 | }; 23 | } 24 | 25 | ParsedColor ParsedColor::fromJson(JsonVariant json) { 26 | uint16_t r, g, b; 27 | 28 | if (json.is()) { 29 | JsonObject color = json.as(); 30 | 31 | r = color["r"]; 32 | g = color["g"]; 33 | b = color["b"]; 34 | } else if (json.is()) { 35 | const char* colorStr = json.as(); 36 | const size_t len = strlen(colorStr); 37 | 38 | if (colorStr[0] == '#' && len == 7) { 39 | uint8_t parsedHex[3]; 40 | hexStrToBytes(colorStr+1, len-1, parsedHex, 3); 41 | 42 | r = parsedHex[0]; 43 | g = parsedHex[1]; 44 | b = parsedHex[2]; 45 | } else { 46 | char colorCStr[len+1]; 47 | uint8_t parsedRgbColors[3] = {0, 0, 0}; 48 | 49 | strcpy(colorCStr, colorStr); 50 | TokenIterator colorValueItr(colorCStr, len, ','); 51 | 52 | for (size_t i = 0; i < 3 && colorValueItr.hasNext(); ++i) { 53 | parsedRgbColors[i] = atoi(colorValueItr.nextToken()); 54 | } 55 | 56 | r = parsedRgbColors[0]; 57 | g = parsedRgbColors[1]; 58 | b = parsedRgbColors[2]; 59 | } 60 | } else { 61 | Serial.println(F("GroupState::parseJsonColor - unknown format for color")); 62 | return ParsedColor{ .success = false }; 63 | } 64 | 65 | return ParsedColor::fromRgb(r, g, b); 66 | } -------------------------------------------------------------------------------- /lib/Types/BulbId.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | BulbId::BulbId() 5 | : deviceId(0), 6 | groupId(0), 7 | deviceType(REMOTE_TYPE_UNKNOWN) 8 | { } 9 | 10 | BulbId::BulbId(const BulbId &other) 11 | : deviceId(other.deviceId), 12 | groupId(other.groupId), 13 | deviceType(other.deviceType) 14 | { } 15 | 16 | BulbId::BulbId( 17 | const uint16_t deviceId, const uint8_t groupId, const MiLightRemoteType deviceType 18 | ) 19 | : deviceId(deviceId), 20 | groupId(groupId), 21 | deviceType(deviceType) 22 | { } 23 | 24 | void BulbId::operator=(const BulbId &other) { 25 | deviceId = other.deviceId; 26 | groupId = other.groupId; 27 | deviceType = other.deviceType; 28 | } 29 | 30 | // determine if now BulbId's are the same. This compared deviceID (the controller/remote ID) and 31 | // groupId (the group number on the controller, 1-4 or 1-8 depending), but ignores the deviceType 32 | // (type of controller/remote) as this doesn't directly affect the identity of the bulb 33 | bool BulbId::operator==(const BulbId &other) { 34 | return deviceId == other.deviceId 35 | && groupId == other.groupId 36 | && deviceType == other.deviceType; 37 | } 38 | 39 | uint32_t BulbId::getCompactId() const { 40 | uint32_t id = (deviceId << 24) | (deviceType << 8) | groupId; 41 | return id; 42 | } 43 | 44 | String BulbId::getHexDeviceId() const { 45 | char hexDeviceId[7]; 46 | sprintf_P(hexDeviceId, PSTR("0x%X"), deviceId); 47 | return hexDeviceId; 48 | } 49 | 50 | void BulbId::serialize(JsonObject json) const { 51 | json[GroupStateFieldNames::DEVICE_ID] = deviceId; 52 | json[GroupStateFieldNames::GROUP_ID] = groupId; 53 | json[GroupStateFieldNames::DEVICE_TYPE] = MiLightRemoteTypeHelpers::remoteTypeToString(deviceType); 54 | } 55 | 56 | void BulbId::serialize(JsonArray json) const { 57 | json.add(deviceId); 58 | json.add(MiLightRemoteTypeHelpers::remoteTypeToString(deviceType)); 59 | json.add(groupId); 60 | } -------------------------------------------------------------------------------- /web/gulpfile.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const gulp = require('gulp'); 3 | const htmlmin = require('gulp-htmlmin'); 4 | const cleancss = require('gulp-clean-css'); 5 | const uglify = require('gulp-uglify'); 6 | const gzip = require('gulp-gzip'); 7 | const del = require('del'); 8 | const inline = require('gulp-inline'); 9 | const inlineImages = require('gulp-css-base64'); 10 | const favicon = require('gulp-base64-favicon'); 11 | 12 | const dataFolder = 'build/'; 13 | 14 | gulp.task('clean', function() { 15 | del([ dataFolder + '*']); 16 | return true; 17 | }); 18 | 19 | gulp.task('buildfs_embeded', ['buildfs_inline'], function() { 20 | 21 | var source = dataFolder + 'index.html.gz'; 22 | var destination = dataFolder + 'index.html.gz.h'; 23 | 24 | var wstream = fs.createWriteStream(destination); 25 | wstream.on('error', function (err) { 26 | console.log(err); 27 | }); 28 | 29 | var data = fs.readFileSync(source); 30 | 31 | wstream.write('#define index_html_gz_len ' + data.length + '\n'); 32 | wstream.write('static const char index_html_gz[] PROGMEM = {') 33 | 34 | for (i=0; i 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | V6CommandHandler* V6CommandHandler::ALL_HANDLERS[] = { 9 | new V6RgbCctCommandHandler(), 10 | new V6RgbwCommandHandler(), 11 | new V6RgbCommandHandler(), 12 | new V6CctCommandHandler() 13 | }; 14 | 15 | const size_t V6CommandHandler::NUM_HANDLERS = size(ALL_HANDLERS); 16 | 17 | bool V6CommandHandler::handleCommand(MiLightClient* client, 18 | uint16_t deviceId, 19 | uint8_t group, 20 | uint8_t commandType, 21 | uint32_t command, 22 | uint32_t commandArg) 23 | { 24 | client->prepare(&remoteConfig, deviceId, group); 25 | 26 | if (commandType == V6_PAIR) { 27 | client->pair(); 28 | } else if (commandType == V6_UNPAIR) { 29 | client->unpair(); 30 | } else if (commandType == V6_PRESET) { 31 | return this->handlePreset(client, command, commandArg); 32 | } else if (commandType == V6_COMMAND) { 33 | return this->handleCommand(client, command, commandArg); 34 | } else { 35 | return false; 36 | } 37 | 38 | return true; 39 | } 40 | 41 | bool V6CommandDemuxer::handleCommand(MiLightClient* client, 42 | uint16_t deviceId, 43 | uint8_t group, 44 | uint8_t commandType, 45 | uint32_t command, 46 | uint32_t commandArg) 47 | { 48 | for (size_t i = 0; i < numHandlers; i++) { 49 | if (((handlers[i]->commandId & command) == handlers[i]->commandId) 50 | && handlers[i]->handleCommand(client, deviceId, group, commandType, command, commandArg)) { 51 | return true; 52 | } 53 | } 54 | 55 | return false; 56 | } 57 | 58 | bool V6CommandDemuxer::handleCommand(MiLightClient* client, 59 | uint32_t commandLsb, 60 | uint32_t commandArg) 61 | { 62 | return false; 63 | } 64 | 65 | bool V6CommandDemuxer::handlePreset(MiLightClient* client, 66 | uint8_t commandLsb, 67 | uint32_t commandArg) 68 | { 69 | return false; 70 | } 71 | -------------------------------------------------------------------------------- /lib/MiLight/RadioSwitchboard.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | RadioSwitchboard::RadioSwitchboard( 4 | std::shared_ptr radioFactory, 5 | GroupStateStore* stateStore, 6 | Settings& settings 7 | ) { 8 | for (size_t i = 0; i < MiLightRadioConfig::NUM_CONFIGS; i++) { 9 | std::shared_ptr radio = radioFactory->create(MiLightRadioConfig::ALL_CONFIGS[i]); 10 | radio->begin(); 11 | radios.push_back(radio); 12 | } 13 | 14 | for (size_t i = 0; i < MiLightRemoteConfig::NUM_REMOTES; i++) { 15 | MiLightRemoteConfig::ALL_REMOTES[i]->packetFormatter->initialize(stateStore, &settings); 16 | } 17 | } 18 | 19 | size_t RadioSwitchboard::getNumRadios() const { 20 | return radios.size(); 21 | } 22 | 23 | std::shared_ptr RadioSwitchboard::switchRadio(size_t radioIx) { 24 | if (radioIx >= getNumRadios()) { 25 | return NULL; 26 | } 27 | 28 | if (this->currentRadio != radios[radioIx]) { 29 | this->currentRadio = radios[radioIx]; 30 | this->currentRadio->configure(); 31 | } 32 | 33 | return this->currentRadio; 34 | } 35 | 36 | std::shared_ptr RadioSwitchboard::switchRadio(const MiLightRemoteConfig* remote) { 37 | std::shared_ptr radio = NULL; 38 | 39 | for (size_t i = 0; i < radios.size(); i++) { 40 | if (&this->radios[i]->config() == &remote->radioConfig) { 41 | radio = switchRadio(i); 42 | break; 43 | } 44 | } 45 | 46 | return radio; 47 | } 48 | 49 | void RadioSwitchboard::write(uint8_t* packet, size_t len) { 50 | if (this->currentRadio == nullptr) { 51 | return; 52 | } 53 | 54 | this->currentRadio->write(packet, len); 55 | } 56 | 57 | size_t RadioSwitchboard::read(uint8_t* packet) { 58 | if (currentRadio == nullptr) { 59 | return 0; 60 | } 61 | 62 | size_t length; 63 | currentRadio->read(packet, length); 64 | 65 | return length; 66 | } 67 | 68 | bool RadioSwitchboard::available() { 69 | if (currentRadio == nullptr) { 70 | return false; 71 | } 72 | 73 | return currentRadio->available(); 74 | } -------------------------------------------------------------------------------- /lib/Udp/V6CommandHandler.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #ifndef _V6_COMMAND_HANDLER_H 5 | #define _V6_COMMAND_HANDLER_H 6 | 7 | enum V6CommandTypes { 8 | V6_PAIR = 0x3D, 9 | V6_UNPAIR = 0x3E, 10 | V6_PRESET = 0x3F, 11 | V6_COMMAND = 0x31 12 | }; 13 | 14 | class V6CommandHandler { 15 | public: 16 | static V6CommandHandler* ALL_HANDLERS[]; 17 | static const size_t NUM_HANDLERS; 18 | 19 | V6CommandHandler(uint16_t commandId, const MiLightRemoteConfig& remoteConfig) 20 | : commandId(commandId), 21 | remoteConfig(remoteConfig) 22 | { } 23 | 24 | virtual bool handleCommand( 25 | MiLightClient* client, 26 | uint16_t deviceId, 27 | uint8_t group, 28 | uint8_t commandType, 29 | uint32_t command, 30 | uint32_t commandArg 31 | ); 32 | 33 | const uint16_t commandId; 34 | const MiLightRemoteConfig& remoteConfig; 35 | 36 | protected: 37 | 38 | virtual bool handleCommand( 39 | MiLightClient* client, 40 | uint32_t command, 41 | uint32_t commandArg 42 | ) = 0; 43 | 44 | virtual bool handlePreset( 45 | MiLightClient* client, 46 | uint8_t commandLsb, 47 | uint32_t commandArg 48 | ) = 0; 49 | }; 50 | 51 | class V6CommandDemuxer : public V6CommandHandler { 52 | public: 53 | V6CommandDemuxer(V6CommandHandler* handlers[], size_t numHandlers) 54 | : V6CommandHandler(0, FUT096Config), 55 | handlers(handlers), 56 | numHandlers(numHandlers) 57 | { } 58 | 59 | virtual bool handleCommand( 60 | MiLightClient* client, 61 | uint16_t deviceId, 62 | uint8_t group, 63 | uint8_t commandType, 64 | uint32_t command, 65 | uint32_t commandArg 66 | ); 67 | 68 | protected: 69 | V6CommandHandler** handlers; 70 | size_t numHandlers; 71 | 72 | virtual bool handleCommand( 73 | MiLightClient* client, 74 | uint32_t command, 75 | uint32_t commandArg 76 | ); 77 | 78 | virtual bool handlePreset( 79 | MiLightClient* client, 80 | uint8_t commandLsb, 81 | uint32_t commandArg 82 | ); 83 | }; 84 | 85 | #endif 86 | -------------------------------------------------------------------------------- /lib/MiLight/V2PacketFormatter.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #ifndef _V2_PACKET_FORMATTER 5 | #define _V2_PACKET_FORMATTER 6 | 7 | #define V2_PACKET_LEN 9 8 | 9 | #define V2_PROTOCOL_ID_INDEX 1 10 | #define V2_COMMAND_INDEX 4 11 | #define V2_ARGUMENT_INDEX 5 12 | 13 | // Default number of values to allow before and after strictly defined range for V2 scales 14 | #define V2_DEFAULT_RANGE_BUFFER 0x13 15 | 16 | class V2PacketFormatter : public PacketFormatter { 17 | public: 18 | V2PacketFormatter(const MiLightRemoteType deviceType, uint8_t protocolId, uint8_t numGroups); 19 | 20 | virtual bool canHandle(const uint8_t* packet, const size_t packetLen); 21 | virtual void initializePacket(uint8_t* packet); 22 | 23 | virtual void updateStatus(MiLightStatus status, uint8_t group); 24 | virtual void command(uint8_t command, uint8_t arg); 25 | virtual void format(uint8_t const* packet, char* buffer); 26 | virtual void unpair(); 27 | 28 | virtual void finalizePacket(uint8_t* packet); 29 | 30 | uint8_t groupCommandArg(MiLightStatus status, uint8_t groupId); 31 | 32 | /* 33 | * Some protocols have scales which have the following characteristics: 34 | * Start at some value X, goes down to 0, then up to Y. 35 | * eg: 36 | * 0x8F, 0x8D, ..., 0, 0x2, ..., 0x20 37 | * This is a parameterized method to convert from [0, 100] TO this scale 38 | */ 39 | static uint8_t tov2scale(uint8_t value, uint8_t endValue, uint8_t interval, bool reverse = true); 40 | 41 | /* 42 | * Method to convert FROM the scale described above to [0, 100]. 43 | * 44 | * An extra parameter is exposed: `buffer`, which allows for a range of values before/after the 45 | * max that will be mapped to 0 and 100, respectively. 46 | */ 47 | static uint8_t fromv2scale(uint8_t value, uint8_t endValue, uint8_t interval, bool reverse = true, uint8_t buffer = V2_DEFAULT_RANGE_BUFFER); 48 | 49 | protected: 50 | const uint8_t protocolId; 51 | const uint8_t numGroups; 52 | void switchMode(const GroupState& currentState, BulbMode desiredMode); 53 | }; 54 | 55 | #endif 56 | -------------------------------------------------------------------------------- /lib/Transitions/FieldTransition.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | FieldTransition::Builder::Builder(size_t id, const BulbId& bulbId, TransitionFn callback, GroupStateField field, uint16_t start, uint16_t end) 6 | : Transition::Builder(id, bulbId, callback) 7 | , stepSize(0) 8 | , field(field) 9 | , start(start) 10 | , end(end) 11 | { } 12 | 13 | std::shared_ptr FieldTransition::Builder::_build() const { 14 | size_t numPeriods = getOrComputeNumPeriods(); 15 | size_t period = getOrComputePeriod(); 16 | 17 | int16_t distance = end - start; 18 | int16_t stepSize = ceil(std::abs(distance / static_cast(numPeriods))); 19 | 20 | if (end < start) { 21 | stepSize = -stepSize; 22 | } 23 | if (stepSize == 0) { 24 | stepSize = end > start ? 1 : -1; 25 | } 26 | 27 | return std::make_shared( 28 | id, 29 | bulbId, 30 | field, 31 | start, 32 | end, 33 | stepSize, 34 | period, 35 | callback 36 | ); 37 | } 38 | 39 | FieldTransition::FieldTransition( 40 | size_t id, 41 | const BulbId& bulbId, 42 | GroupStateField field, 43 | uint16_t startValue, 44 | uint16_t endValue, 45 | int16_t stepSize, 46 | size_t period, 47 | TransitionFn callback 48 | ) : Transition(id, bulbId, period, callback) 49 | , field(field) 50 | , currentValue(startValue) 51 | , endValue(endValue) 52 | , stepSize(stepSize) 53 | , finished(false) 54 | { } 55 | 56 | void FieldTransition::step() { 57 | callback(bulbId, field, currentValue); 58 | 59 | if (currentValue != endValue) { 60 | Transition::stepValue(currentValue, endValue, stepSize); 61 | } else { 62 | finished = true; 63 | } 64 | } 65 | 66 | bool FieldTransition::isFinished() { 67 | return finished; 68 | } 69 | 70 | void FieldTransition::childSerialize(JsonObject& json) { 71 | json[F("type")] = F("field"); 72 | json[F("field")] = GroupStateFieldHelpers::getFieldName(field); 73 | json[F("current_value")] = currentValue; 74 | json[F("end_value")] = endValue; 75 | json[F("step_size")] = stepSize; 76 | } -------------------------------------------------------------------------------- /lib/MiLight/V2RFEncoding.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #define V2_OFFSET(byte, key, jumpStart) ( \ 4 | V2_OFFSETS[byte-1][key%4] \ 5 | + \ 6 | ((jumpStart > 0 && key >= jumpStart && key < jumpStart+0x80) ? 0x80 : 0) \ 7 | ) 8 | 9 | uint8_t const V2RFEncoding::V2_OFFSETS[][4] = { 10 | { 0x45, 0x1F, 0x14, 0x5C }, // request type 11 | { 0x2B, 0xC9, 0xE3, 0x11 }, // id 1 12 | { 0x6D, 0x5F, 0x8A, 0x2B }, // id 2 13 | { 0xAF, 0x03, 0x1D, 0xF3 }, // command 14 | { 0x1A, 0xE2, 0xF0, 0xD1 }, // argument 15 | { 0x04, 0xD8, 0x71, 0x42 }, // sequence 16 | { 0xAF, 0x04, 0xDD, 0x07 }, // group 17 | { 0x61, 0x13, 0x38, 0x64 } // checksum 18 | }; 19 | 20 | uint8_t V2RFEncoding::xorKey(uint8_t key) { 21 | // Generate most significant nibble 22 | const uint8_t shift = (key & 0x0F) < 0x04 ? 0 : 1; 23 | const uint8_t x = (((key & 0xF0) >> 4) + shift + 6) % 8; 24 | const uint8_t msn = (((4 + x) ^ 1) & 0x0F) << 4; 25 | 26 | // Generate least significant nibble 27 | const uint8_t lsn = ((((key & 0xF) + 4)^2) & 0x0F); 28 | 29 | return ( msn | lsn ); 30 | } 31 | 32 | uint8_t V2RFEncoding::decodeByte(uint8_t byte, uint8_t s1, uint8_t xorKey, uint8_t s2) { 33 | uint8_t value = byte - s2; 34 | value = value ^ xorKey; 35 | value = value - s1; 36 | 37 | return value; 38 | } 39 | 40 | uint8_t V2RFEncoding::encodeByte(uint8_t byte, uint8_t s1, uint8_t xorKey, uint8_t s2) { 41 | uint8_t value = byte + s1; 42 | value = value ^ xorKey; 43 | value = value + s2; 44 | 45 | return value; 46 | } 47 | 48 | void V2RFEncoding::decodeV2Packet(uint8_t *packet) { 49 | uint8_t key = xorKey(packet[0]); 50 | 51 | for (size_t i = 1; i <= 8; i++) { 52 | packet[i] = decodeByte(packet[i], 0, key, V2_OFFSET(i, packet[0], V2_OFFSET_JUMP_START)); 53 | } 54 | } 55 | 56 | void V2RFEncoding::encodeV2Packet(uint8_t *packet) { 57 | uint8_t key = xorKey(packet[0]); 58 | uint8_t sum = key; 59 | 60 | for (size_t i = 1; i <= 7; i++) { 61 | sum += packet[i]; 62 | packet[i] = encodeByte(packet[i], 0, key, V2_OFFSET(i, packet[0], V2_OFFSET_JUMP_START)); 63 | } 64 | 65 | packet[8] = encodeByte(sum, 2, key, V2_OFFSET(8, packet[0], 0)); 66 | } 67 | -------------------------------------------------------------------------------- /lib/MiLight/PacketSender.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | class PacketSender { 9 | public: 10 | typedef std::function PacketSentHandler; 11 | static const size_t DEFAULT_PACKET_SENDS_VALUE = 0; 12 | 13 | PacketSender( 14 | RadioSwitchboard& radioSwitchboard, 15 | Settings& settings, 16 | PacketSentHandler packetSentHandler 17 | ); 18 | 19 | void enqueue(uint8_t* packet, const MiLightRemoteConfig* remoteConfig, const size_t repeatsOverride = 0); 20 | void loop(); 21 | 22 | // Return true if there are queued packets 23 | bool isSending(); 24 | 25 | // Return the number of queued packets 26 | size_t queueLength() const; 27 | size_t droppedPackets() const; 28 | 29 | private: 30 | RadioSwitchboard& radioSwitchboard; 31 | Settings& settings; 32 | GroupStateStore* stateStore; 33 | PacketQueue queue; 34 | 35 | // The current packet we're sending and the number of repeats left 36 | std::shared_ptr currentPacket; 37 | size_t packetRepeatsRemaining; 38 | 39 | // Handler called after packets are sent. Will not be called multiple times 40 | // per repeat. 41 | PacketSentHandler packetSentHandler; 42 | 43 | // Send a batch of repeats for the current packet 44 | void handleCurrentPacket(); 45 | 46 | // Switch to the next packet in the queue 47 | void nextPacket(); 48 | 49 | // Send repeats of the current packet N times 50 | void sendRepeats(size_t num); 51 | 52 | // Used to track auto repeat limiting 53 | unsigned long lastSend; 54 | uint8_t currentResendCount; 55 | 56 | // This will be pre-computed, but is simply: 57 | // 58 | // (sensitivity / 1000.0) * R 59 | // 60 | // Where R is the base number of repeats. 61 | size_t throttleMultiplier; 62 | 63 | /* 64 | * Calculates the number of resend packets based on when the last packet 65 | * was sent using this function: 66 | * 67 | * lastRepeatsValue + (millisSinceLastSend - THRESHOLD) * throttleMultiplier 68 | * 69 | * When the last send was more recent than THRESHOLD, the number of repeats 70 | * will be decreased to a minimum of zero. When less recent, it will be 71 | * increased up to a maximum of the default resend count. 72 | */ 73 | void updateResendCount(); 74 | }; -------------------------------------------------------------------------------- /lib/Types/MiLightRemoteType.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | static const char* REMOTE_NAME_RGBW = "rgbw"; 5 | static const char* REMOTE_NAME_CCT = "cct"; 6 | static const char* REMOTE_NAME_RGB_CCT = "rgb_cct"; 7 | static const char* REMOTE_NAME_FUT089 = "fut089"; 8 | static const char* REMOTE_NAME_RGB = "rgb"; 9 | static const char* REMOTE_NAME_FUT091 = "fut091"; 10 | static const char* REMOTE_NAME_FUT020 = "fut020"; 11 | 12 | const MiLightRemoteType MiLightRemoteTypeHelpers::remoteTypeFromString(const String& type) { 13 | if (type.equalsIgnoreCase(REMOTE_NAME_RGBW) || type.equalsIgnoreCase("fut096")) { 14 | return REMOTE_TYPE_RGBW; 15 | } 16 | 17 | if (type.equalsIgnoreCase(REMOTE_NAME_CCT) || type.equalsIgnoreCase("fut007")) { 18 | return REMOTE_TYPE_CCT; 19 | } 20 | 21 | if (type.equalsIgnoreCase(REMOTE_NAME_RGB_CCT) || type.equalsIgnoreCase("fut092")) { 22 | return REMOTE_TYPE_RGB_CCT; 23 | } 24 | 25 | if (type.equalsIgnoreCase(REMOTE_NAME_FUT089)) { 26 | return REMOTE_TYPE_FUT089; 27 | } 28 | 29 | if (type.equalsIgnoreCase(REMOTE_NAME_RGB) || type.equalsIgnoreCase("fut098")) { 30 | return REMOTE_TYPE_RGB; 31 | } 32 | 33 | if (type.equalsIgnoreCase("v2_cct") || type.equalsIgnoreCase(REMOTE_NAME_FUT091)) { 34 | return REMOTE_TYPE_FUT091; 35 | } 36 | 37 | if (type.equalsIgnoreCase(REMOTE_NAME_FUT020)) { 38 | return REMOTE_TYPE_FUT020; 39 | } 40 | 41 | Serial.print(F("remoteTypeFromString: ERROR - tried to fetch remote config for type: ")); 42 | Serial.println(type); 43 | 44 | return REMOTE_TYPE_UNKNOWN; 45 | } 46 | 47 | const String MiLightRemoteTypeHelpers::remoteTypeToString(const MiLightRemoteType type) { 48 | switch (type) { 49 | case REMOTE_TYPE_RGBW: 50 | return REMOTE_NAME_RGBW; 51 | case REMOTE_TYPE_CCT: 52 | return REMOTE_NAME_CCT; 53 | case REMOTE_TYPE_RGB_CCT: 54 | return REMOTE_NAME_RGB_CCT; 55 | case REMOTE_TYPE_FUT089: 56 | return REMOTE_NAME_FUT089; 57 | case REMOTE_TYPE_RGB: 58 | return REMOTE_NAME_RGB; 59 | case REMOTE_TYPE_FUT091: 60 | return REMOTE_NAME_FUT091; 61 | case REMOTE_TYPE_FUT020: 62 | return REMOTE_NAME_FUT020; 63 | default: 64 | Serial.print(F("remoteTypeToString: ERROR - tried to fetch remote config name for unknown type: ")); 65 | Serial.println(type); 66 | return "unknown"; 67 | } 68 | } -------------------------------------------------------------------------------- /lib/MiLight/FUT091PacketFormatter.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | static const uint8_t BRIGHTNESS_SCALE_MAX = 0x97; 7 | static const uint8_t KELVIN_SCALE_MAX = 0xC5; 8 | 9 | void FUT091PacketFormatter::updateBrightness(uint8_t value) { 10 | command(static_cast(FUT091Command::BRIGHTNESS), V2PacketFormatter::tov2scale(value, BRIGHTNESS_SCALE_MAX, 2)); 11 | } 12 | 13 | void FUT091PacketFormatter::updateTemperature(uint8_t value) { 14 | command(static_cast(FUT091Command::KELVIN), V2PacketFormatter::tov2scale(value, KELVIN_SCALE_MAX, 2, false)); 15 | } 16 | 17 | void FUT091PacketFormatter::enableNightMode() { 18 | uint8_t arg = groupCommandArg(OFF, groupId); 19 | command(static_cast(FUT091Command::ON_OFF) | 0x80, arg); 20 | } 21 | 22 | BulbId FUT091PacketFormatter::parsePacket(const uint8_t *packet, JsonObject result) { 23 | uint8_t packetCopy[V2_PACKET_LEN]; 24 | memcpy(packetCopy, packet, V2_PACKET_LEN); 25 | V2RFEncoding::decodeV2Packet(packetCopy); 26 | 27 | BulbId bulbId( 28 | (packetCopy[2] << 8) | packetCopy[3], 29 | packetCopy[7], 30 | REMOTE_TYPE_FUT091 31 | ); 32 | 33 | uint8_t command = (packetCopy[V2_COMMAND_INDEX] & 0x7F); 34 | uint8_t arg = packetCopy[V2_ARGUMENT_INDEX]; 35 | 36 | if (command == (uint8_t)FUT091Command::ON_OFF) { 37 | if ((packetCopy[V2_COMMAND_INDEX] & 0x80) == 0x80) { 38 | result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::NIGHT_MODE; 39 | } else if (arg < 5) { // Group is not reliably encoded in group byte. Extract from arg byte 40 | result[GroupStateFieldNames::STATE] = "ON"; 41 | bulbId.groupId = arg; 42 | } else { 43 | result[GroupStateFieldNames::STATE] = "OFF"; 44 | bulbId.groupId = arg-5; 45 | } 46 | } else if (command == (uint8_t)FUT091Command::BRIGHTNESS) { 47 | uint8_t level = V2PacketFormatter::fromv2scale(arg, BRIGHTNESS_SCALE_MAX, 2, true); 48 | result[GroupStateFieldNames::BRIGHTNESS] = Units::rescale(level, 255, 100); 49 | } else if (command == (uint8_t)FUT091Command::KELVIN) { 50 | uint8_t kelvin = V2PacketFormatter::fromv2scale(arg, KELVIN_SCALE_MAX, 2, false); 51 | result[GroupStateFieldNames::COLOR_TEMP] = Units::whiteValToMireds(kelvin, 100); 52 | } else { 53 | result["button_id"] = command; 54 | result["argument"] = arg; 55 | } 56 | 57 | return bulbId; 58 | } 59 | -------------------------------------------------------------------------------- /lib/Udp/V5MiLightUdpServer.h: -------------------------------------------------------------------------------- 1 | // This protocol is documented here: 2 | // http://www.limitlessled.com/dev/ 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | #ifndef _V5_MILIGHT_UDP_SERVER 10 | #define _V5_MILIGHT_UDP_SERVER 11 | 12 | enum MiLightUdpCommands { 13 | UDP_CCT_ALL_ON = 0x35, 14 | UDP_CCT_ALL_OFF = 0x39, 15 | UDP_CCT_GROUP_1_ON = 0x38, 16 | UDP_CCT_GROUP_1_OFF = 0x3B, 17 | UDP_CCT_GROUP_2_ON = 0x3D, 18 | UDP_CCT_GROUP_2_OFF = 0x33, 19 | UDP_CCT_GROUP_3_ON = 0x37, 20 | UDP_CCT_GROUP_3_OFF = 0x3A, 21 | UDP_CCT_GROUP_4_ON = 0x32, 22 | UDP_CCT_GROUP_4_OFF = 0x36, 23 | UDP_CCT_TEMPERATURE_DOWN = 0x3F, 24 | UDP_CCT_TEMPERATURE_UP = 0x3E, 25 | UDP_CCT_BRIGHTNESS_DOWN = 0x34, 26 | UDP_CCT_BRIGHTNESS_UP = 0x3C, 27 | UDP_CCT_NIGHT_MODE = 0xB9, 28 | 29 | UDP_RGBW_ALL_OFF = 0x41, 30 | UDP_RGBW_ALL_ON = 0x42, 31 | UDP_RGBW_SPEED_UP = 0x43, 32 | UDP_RGBW_SPEED_DOWN = 0x44, 33 | UDP_RGBW_GROUP_1_ON = 0x45, 34 | UDP_RGBW_GROUP_1_OFF = 0x46, 35 | UDP_RGBW_GROUP_2_ON = 0x47, 36 | UDP_RGBW_GROUP_2_OFF = 0x48, 37 | UDP_RGBW_GROUP_3_ON = 0x49, 38 | UDP_RGBW_GROUP_3_OFF = 0x4A, 39 | UDP_RGBW_GROUP_4_ON = 0x4B, 40 | UDP_RGBW_GROUP_4_OFF = 0x4C, 41 | UDP_RGBW_DISCO_MODE = 0x4D, 42 | UDP_RGBW_GROUP_ALL_WHITE = 0xC2, 43 | UDP_RGBW_GROUP_1_WHITE = 0xC5, 44 | UDP_RGBW_GROUP_2_WHITE = 0xC7, 45 | UDP_RGBW_GROUP_3_WHITE = 0xC9, 46 | UDP_RGBW_GROUP_4_WHITE = 0xCB, 47 | UDP_RGBW_GROUP_ALL_NIGHT = 0xC1, 48 | UDP_RGBW_GROUP_1_NIGHT = 0xC6, 49 | UDP_RGBW_GROUP_2_NIGHT = 0xC8, 50 | UDP_RGBW_GROUP_3_NIGHT = 0xCA, 51 | UDP_RGBW_GROUP_4_NIGHT = 0xCC, 52 | UDP_RGBW_BRIGHTNESS = 0x4E, 53 | UDP_RGBW_COLOR = 0x40 54 | }; 55 | 56 | class V5MiLightUdpServer : public MiLightUdpServer { 57 | public: 58 | V5MiLightUdpServer(MiLightClient*& client, uint16_t port, uint16_t deviceId) 59 | : MiLightUdpServer(client, port, deviceId) 60 | { } 61 | 62 | // Should return size of the response packet 63 | virtual void handlePacket(uint8_t* packet, size_t packetSize); 64 | 65 | protected: 66 | void handleCommand(uint8_t command, uint8_t commandArg); 67 | void pressButton(uint8_t button); 68 | }; 69 | 70 | #endif 71 | -------------------------------------------------------------------------------- /.prepare_docs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # This script sets up API documentation bundles for deployment to Github Pages. 4 | # It expects the following structure: 5 | # 6 | # In development branches: 7 | # 8 | # * ./docs/openapi.yaml - OpenAPI spec in 9 | # * ./docs/gh-pages - Any assets that should be copied to gh-pages root 10 | # 11 | # In Github Pages, it will generate: 12 | # 13 | # * ./ - Files from ./docs/gh-pages will be copied 14 | # * ./branches//... - Deployment bundles including an index.html 15 | # and a snapshot of the Open API spec. 16 | 17 | set -eo pipefail 18 | 19 | prepare_docs_log() { 20 | echo "[prepare docs release] -- $@" 21 | } 22 | 23 | # Only run for tagged commits 24 | if [ -z "$(git tag -l --points-at HEAD)" ]; then 25 | prepare_docs_log "Skipping non-tagged commit." 26 | exit 0 27 | fi 28 | 29 | DOCS_DIR="./docs" 30 | DIST_DIR="./dist/docs" 31 | BRANCHES_DIR="${DIST_DIR}/branches" 32 | API_SPEC_FILE="${DOCS_DIR}/openapi.yaml" 33 | 34 | rm -rf "${DIST_DIR}" 35 | 36 | redoc_bundle_file=$(mktemp) 37 | git_ref_version=$(git describe --always) 38 | branch_docs_dir="${BRANCHES_DIR}/${git_ref_version}" 39 | 40 | # Build Redoc bundle (a single HTML file) 41 | redoc-cli bundle ${API_SPEC_FILE} -o ${redoc_bundle_file} --title 'Milight Hub API Documentation' 42 | 43 | # Check out current stuff from gh-pages (we'll append to it) 44 | git fetch origin 'refs/heads/gh-pages:refs/heads/gh-pages' 45 | git checkout gh-pages -- branches || prepare_docs_log "Failed to checkout branches from gh-pages, skipping..." 46 | 47 | if [ -e "./branches" ]; then 48 | mkdir -p "${DIST_DIR}" 49 | mv "./branches" "${BRANCHES_DIR}" 50 | else 51 | mkdir -p "${BRANCHES_DIR}" 52 | fi 53 | 54 | if [ -e "${DOCS_DIR}/gh-pages" ]; then 55 | cp -r ${DOCS_DIR}/gh-pages/* "${DIST_DIR}" 56 | else 57 | prepare_docs_log "Skipping copy of gh-pages dir, doesn't exist" 58 | fi 59 | 60 | # Create the docs bundle for our ref. This will be the redoc bundle + a 61 | # snapshot of the OpenAPI spec 62 | mkdir -p "${branch_docs_dir}" 63 | cp "${API_SPEC_FILE}" "${branch_docs_dir}" 64 | cp "${redoc_bundle_file}" "${branch_docs_dir}/index.html" 65 | 66 | # Update `latest` symlink to this branch 67 | rm -rf "${BRANCHES_DIR}/latest" 68 | ln -s "${git_ref_version}" "${BRANCHES_DIR}/latest" 69 | 70 | # Create a JSON file containing a list of all branches with docs (we'll 71 | # have an index page that renders the list). 72 | ls "${BRANCHES_DIR}" | jq -Rc '.' | jq -sc '.' > "${DIST_DIR}/branches.json" -------------------------------------------------------------------------------- /lib/Udp/V6MiLightUdpServer.h: -------------------------------------------------------------------------------- 1 | // This protocol is documented here: 2 | // http://www.limitlessled.com/dev/ 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | 10 | #define V6_COMMAND_LEN 8 11 | #define V6_MAX_SESSIONS 10 12 | 13 | #ifndef _V6_MILIGHT_UDP_SERVER 14 | #define _V6_MILIGHT_UDP_SERVER 15 | 16 | struct V6Session { 17 | V6Session(IPAddress ipAddr, uint16_t port, uint16_t sessionId) 18 | : ipAddr(ipAddr), 19 | port(port), 20 | sessionId(sessionId), 21 | next(NULL) 22 | { } 23 | 24 | IPAddress ipAddr; 25 | uint16_t port; 26 | uint16_t sessionId; 27 | V6Session* next; 28 | }; 29 | 30 | class V6MiLightUdpServer : public MiLightUdpServer { 31 | public: 32 | V6MiLightUdpServer(MiLightClient*& client, uint16_t port, uint16_t deviceId) 33 | : MiLightUdpServer(client, port, deviceId), 34 | sessionId(0), 35 | numSessions(0), 36 | firstSession(NULL) 37 | { } 38 | 39 | ~V6MiLightUdpServer(); 40 | 41 | // Should return size of the response packet 42 | virtual void handlePacket(uint8_t* packet, size_t packetSize); 43 | 44 | template 45 | static T readInt(uint8_t* packet); 46 | 47 | template 48 | static uint8_t* writeInt(const T& value, uint8_t* packet); 49 | 50 | protected: 51 | static V6CommandDemuxer COMMAND_DEMUXER; 52 | 53 | static uint8_t START_SESSION_COMMAND[]; 54 | static uint8_t START_SESSION_RESPONSE[]; 55 | static uint8_t COMMAND_HEADER[]; 56 | static uint8_t COMMAND_RESPONSE[]; 57 | static uint8_t LOCAL_SEARCH_COMMAND[]; 58 | static uint8_t HEARTBEAT_HEADER[]; 59 | static uint8_t HEARTBEAT_HEADER2[]; 60 | 61 | static uint8_t SEARCH_COMMAND[]; 62 | static uint8_t SEARCH_RESPONSE[]; 63 | 64 | static uint8_t OPEN_COMMAND_RESPONSE[]; 65 | 66 | uint16_t sessionId; 67 | size_t numSessions; 68 | V6Session* firstSession; 69 | 70 | uint16_t beginSession(); 71 | bool sendResponse(uint16_t sessionId, uint8_t* responseBuffer, size_t responseSize); 72 | bool matchesPacket(uint8_t* packet1, size_t packet1Len, uint8_t* packet2, size_t packet2Len); 73 | void writeMacAddr(uint8_t* packet); 74 | 75 | void handleSearch(); 76 | void handleStartSession(); 77 | bool handleOpenCommand(uint16_t sessionId); 78 | void handleHeartbeat(uint16_t sessionId); 79 | void handleCommand( 80 | uint16_t sessionId, 81 | uint8_t sequenceNum, 82 | uint8_t* cmd, 83 | uint8_t group, 84 | uint8_t checksum 85 | ); 86 | }; 87 | 88 | #endif 89 | -------------------------------------------------------------------------------- /lib/Udp/MiLightDiscoveryServer.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | const char V3_SEARCH_STRING[] = "Link_Wi-Fi"; 6 | const char V6_SEARCH_STRING[] = "HF-A11ASSISTHREAD"; 7 | 8 | MiLightDiscoveryServer::MiLightDiscoveryServer(Settings& settings) 9 | : settings(settings) 10 | { } 11 | 12 | MiLightDiscoveryServer::MiLightDiscoveryServer(MiLightDiscoveryServer& other) 13 | : settings(other.settings) 14 | { } 15 | 16 | MiLightDiscoveryServer& MiLightDiscoveryServer::operator=(MiLightDiscoveryServer other) { 17 | this->settings = other.settings; 18 | this->socket = other.socket; 19 | return *this; 20 | } 21 | 22 | MiLightDiscoveryServer::~MiLightDiscoveryServer() { 23 | socket.stop(); 24 | } 25 | 26 | void MiLightDiscoveryServer::begin() { 27 | socket.begin(settings.discoveryPort); 28 | } 29 | 30 | void MiLightDiscoveryServer::handleClient() { 31 | size_t packetSize = socket.parsePacket(); 32 | 33 | if (packetSize) { 34 | char buffer[size(V6_SEARCH_STRING) + 1]; 35 | socket.read(buffer, packetSize); 36 | buffer[packetSize] = 0; 37 | 38 | #ifdef MILIGHT_UDP_DEBUG 39 | printf("Got discovery packet: %s\n", buffer); 40 | #endif 41 | 42 | if (strcmp(buffer, V3_SEARCH_STRING) == 0) { 43 | handleDiscovery(5); 44 | } else if (strcmp(buffer, V6_SEARCH_STRING) == 0) { 45 | handleDiscovery(6); 46 | } 47 | } 48 | } 49 | 50 | void MiLightDiscoveryServer::handleDiscovery(uint8_t version) { 51 | #ifdef MILIGHT_UDP_DEBUG 52 | printf_P(PSTR("Handling discovery for version: %u, %d configs to consider\n"), version, settings.gatewayConfigs.size()); 53 | #endif 54 | 55 | char buffer[40]; 56 | 57 | for (size_t i = 0; i < settings.gatewayConfigs.size(); i++) { 58 | const GatewayConfig& config = *settings.gatewayConfigs[i]; 59 | 60 | if (config.protocolVersion != version) { 61 | continue; 62 | } 63 | 64 | IPAddress addr = WiFi.localIP(); 65 | char* ptr = buffer; 66 | ptr += sprintf_P( 67 | buffer, 68 | PSTR("%d.%d.%d.%d,00000000%02X%02X"), 69 | addr[0], addr[1], addr[2], addr[3], 70 | (config.deviceId >> 8), (config.deviceId & 0xFF) 71 | ); 72 | 73 | if (config.protocolVersion == 5) { 74 | sendResponse(buffer); 75 | } else { 76 | sprintf_P(ptr, PSTR(",HF-LPB100")); 77 | sendResponse(buffer); 78 | } 79 | } 80 | } 81 | 82 | void MiLightDiscoveryServer::sendResponse(char* buffer) { 83 | #ifdef MILIGHT_UDP_DEBUG 84 | printf_P(PSTR("Sending response: %s\n"), buffer); 85 | #endif 86 | 87 | socket.beginPacket(socket.remoteIP(), socket.remotePort()); 88 | socket.write(buffer); 89 | socket.endPacket(); 90 | } 91 | -------------------------------------------------------------------------------- /lib/Transitions/Transition.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | #pragma once 10 | 11 | class Transition { 12 | public: 13 | using TransitionFn = std::function; 14 | 15 | // transition commands are in seconds, convert to ms. 16 | static const uint16_t DURATION_UNIT_MULTIPLIER = 1000; 17 | 18 | 19 | class Builder { 20 | public: 21 | Builder(size_t id, const BulbId& bulbId, TransitionFn callback); 22 | 23 | Builder& setDuration(float duration); 24 | Builder& setPeriod(size_t period); 25 | Builder& setNumPeriods(size_t numPeriods); 26 | 27 | void setDurationRaw(size_t duration); 28 | 29 | bool isSetDuration() const; 30 | bool isSetPeriod() const; 31 | bool isSetNumPeriods() const; 32 | 33 | size_t getOrComputePeriod() const; 34 | size_t getOrComputeDuration() const; 35 | size_t getOrComputeNumPeriods() const; 36 | 37 | size_t getDuration() const; 38 | size_t getPeriod() const; 39 | size_t getNumPeriods() const; 40 | 41 | std::shared_ptr build(); 42 | 43 | const size_t id; 44 | const BulbId& bulbId; 45 | const TransitionFn callback; 46 | 47 | private: 48 | size_t duration; 49 | size_t period; 50 | size_t numPeriods; 51 | 52 | virtual std::shared_ptr _build() const = 0; 53 | size_t numSetParams() const; 54 | }; 55 | 56 | // Default time to wait between steps. Do this rather than having a fixed step size because it's 57 | // more capable of adapting to different situations. 58 | static const size_t DEFAULT_PERIOD = 450; 59 | static const size_t DEFAULT_NUM_PERIODS = 10; 60 | static const size_t DEFAULT_DURATION = DEFAULT_PERIOD*DEFAULT_NUM_PERIODS; 61 | 62 | // If period goes lower than this, throttle other parameters up to adjust. 63 | static const size_t MIN_PERIOD = 150; 64 | 65 | const size_t id; 66 | const BulbId bulbId; 67 | const TransitionFn callback; 68 | 69 | Transition( 70 | size_t id, 71 | const BulbId& bulbId, 72 | size_t period, 73 | TransitionFn callback 74 | ); 75 | 76 | void tick(); 77 | virtual bool isFinished() = 0; 78 | void serialize(JsonObject& doc); 79 | virtual void step() = 0; 80 | virtual void childSerialize(JsonObject& doc) = 0; 81 | 82 | static size_t calculatePeriod(int16_t distance, size_t stepSize, size_t duration); 83 | 84 | protected: 85 | const size_t period; 86 | unsigned long lastSent; 87 | 88 | static void stepValue(int16_t& current, int16_t end, int16_t stepSize); 89 | }; -------------------------------------------------------------------------------- /docs/gh-pages/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 23 | 24 | 25 | 60 | 61 | 62 | 65 | 66 |

MiLight Hub REST API Documentation

67 | 68 |
69 | 70 |
71 | Loading... 72 | 73 | -------------------------------------------------------------------------------- /lib/MiLight/RgbwPacketFormatter.h: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #ifndef _RGBW_PACKET_FORMATTER_H 4 | #define _RGBW_PACKET_FORMATTER_H 5 | 6 | #define RGBW_PROTOCOL_ID_BYTE 0xB0 7 | 8 | enum MiLightRgbwButton { 9 | RGBW_ALL_ON = 0x01, 10 | RGBW_ALL_OFF = 0x02, 11 | RGBW_GROUP_1_ON = 0x03, 12 | RGBW_GROUP_1_OFF = 0x04, 13 | RGBW_GROUP_2_ON = 0x05, 14 | RGBW_GROUP_2_OFF = 0x06, 15 | RGBW_GROUP_3_ON = 0x07, 16 | RGBW_GROUP_3_OFF = 0x08, 17 | RGBW_GROUP_4_ON = 0x09, 18 | RGBW_GROUP_4_OFF = 0x0A, 19 | RGBW_SPEED_UP = 0x0B, 20 | RGBW_SPEED_DOWN = 0x0C, 21 | RGBW_DISCO_MODE = 0x0D, 22 | RGBW_BRIGHTNESS = 0x0E, 23 | RGBW_COLOR = 0x0F, 24 | RGBW_ALL_MAX_LEVEL = 0x11, 25 | RGBW_ALL_MIN_LEVEL = 0x12, 26 | 27 | // These are the only mechanism (that I know of) to disable RGB and set the 28 | // color to white. 29 | RGBW_GROUP_1_MAX_LEVEL = 0x13, 30 | RGBW_GROUP_1_MIN_LEVEL = 0x14, 31 | RGBW_GROUP_2_MAX_LEVEL = 0x15, 32 | RGBW_GROUP_2_MIN_LEVEL = 0x16, 33 | RGBW_GROUP_3_MAX_LEVEL = 0x17, 34 | RGBW_GROUP_3_MIN_LEVEL = 0x18, 35 | RGBW_GROUP_4_MAX_LEVEL = 0x19, 36 | RGBW_GROUP_4_MIN_LEVEL = 0x1A, 37 | 38 | // Button codes for night mode. A long press on the corresponding OFF button 39 | // Not actually needed/used. 40 | RGBW_ALL_NIGHT = 0x12, 41 | RGBW_GROUP_1_NIGHT = 0x14, 42 | RGBW_GROUP_2_NIGHT = 0x16, 43 | RGBW_GROUP_3_NIGHT = 0x18, 44 | RGBW_GROUP_4_NIGHT = 0x1A, 45 | }; 46 | 47 | #define RGBW_COMMAND_INDEX 5 48 | #define RGBW_BRIGHTNESS_GROUP_INDEX 4 49 | #define RGBW_COLOR_INDEX 3 50 | #define RGBW_NUM_MODES 9 51 | 52 | class RgbwPacketFormatter : public PacketFormatter { 53 | public: 54 | RgbwPacketFormatter() 55 | : PacketFormatter(REMOTE_TYPE_RGBW, 7) 56 | { } 57 | 58 | virtual bool canHandle(const uint8_t* packet, const size_t len); 59 | virtual void updateStatus(MiLightStatus status, uint8_t groupId); 60 | virtual void updateBrightness(uint8_t value); 61 | virtual void command(uint8_t command, uint8_t arg); 62 | virtual void updateHue(uint16_t value); 63 | virtual void updateColorRaw(uint8_t value); 64 | virtual void updateColorWhite(); 65 | virtual void format(uint8_t const* packet, char* buffer); 66 | virtual void unpair(); 67 | virtual void modeSpeedDown(); 68 | virtual void modeSpeedUp(); 69 | virtual void nextMode(); 70 | virtual void previousMode(); 71 | virtual void updateMode(uint8_t mode); 72 | virtual void enableNightMode(); 73 | virtual BulbId parsePacket(const uint8_t* packet, JsonObject result); 74 | 75 | virtual void initializePacket(uint8_t* packet); 76 | 77 | protected: 78 | static bool isStatusCommand(const uint8_t command); 79 | uint8_t currentMode(); 80 | }; 81 | 82 | #endif 83 | -------------------------------------------------------------------------------- /lib/Radio/LT8900MiLightRadio.h: -------------------------------------------------------------------------------- 1 | #ifdef ARDUINO 2 | #include "Arduino.h" 3 | #else 4 | #include 5 | #include 6 | #include 7 | #endif 8 | 9 | #include 10 | #include 11 | 12 | //#define DEBUG_PRINTF 13 | 14 | // Register defines 15 | #define REGISTER_READ 0b10000000 //bin 16 | #define REGISTER_WRITE 0b00000000 //bin 17 | #define REGISTER_MASK 0b01111111 //bin 18 | 19 | #define R_CHANNEL 7 20 | #define CHANNEL_RX_BIT 7 21 | #define CHANNEL_TX_BIT 8 22 | #define CHANNEL_MASK 0b01111111 ///bin 23 | 24 | #define STATUS_PKT_BIT_MASK 0x40 25 | 26 | #define R_STATUS 48 27 | #define STATUS_CRC_BIT 15 28 | 29 | #define R_FIFO 50 30 | #define R_FIFO_CONTROL 52 31 | 32 | #define R_SYNCWORD1 36 33 | #define R_SYNCWORD2 37 34 | #define R_SYNCWORD3 38 35 | #define R_SYNCWORD4 39 36 | 37 | #define DEFAULT_TIME_BETWEEN_RETRANSMISSIONS_uS 350 38 | // #define DEFAULT_TIME_BETWEEN_RETRANSMISSIONS_uS 0 39 | 40 | #ifndef MILIGHTRADIOPL1167_LT8900_H_ 41 | #define MILIGHTRADIOPL1167_LT8900_H_ 42 | 43 | class LT8900MiLightRadio : public MiLightRadio { 44 | public: 45 | LT8900MiLightRadio(byte byCSPin, byte byResetPin, byte byPktFlag, const MiLightRadioConfig& config); 46 | 47 | virtual int begin(); 48 | virtual bool available(); 49 | virtual int read(uint8_t frame[], size_t &frame_length); 50 | virtual int write(uint8_t frame[], size_t frame_length); 51 | virtual int resend(); 52 | virtual int configure(); 53 | virtual const MiLightRadioConfig& config(); 54 | 55 | private: 56 | 57 | void vInitRadioModule(); 58 | void vSetSyncWord(uint16_t syncWord3, uint16_t syncWord2, uint16_t syncWord1, uint16_t syncWord0); 59 | uint16_t uiReadRegister(uint8_t reg); 60 | void regWrite16(byte ADDR, byte V1, byte V2, byte WAIT); 61 | uint8_t uiWriteRegister(uint8_t reg, uint16_t data); 62 | 63 | bool bAvailablePin(void); 64 | bool bAvailableRegister(void); 65 | void vStartListening(uint uiChannelToListenTo); 66 | void vResumeRX(void); 67 | int iReadRXBuffer(uint8_t *buffer, size_t maxBuffer); 68 | void vSetChannel(uint8_t channel); 69 | void vGenericSendPacket(int iMode, int iLength, byte *pbyFrame, byte byChannel ); 70 | bool bCheckRadioConnection(void); 71 | bool sendPacket(uint8_t *data, size_t packetSize,byte byChannel); 72 | 73 | byte _pin_pktflag; 74 | byte _csPin; 75 | bool _bConnected; 76 | 77 | const MiLightRadioConfig& _config; 78 | 79 | uint8_t _channel; 80 | uint8_t _packet[10]; 81 | uint8_t _out_packet[10]; 82 | bool _waiting; 83 | int _dupes_received; 84 | size_t _currentPacketLen; 85 | size_t _currentPacketPos; 86 | }; 87 | 88 | 89 | 90 | #endif 91 | -------------------------------------------------------------------------------- /lib/Udp/V6RgbCctCommandHandler.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | bool V6RgbCctCommandHandler::handlePreset( 4 | MiLightClient* client, 5 | uint8_t commandLsb, 6 | uint32_t commandArg) 7 | { 8 | if (commandLsb == 0) { 9 | const uint8_t saturation = commandArg >> 24; 10 | const uint8_t color = (commandArg >> 16); 11 | const uint8_t brightness = (commandArg >> 8); 12 | 13 | client->updateBrightness(brightness); 14 | client->updateColorRaw(color); 15 | client->updateSaturation(saturation); 16 | } else if (commandLsb == 1) { 17 | const uint8_t brightness = (commandArg >> 16); 18 | const uint8_t kelvin = (commandArg >> 8); 19 | 20 | client->updateBrightness(brightness); 21 | client->updateTemperature(0x64 - kelvin); 22 | } else { 23 | return false; 24 | } 25 | 26 | return true; 27 | } 28 | 29 | bool V6RgbCctCommandHandler::handleCommand( 30 | MiLightClient* client, 31 | uint32_t command, 32 | uint32_t commandArg) 33 | { 34 | const uint8_t cmd = command & 0x7F; 35 | const uint8_t arg = commandArg >> 24; 36 | 37 | client->setHeld((command & 0x80) == 0x80); 38 | 39 | if (cmd == V2_STATUS) { 40 | switch (arg) { 41 | case V2_RGB_CCT_ON: 42 | case V2_RGB_CCT_OFF: 43 | client->updateStatus(arg == V2_RGB_CCT_ON ? ON : OFF); 44 | break; 45 | 46 | case V2_RGB_NIGHT_MODE: 47 | client->enableNightMode(); 48 | break; 49 | 50 | case V2_RGB_CCT_SPEED_DOWN: 51 | client->modeSpeedDown(); 52 | break; 53 | 54 | case V2_RGB_CCT_SPEED_UP: 55 | client->modeSpeedUp(); 56 | break; 57 | 58 | default: 59 | return false; 60 | } 61 | 62 | return true; 63 | } 64 | 65 | switch (cmd) { 66 | case V2_COLOR: 67 | handleUpdateColor(client, commandArg); 68 | break; 69 | 70 | case V2_KELVIN: 71 | client->updateTemperature(100 - arg); 72 | break; 73 | 74 | case V2_BRIGHTNESS: 75 | client->updateBrightness(arg); 76 | break; 77 | 78 | case V2_SATURATION: 79 | client->updateSaturation(100 - arg); 80 | break; 81 | 82 | case V2_MODE: 83 | client->updateMode(arg-1); 84 | break; 85 | 86 | default: 87 | return false; 88 | } 89 | 90 | return true; 91 | } 92 | 93 | /* 94 | * Arguments are 32 bits. Most commands use the first byte, but color arguments 95 | * can use all four. Triggered in app when quickly transitioning through colors. 96 | */ 97 | void V6RgbCctCommandHandler::handleUpdateColor(MiLightClient *client, uint32_t color) { 98 | for (int i = 3; i >= 0; i--) { 99 | const uint8_t argValue = (color >> (i*8)) & 0xFF; 100 | 101 | client->updateColorRaw(argValue + 0xF6); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /lib/MiLight/FUT020PacketFormatter.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | void FUT020PacketFormatter::updateColorRaw(uint8_t color) { 5 | command(static_cast(FUT020Command::COLOR), color); 6 | } 7 | 8 | void FUT020PacketFormatter::updateHue(uint16_t hue) { 9 | uint16_t remapped = Units::rescale(hue, 255.0, 360.0); 10 | remapped = (remapped + 0xB0) % 0x100; 11 | 12 | updateColorRaw(remapped); 13 | } 14 | 15 | void FUT020PacketFormatter::updateColorWhite() { 16 | command(static_cast(FUT020Command::COLOR_WHITE_TOGGLE), 0); 17 | } 18 | 19 | void FUT020PacketFormatter::nextMode() { 20 | command(static_cast(FUT020Command::MODE_SWITCH), 0); 21 | } 22 | 23 | void FUT020PacketFormatter::updateBrightness(uint8_t value) { 24 | const GroupState* state = this->stateStore->get(deviceId, groupId, MiLightRemoteType::REMOTE_TYPE_FUT020); 25 | int8_t knownValue = (state != NULL && state->isSetBrightness()) ? state->getBrightness() : -1; 26 | 27 | valueByStepFunction( 28 | &PacketFormatter::increaseBrightness, 29 | &PacketFormatter::decreaseBrightness, 30 | FUT02xPacketFormatter::NUM_BRIGHTNESS_INTERVALS, 31 | value / FUT02xPacketFormatter::NUM_BRIGHTNESS_INTERVALS, 32 | knownValue / FUT02xPacketFormatter::NUM_BRIGHTNESS_INTERVALS 33 | ); 34 | } 35 | 36 | void FUT020PacketFormatter::increaseBrightness() { 37 | command(static_cast(FUT020Command::BRIGHTNESS_UP), 0); 38 | } 39 | 40 | void FUT020PacketFormatter::decreaseBrightness() { 41 | command(static_cast(FUT020Command::BRIGHTNESS_DOWN), 0); 42 | } 43 | 44 | void FUT020PacketFormatter::updateStatus(MiLightStatus status, uint8_t groupId) { 45 | command(static_cast(FUT020Command::ON_OFF), 0); 46 | } 47 | 48 | BulbId FUT020PacketFormatter::parsePacket(const uint8_t* packet, JsonObject result) { 49 | FUT020Command command = static_cast(packet[FUT02xPacketFormatter::FUT02X_COMMAND_INDEX] & 0x0F); 50 | 51 | BulbId bulbId( 52 | (packet[1] << 8) | packet[2], 53 | 0, 54 | REMOTE_TYPE_FUT020 55 | ); 56 | 57 | switch (command) { 58 | case FUT020Command::ON_OFF: 59 | result[F("state")] = F("ON"); 60 | break; 61 | 62 | case FUT020Command::BRIGHTNESS_DOWN: 63 | result[F("command")] = F("brightness_down"); 64 | break; 65 | 66 | case FUT020Command::BRIGHTNESS_UP: 67 | result[F("command")] = F("brightness_up"); 68 | break; 69 | 70 | case FUT020Command::MODE_SWITCH: 71 | result[F("command")] = F("next_mode"); 72 | break; 73 | 74 | case FUT020Command::COLOR_WHITE_TOGGLE: 75 | result[F("command")] = F("color_white_toggle"); 76 | break; 77 | 78 | case FUT020Command::COLOR: 79 | uint16_t remappedColor = Units::rescale(packet[FUT02xPacketFormatter::FUT02X_ARGUMENT_INDEX], 360.0, 255.0); 80 | remappedColor = (remappedColor + 113) % 360; 81 | result[GroupStateFieldNames::HUE] = remappedColor; 82 | break; 83 | } 84 | 85 | return bulbId; 86 | } -------------------------------------------------------------------------------- /test/remote/lib/mqtt_client.rb: -------------------------------------------------------------------------------- 1 | require 'mqtt' 2 | require 'timeout' 3 | require 'json' 4 | 5 | class MqttClient 6 | BreakListenLoopError = Class.new(StandardError) 7 | 8 | def initialize(server, username, password, topic_prefix) 9 | @client = MQTT::Client.connect("mqtt://#{username}:#{password}@#{server}") 10 | @topic_prefix = topic_prefix 11 | @listen_threads = [] 12 | end 13 | 14 | def disconnect 15 | @client.disconnect 16 | end 17 | 18 | def reconnect 19 | @client.disconnect 20 | @client.connect 21 | end 22 | 23 | def wait_for_message(topic, timeout = 10) 24 | on_message(topic, timeout) { |topic, message| } 25 | wait_for_listeners 26 | end 27 | 28 | def id_topic_suffix(params) 29 | if params 30 | str_id = if params[:id_format] == 'decimal' 31 | params[:id].to_s 32 | else 33 | sprintf '0x%04X', params[:id] 34 | end 35 | 36 | "#{str_id}/#{params[:type]}/#{params[:group_id]}" 37 | else 38 | "+/+/+" 39 | end 40 | end 41 | 42 | def on_update(id_params = nil, timeout = 10, &block) 43 | on_id_message('updates', id_params, timeout, &block) 44 | end 45 | 46 | def on_state(id_params = nil, timeout = 10, &block) 47 | on_id_message('state', id_params, timeout, &block) 48 | end 49 | 50 | def on_id_message(path, id_params, timeout, &block) 51 | sub_topic = "#{@topic_prefix}#{path}/#{id_topic_suffix(nil)}" 52 | 53 | on_message(sub_topic, timeout) do |topic, message| 54 | topic_parts = topic.split('/') 55 | topic_id_params = { 56 | id: topic_parts[2].to_i(16), 57 | type: topic_parts[3], 58 | group_id: topic_parts[4].to_i, 59 | unparsed_id: topic_parts[2] 60 | } 61 | 62 | if !id_params || %w(id type group_id).all? { |k| k=k.to_sym; topic_id_params[k] == id_params[k] } 63 | begin 64 | message = JSON.parse(message) 65 | rescue JSON::ParserError => e 66 | end 67 | 68 | yield( topic_id_params, message ) 69 | end 70 | end 71 | end 72 | 73 | def on_message(topic, timeout = 10, raise_error = true, &block) 74 | @listen_threads << Thread.new do 75 | begin 76 | Timeout.timeout(timeout) do 77 | @client.get(topic) do |topic, message| 78 | ret_val = yield(topic, message) 79 | raise BreakListenLoopError if ret_val 80 | end 81 | end 82 | rescue Timeout::Error => e 83 | puts "Timed out listening for message on: #{topic}" 84 | raise e if raise_error 85 | rescue BreakListenLoopError 86 | end 87 | end 88 | end 89 | 90 | def publish(topic, state = {}, retain = false) 91 | state = state.to_json unless state.is_a?(String) 92 | 93 | @client.publish(topic, state, retain) 94 | end 95 | 96 | def patch_state(id_params, state = {}) 97 | @client.publish( 98 | "#{@topic_prefix}commands/#{id_topic_suffix(id_params)}", 99 | state.to_json 100 | ) 101 | end 102 | 103 | def wait_for_listeners 104 | @listen_threads.each(&:join) 105 | @listen_threads.clear 106 | end 107 | end -------------------------------------------------------------------------------- /test/remote/README.md: -------------------------------------------------------------------------------- 1 | ## Integration Tests 2 | 3 | This integration test suite is built using rspec. It integrates with espMH in a variety of ways, and monitors externally visible behaviors and states to ensure they match expectations. 4 | 5 | ### Setup 6 | 7 | 1. Copy `settings.json.example` to `settings.json` and make appropriate modifications for your setup. 8 | 1. Copy `espmh.env.example` to `espmh.env` and make appropriate modifications. For MQTT tests, you will need an external MQTT broker. 9 | 1. Install ruby and the bundler gem. 10 | 1. Run `bundle install`. 11 | 12 | ### Running 13 | 14 | Run the tests using `bundle exec rspec`. 15 | 16 | ### Example output 17 | 18 | ``` 19 | $ bundle exec rspec -f d 20 | 21 | Environment 22 | needs to have a settings.json file 23 | environment 24 | should have a host defined 25 | should respond to /about 26 | client 27 | should return IDs 28 | 29 | State 30 | deleting 31 | should remove retained state 32 | birth and LWT 33 | should send birth message when configured 34 | commands and state 35 | should affect state 36 | should publish to state topics 37 | should publish an update message for each new command 38 | should respect the state update interval 39 | :device_id token for command topic 40 | should support hexadecimal device IDs 41 | should support decimal device IDs 42 | :hex_device_id for command topic 43 | should respond to commands 44 | :dec_device_id for command topic 45 | should respond to commands 46 | :hex_device_id for update/state topics 47 | should publish updates with hexadecimal device ID 48 | should publish state with hexadecimal device ID 49 | :dec_device_id for update/state topics 50 | should publish updates with hexadecimal device ID 51 | should publish state with hexadecimal device ID 52 | 53 | REST Server 54 | authentication 55 | should not require auth unless both username and password are set 56 | should require auth for all routes when password is set 57 | 58 | Settings 59 | POST settings file 60 | should clobber patched settings 61 | should apply POSTed settings 62 | radio 63 | should store a set of channels 64 | should store a listen channel 65 | static ip 66 | should boot with static IP when applied 67 | 68 | State 69 | toggle command 70 | should toggle ON to OFF 71 | should toggle OFF to ON 72 | deleting 73 | should support deleting state 74 | persistence 75 | should persist parameters 76 | should affect member groups when changing group 0 77 | should keep group 0 state 78 | should clear group 0 state after member group state changes 79 | should not clear group 0 state when updating member group state if value is the same 80 | changing member state mode and then changing level should preserve group 0 brightness for original mode 81 | fields 82 | should support the color field 83 | increment/decrement commands 84 | should assume state after sufficiently many down commands 85 | should assume state after sufficiently many up commands 86 | should affect known state 87 | 88 | Finished in 2 minutes 36.9 seconds (files took 0.23476 seconds to load) 89 | 38 examples, 0 failures 90 | ``` -------------------------------------------------------------------------------- /test/remote/lib/api_client.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'net/http' 3 | require 'net/http/post/multipart' 4 | require 'uri' 5 | 6 | class ApiClient 7 | def initialize(host, base_id) 8 | @host = host 9 | @current_id = Integer(base_id) 10 | end 11 | 12 | def self.from_environment 13 | ApiClient.new( 14 | ENV.fetch('ESPMH_HOSTNAME'), 15 | ENV.fetch('ESPMH_TEST_DEVICE_ID_BASE') 16 | ) 17 | end 18 | 19 | def generate_id 20 | id = @current_id 21 | @current_id += 1 22 | id 23 | end 24 | 25 | def set_auth!(username, password) 26 | @username = username 27 | @password = password 28 | end 29 | 30 | def clear_auth! 31 | @username = nil 32 | @password = nil 33 | end 34 | 35 | def reboot 36 | post('/system', '{"command":"restart"}') 37 | end 38 | 39 | def request(type, path, req_body = nil) 40 | uri = URI("http://#{@host}#{path}") 41 | Net::HTTP.start(uri.host, uri.port) do |http| 42 | req_type = Net::HTTP.const_get(type) 43 | 44 | req = req_type.new(uri) 45 | if req_body 46 | req['Content-Type'] = 'application/json' 47 | req_body = req_body.to_json if !req_body.is_a?(String) 48 | req.body = req_body 49 | end 50 | 51 | if @username && @password 52 | req.basic_auth(@username, @password) 53 | end 54 | 55 | res = http.request(req) 56 | 57 | begin 58 | res.value 59 | rescue Exception => e 60 | puts "REST Client Error: #{e}\nBody:\n#{res.body}" 61 | raise e 62 | end 63 | 64 | body = res.body 65 | 66 | if res['content-type'].downcase == 'application/json' 67 | body = JSON.parse(body) 68 | end 69 | 70 | body 71 | end 72 | end 73 | 74 | def upload_json(path, file) 75 | `curl -s "http://#{@host}#{path}" -X POST -F 'f=@#{file}'` 76 | end 77 | 78 | def patch_settings(settings) 79 | put('/settings', settings) 80 | end 81 | 82 | def get(path) 83 | request(:Get, path) 84 | end 85 | 86 | def put(path, body) 87 | request(:Put, path, body) 88 | end 89 | 90 | def post(path, body) 91 | request(:Post, path, body) 92 | end 93 | 94 | def delete(path) 95 | request(:Delete, path) 96 | end 97 | 98 | def state_path(params = {}) 99 | query = if params[:blockOnQueue].nil? || params[:blockOnQueue] 100 | "?blockOnQueue=true" 101 | else 102 | "" 103 | end 104 | 105 | "/gateways/#{params[:id]}/#{params[:type]}/#{params[:group_id]}#{query}" 106 | end 107 | 108 | def delete_state(params = {}) 109 | delete(state_path(params)) 110 | end 111 | 112 | def get_state(params = {}) 113 | get(state_path(params)) 114 | end 115 | 116 | def patch_state(state, params = {}) 117 | put(state_path(params), state.to_json) 118 | end 119 | 120 | def schedule_transition(_id_params, transition_params) 121 | id_params = { 122 | device_id: _id_params[:id], 123 | remote_type: _id_params[:type], 124 | group_id: _id_params[:group_id] 125 | } 126 | 127 | post("/transitions", id_params.merge(transition_params)) 128 | end 129 | 130 | def transitions 131 | get('/transitions')['transitions'] 132 | end 133 | end -------------------------------------------------------------------------------- /lib/MiLight/MiLightRemoteConfig.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | /** 5 | * IMPORTANT NOTE: These should be in the same order as MiLightRemoteType. 6 | */ 7 | const MiLightRemoteConfig* MiLightRemoteConfig::ALL_REMOTES[] = { 8 | &FUT096Config, // rgbw 9 | &FUT007Config, // cct 10 | &FUT092Config, // rgb+cct 11 | &FUT098Config, // rgb 12 | &FUT089Config, // 8-group rgb+cct (b8, fut089) 13 | &FUT091Config, 14 | &FUT020Config 15 | }; 16 | 17 | const size_t MiLightRemoteConfig::NUM_REMOTES = size(ALL_REMOTES); 18 | 19 | const MiLightRemoteConfig* MiLightRemoteConfig::fromType(const String& type) { 20 | return fromType(MiLightRemoteTypeHelpers::remoteTypeFromString(type)); 21 | } 22 | 23 | const MiLightRemoteConfig* MiLightRemoteConfig::fromType(MiLightRemoteType type) { 24 | if (type == REMOTE_TYPE_UNKNOWN || type >= size(ALL_REMOTES)) { 25 | Serial.print(F("MiLightRemoteConfig::fromType: ERROR - tried to fetch remote config for unknown type: ")); 26 | Serial.println(type); 27 | return NULL; 28 | } 29 | 30 | return ALL_REMOTES[type]; 31 | } 32 | 33 | const MiLightRemoteConfig* MiLightRemoteConfig::fromReceivedPacket( 34 | const MiLightRadioConfig& radioConfig, 35 | const uint8_t* packet, 36 | const size_t len 37 | ) { 38 | for (size_t i = 0; i < MiLightRemoteConfig::NUM_REMOTES; i++) { 39 | const MiLightRemoteConfig* config = MiLightRemoteConfig::ALL_REMOTES[i]; 40 | if (&config->radioConfig == &radioConfig 41 | && config->packetFormatter->canHandle(packet, len)) { 42 | return config; 43 | } 44 | } 45 | 46 | // This can happen under normal circumstances, so not an error condition 47 | #ifdef DEBUG_PRINTF 48 | Serial.println(F("MiLightRemoteConfig::fromReceivedPacket: ERROR - tried to fetch remote config for unknown packet")); 49 | #endif 50 | 51 | return NULL; 52 | } 53 | 54 | const MiLightRemoteConfig FUT096Config( //rgbw 55 | new RgbwPacketFormatter(), 56 | MiLightRadioConfig::ALL_CONFIGS[0], 57 | REMOTE_TYPE_RGBW, 58 | "rgbw", 59 | 4 60 | ); 61 | 62 | const MiLightRemoteConfig FUT007Config( //cct 63 | new CctPacketFormatter(), 64 | MiLightRadioConfig::ALL_CONFIGS[1], 65 | REMOTE_TYPE_CCT, 66 | "cct", 67 | 4 68 | ); 69 | 70 | const MiLightRemoteConfig FUT091Config( //v2 cct 71 | new FUT091PacketFormatter(), 72 | MiLightRadioConfig::ALL_CONFIGS[2], 73 | REMOTE_TYPE_FUT091, 74 | "fut091", 75 | 4 76 | ); 77 | 78 | const MiLightRemoteConfig FUT092Config( //rgb+cct 79 | new RgbCctPacketFormatter(), 80 | MiLightRadioConfig::ALL_CONFIGS[2], 81 | REMOTE_TYPE_RGB_CCT, 82 | "rgb_cct", 83 | 4 84 | ); 85 | 86 | const MiLightRemoteConfig FUT089Config( //rgb+cct B8 / FUT089 87 | new FUT089PacketFormatter(), 88 | MiLightRadioConfig::ALL_CONFIGS[2], 89 | REMOTE_TYPE_FUT089, 90 | "fut089", 91 | 8 92 | ); 93 | 94 | const MiLightRemoteConfig FUT098Config( //rgb 95 | new RgbPacketFormatter(), 96 | MiLightRadioConfig::ALL_CONFIGS[3], 97 | REMOTE_TYPE_RGB, 98 | "rgb", 99 | 0 100 | ); 101 | 102 | const MiLightRemoteConfig FUT020Config( 103 | new FUT020PacketFormatter(), 104 | MiLightRadioConfig::ALL_CONFIGS[4], 105 | REMOTE_TYPE_FUT020, 106 | "fut020", 107 | 0 108 | ); -------------------------------------------------------------------------------- /lib/Radio/MiLightRadioConfig.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | #ifndef _MILIGHT_RADIO_CONFIG 7 | #define _MILIGHT_RADIO_CONFIG 8 | 9 | #define MILIGHT_MAX_PACKET_LENGTH 9 10 | 11 | class MiLightRadioConfig { 12 | public: 13 | static const size_t NUM_CHANNELS = 3; 14 | 15 | // We can set this to two possible values. It only has an affect on the nRF24 radio. The 16 | // LT8900/PL1167 radio will always use the raw syncwords. For the nRF24, this controls what 17 | // we set the "address" to, which roughly corresponds to the LT8900 syncword. 18 | // 19 | // The PL1167 packet is structured as follows (lengths in bits): 20 | // Preamble ( 8) | Syncword (32) | Trailer ( 4) | Packet Len ( 8) | Packet (...) 21 | // 22 | // 4 -- Use the raw syncword bits as the address. This means the Trailer will be included in 23 | // the packet data. Since the Trailer is 4 bits, packet data will not be byte-aligned, 24 | // and the data must be bitshifted every time it's received. 25 | // 26 | // 5 -- Include the Trailer in the syncword. Avoids us needing to bitshift packet data. The 27 | // downside is that the Trailer is hardcoded and assumed based on received packets. 28 | // 29 | // In general, this should be set to 5 unless packets that should be showing up are 30 | // mysteriously not present. 31 | static const uint8_t SYNCWORD_LENGTH = 5; 32 | 33 | MiLightRadioConfig( 34 | const uint16_t syncword0, 35 | const uint16_t syncword3, 36 | const size_t packetLength, 37 | const uint8_t channel0, 38 | const uint8_t channel1, 39 | const uint8_t channel2, 40 | const uint8_t preamble, 41 | const uint8_t trailer 42 | ) : syncword0(syncword0) 43 | , syncword3(syncword3) 44 | , packetLength(packetLength) 45 | { 46 | channels[0] = channel0; 47 | channels[1] = channel1; 48 | channels[2] = channel2; 49 | 50 | size_t ix = SYNCWORD_LENGTH; 51 | 52 | // precompute the syncword for the nRF24. we include the fixed preamble and trailer in the 53 | // syncword to avoid needing to bitshift packets. trailer is 4 bits, so the actual syncword 54 | // is no longer byte-aligned. 55 | if (SYNCWORD_LENGTH == 5) { 56 | syncwordBytes[ --ix ] = reverseBits( 57 | ((syncword0 << 4) & 0xF0) | (preamble & 0x0F) 58 | ); 59 | syncwordBytes[ --ix ] = reverseBits((syncword0 >> 4) & 0xFF); 60 | syncwordBytes[ --ix ] = reverseBits(((syncword0 >> 12) & 0x0F) + ((syncword3 << 4) & 0xF0)); 61 | syncwordBytes[ --ix ] = reverseBits((syncword3 >> 4) & 0xFF); 62 | syncwordBytes[ --ix ] = reverseBits( 63 | ((syncword3 >> 12) & 0x0F) | ((trailer << 4) & 0xF0) 64 | ); 65 | } else { 66 | syncwordBytes[ --ix ] = reverseBits(syncword0 & 0xff); 67 | syncwordBytes[ --ix ] = reverseBits( (syncword0 >> 8) & 0xff); 68 | syncwordBytes[ --ix ] = reverseBits(syncword3 & 0xff); 69 | syncwordBytes[ --ix ] = reverseBits( (syncword3 >> 8) & 0xff); 70 | } 71 | } 72 | 73 | uint8_t channels[3]; 74 | uint8_t syncwordBytes[SYNCWORD_LENGTH]; 75 | uint16_t syncword0, syncword3; 76 | 77 | const size_t packetLength; 78 | 79 | static const size_t NUM_CONFIGS = 5; 80 | static MiLightRadioConfig ALL_CONFIGS[NUM_CONFIGS]; 81 | }; 82 | 83 | #endif 84 | -------------------------------------------------------------------------------- /lib/Radio/NRF24MiLightRadio.cpp: -------------------------------------------------------------------------------- 1 | // Adapated from code from henryk 2 | 3 | #include 4 | #include 5 | 6 | #define PACKET_ID(packet, packet_length) ( (packet[1] << 8) | packet[packet_length - 1] ) 7 | 8 | NRF24MiLightRadio::NRF24MiLightRadio( 9 | RF24& rf24, 10 | const MiLightRadioConfig& config, 11 | const std::vector& channels, 12 | RF24Channel listenChannel 13 | ) 14 | : channels(channels), 15 | listenChannelIx(static_cast(listenChannel)), 16 | _pl1167(PL1167_nRF24(rf24)), 17 | _config(config), 18 | _waiting(false) 19 | { } 20 | 21 | int NRF24MiLightRadio::begin() { 22 | int retval = _pl1167.open(); 23 | if (retval < 0) { 24 | return retval; 25 | } 26 | 27 | retval = configure(); 28 | if (retval < 0) { 29 | return retval; 30 | } 31 | 32 | available(); 33 | 34 | return 0; 35 | } 36 | 37 | int NRF24MiLightRadio::configure() { 38 | int retval = _pl1167.setSyncword(_config.syncwordBytes, MiLightRadioConfig::SYNCWORD_LENGTH); 39 | if (retval < 0) { 40 | return retval; 41 | } 42 | 43 | // +1 to be able to buffer the length 44 | retval = _pl1167.setMaxPacketLength(_config.packetLength + 1); 45 | if (retval < 0) { 46 | return retval; 47 | } 48 | 49 | return 0; 50 | } 51 | 52 | bool NRF24MiLightRadio::available() { 53 | if (_waiting) { 54 | #ifdef DEBUG_PRINTF 55 | printf("_waiting\n"); 56 | #endif 57 | return true; 58 | } 59 | 60 | if (_pl1167.receive(_config.channels[listenChannelIx]) > 0) { 61 | #ifdef DEBUG_PRINTF 62 | printf("NRF24MiLightRadio - received packet!\n"); 63 | #endif 64 | size_t packet_length = sizeof(_packet); 65 | if (_pl1167.readFIFO(_packet, packet_length) < 0) { 66 | return false; 67 | } 68 | #ifdef DEBUG_PRINTF 69 | printf("NRF24MiLightRadio - Checking packet length (expecting %d, is %d)\n", _packet[0] + 1U, packet_length); 70 | #endif 71 | if (packet_length == 0 || packet_length != _packet[0] + 1U) { 72 | return false; 73 | } 74 | uint32_t packet_id = PACKET_ID(_packet, packet_length); 75 | #ifdef DEBUG_PRINTF 76 | printf("Packet id: %d\n", packet_id); 77 | #endif 78 | if (packet_id == _prev_packet_id) { 79 | _dupes_received++; 80 | } else { 81 | _prev_packet_id = packet_id; 82 | _waiting = true; 83 | } 84 | } 85 | 86 | return _waiting; 87 | } 88 | 89 | int NRF24MiLightRadio::read(uint8_t frame[], size_t &frame_length) 90 | { 91 | if (!_waiting) { 92 | frame_length = 0; 93 | return -1; 94 | } 95 | 96 | if (frame_length > sizeof(_packet) - 1) { 97 | frame_length = sizeof(_packet) - 1; 98 | } 99 | 100 | if (frame_length > _packet[0]) { 101 | frame_length = _packet[0]; 102 | } 103 | 104 | memcpy(frame, _packet + 1, frame_length); 105 | _waiting = false; 106 | 107 | return _packet[0]; 108 | } 109 | 110 | int NRF24MiLightRadio::write(uint8_t frame[], size_t frame_length) { 111 | if (frame_length > sizeof(_out_packet) - 1) { 112 | return -1; 113 | } 114 | 115 | memcpy(_out_packet + 1, frame, frame_length); 116 | _out_packet[0] = frame_length; 117 | 118 | int retval = resend(); 119 | if (retval < 0) { 120 | return retval; 121 | } 122 | return frame_length; 123 | } 124 | 125 | int NRF24MiLightRadio::resend() { 126 | for (std::vector::const_iterator it = channels.begin(); it != channels.end(); ++it) { 127 | size_t channelIx = static_cast(*it); 128 | uint8_t channel = _config.channels[channelIx]; 129 | 130 | _pl1167.writeFIFO(_out_packet, _out_packet[0] + 1); 131 | _pl1167.transmit(channel); 132 | } 133 | 134 | return 0; 135 | } 136 | 137 | const MiLightRadioConfig& NRF24MiLightRadio::config() { 138 | return _config; 139 | } 140 | -------------------------------------------------------------------------------- /platformio.ini: -------------------------------------------------------------------------------- 1 | ; PlatformIO Project Configuration File 2 | ; 3 | ; Build options: build flags, source filter 4 | ; Upload options: custom upload port, speed and extra flags 5 | ; Library options: dependencies, extra library storages 6 | ; Advanced options: extra scripting 7 | ; 8 | ; Please visit documentation for the other options and examples 9 | ; http://docs.platformio.org/page/projectconf.html 10 | 11 | [common] 12 | framework = arduino 13 | platform = espressif8266@~1.8 14 | board_f_cpu = 160000000L 15 | lib_deps_builtin = 16 | SPI 17 | lib_deps_external = 18 | sidoh/WiFiManager#cmidgley 19 | RF24@~1.3.2 20 | ArduinoJson@~6.10.1 21 | PubSubClient@~2.7 22 | ratkins/RGBConverter@07010f2 23 | WebSockets@~2.1.2 24 | CircularBuffer@~1.2.0 25 | PathVariableHandlers@~2.0.0 26 | RichHttpServer@~2.0.2 27 | extra_scripts = 28 | pre:.build_web.py 29 | test_ignore = remote 30 | upload_speed = 460800 31 | build_flags = 32 | !python .get_version.py 33 | # For compatibility with WebSockets 2.1.4 and v2.4 of the Arduino SDK 34 | -D USING_AXTLS 35 | -D MQTT_MAX_PACKET_SIZE=250 36 | -D HTTP_UPLOAD_BUFLEN=128 37 | -D FIRMWARE_NAME=milight-hub 38 | -D RICH_HTTP_REQUEST_BUFFER_SIZE=2048 39 | -D RICH_HTTP_RESPONSE_BUFFER_SIZE=2048 40 | -Idist -Ilib/DataStructures 41 | # -D STATE_DEBUG 42 | # -D DEBUG_PRINTF 43 | # -D MQTT_DEBUG 44 | # -D MILIGHT_UDP_DEBUG 45 | # -D STATE_DEBUG 46 | 47 | [env:nodemcuv2] 48 | platform = ${common.platform} 49 | framework = ${common.framework} 50 | upload_speed = ${common.upload_speed} 51 | board = nodemcuv2 52 | build_flags = ${common.build_flags} -Wl,-Tesp8266.flash.4m1m.ld -D FIRMWARE_VARIANT=nodemcuv2 53 | extra_scripts = ${common.extra_scripts} 54 | lib_deps = 55 | ${common.lib_deps_builtin} 56 | ${common.lib_deps_external} 57 | test_ignore = ${common.test_ignore} 58 | 59 | [env:d1_mini] 60 | platform = ${common.platform} 61 | framework = ${common.framework} 62 | upload_speed = ${common.upload_speed} 63 | board = d1_mini 64 | build_flags = ${common.build_flags} -Wl,-Tesp8266.flash.4m1m.ld -D FIRMWARE_VARIANT=d1_mini 65 | extra_scripts = ${common.extra_scripts} 66 | lib_deps = 67 | ${common.lib_deps_builtin} 68 | ${common.lib_deps_external} 69 | test_ignore = ${common.test_ignore} 70 | 71 | [env:esp12] 72 | platform = ${common.platform} 73 | framework = ${common.framework} 74 | upload_speed = ${common.upload_speed} 75 | board = esp12e 76 | build_flags = ${common.build_flags} -Wl,-Tesp8266.flash.4m1m.ld -D FIRMWARE_VARIANT=esp12 77 | extra_scripts = ${common.extra_scripts} 78 | lib_deps = 79 | ${common.lib_deps_builtin} 80 | ${common.lib_deps_external} 81 | test_ignore = ${common.test_ignore} 82 | 83 | [env:esp07] 84 | platform = ${common.platform} 85 | framework = ${common.framework} 86 | upload_speed = ${common.upload_speed} 87 | board = esp07 88 | build_flags = ${common.build_flags} -Wl,-Tesp8266.flash.1m64.ld -D FIRMWARE_VARIANT=esp07 89 | extra_scripts = ${common.extra_scripts} 90 | lib_deps = 91 | ${common.lib_deps_builtin} 92 | ${common.lib_deps_external} 93 | test_ignore = ${common.test_ignore} 94 | 95 | [env:huzzah] 96 | platform = ${common.platform} 97 | framework = ${common.framework} 98 | upload_speed = ${common.upload_speed} 99 | board = huzzah 100 | build_flags = ${common.build_flags} -D FIRMWARE_VARIANT=huzzah 101 | extra_scripts = ${common.extra_scripts} 102 | lib_deps = 103 | ${common.lib_deps_builtin} 104 | ${common.lib_deps_external} 105 | test_ignore = ${common.test_ignore} 106 | 107 | [env:d1_mini_pro] 108 | platform = ${common.platform} 109 | framework = ${common.framework} 110 | upload_speed = ${common.upload_speed} 111 | board = d1_mini_pro 112 | build_flags = ${common.build_flags} -Wl,-Tesp8266.flash.4m1m.ld -D FIRMWARE_VARIANT=d1_mini_PRO 113 | extra_scripts = ${common.extra_scripts} 114 | lib_deps = 115 | ${common.lib_deps_builtin} 116 | ${common.lib_deps_external} 117 | test_ignore = ${common.test_ignore} -------------------------------------------------------------------------------- /lib/Transitions/TransitionController.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | #include 9 | #include 10 | #include 11 | 12 | using namespace std::placeholders; 13 | 14 | TransitionController::TransitionController() 15 | : callback(std::bind(&TransitionController::transitionCallback, this, _1, _2, _3)) 16 | , currentId(0) 17 | { } 18 | 19 | void TransitionController::clearListeners() { 20 | observers.clear(); 21 | } 22 | 23 | void TransitionController::addListener(Transition::TransitionFn fn) { 24 | observers.push_back(fn); 25 | } 26 | 27 | std::shared_ptr TransitionController::buildColorTransition(const BulbId& bulbId, const ParsedColor& start, const ParsedColor& end) { 28 | return std::make_shared( 29 | currentId++, 30 | bulbId, 31 | callback, 32 | start, 33 | end 34 | ); 35 | } 36 | 37 | std::shared_ptr TransitionController::buildFieldTransition(const BulbId& bulbId, GroupStateField field, uint16_t start, uint16_t end) { 38 | return std::make_shared( 39 | currentId++, 40 | bulbId, 41 | callback, 42 | field, 43 | start, 44 | end 45 | ); 46 | } 47 | 48 | std::shared_ptr TransitionController::buildStatusTransition(const BulbId& bulbId, MiLightStatus status, uint8_t startLevel) { 49 | std::shared_ptr transition; 50 | 51 | if (status == ON) { 52 | // Make sure bulb is on before transitioning brightness 53 | callback(bulbId, GroupStateField::STATUS, ON); 54 | 55 | transition = buildFieldTransition(bulbId, GroupStateField::LEVEL, startLevel, 100); 56 | } else { 57 | transition = std::make_shared( 58 | currentId++, 59 | GroupStateField::STATUS, 60 | OFF, 61 | buildFieldTransition(bulbId, GroupStateField::LEVEL, startLevel, 0) 62 | ); 63 | } 64 | 65 | return transition; 66 | } 67 | 68 | void TransitionController::addTransition(std::shared_ptr transition) { 69 | activeTransitions.add(transition); 70 | } 71 | 72 | void TransitionController::transitionCallback(const BulbId& bulbId, GroupStateField field, uint16_t arg) { 73 | for (auto it = observers.begin(); it != observers.end(); ++it) { 74 | (*it)(bulbId, field, arg); 75 | } 76 | } 77 | 78 | void TransitionController::clear() { 79 | activeTransitions.clear(); 80 | } 81 | 82 | void TransitionController::loop() { 83 | auto current = activeTransitions.getHead(); 84 | 85 | while (current != nullptr) { 86 | auto next = current->next; 87 | 88 | Transition& t = *current->data; 89 | t.tick(); 90 | 91 | if (t.isFinished()) { 92 | activeTransitions.remove(current); 93 | } 94 | 95 | current = next; 96 | } 97 | } 98 | 99 | ListNode>* TransitionController::getTransitions() { 100 | return activeTransitions.getHead(); 101 | } 102 | 103 | ListNode>* TransitionController::findTransition(size_t id) { 104 | auto current = getTransitions(); 105 | 106 | while (current != nullptr) { 107 | if (current->data->id == id) { 108 | return current; 109 | } 110 | current = current->next; 111 | } 112 | 113 | return nullptr; 114 | } 115 | 116 | Transition* TransitionController::getTransition(size_t id) { 117 | auto node = findTransition(id); 118 | 119 | if (node == nullptr) { 120 | return nullptr; 121 | } else { 122 | return node->data.get(); 123 | } 124 | } 125 | 126 | bool TransitionController::deleteTransition(size_t id) { 127 | auto node = findTransition(id); 128 | 129 | if (node == nullptr) { 130 | return false; 131 | } else { 132 | activeTransitions.remove(node); 133 | return true; 134 | } 135 | } -------------------------------------------------------------------------------- /lib/MiLight/PacketFormatter.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | 10 | #ifndef _PACKET_FORMATTER_H 11 | #define _PACKET_FORMATTER_H 12 | 13 | // Most packets sent is for CCT bulbs, which always includes 10 down commands 14 | // and can include up to 10 up commands. CCT packets are 7 bytes. 15 | // (10 * 7) + (10 * 7) = 140 16 | #define PACKET_FORMATTER_BUFFER_SIZE 140 17 | 18 | struct PacketStream { 19 | PacketStream(); 20 | 21 | uint8_t* next(); 22 | bool hasNext(); 23 | 24 | uint8_t* packetStream; 25 | size_t numPackets; 26 | size_t packetLength; 27 | size_t currentPacket; 28 | }; 29 | 30 | class PacketFormatter { 31 | public: 32 | PacketFormatter(const MiLightRemoteType deviceType, const size_t packetLength, const size_t maxPackets = 1); 33 | 34 | // Ideally these would be constructor parameters. We could accomplish this by 35 | // wrapping PacketFormaters in a factory, as Settings and StateStore are not 36 | // available at construction time. 37 | // 38 | // For now, just rely on the user calling this method. 39 | void initialize(GroupStateStore* stateStore, const Settings* settings); 40 | 41 | typedef void (PacketFormatter::*StepFunction)(); 42 | 43 | virtual bool canHandle(const uint8_t* packet, const size_t len); 44 | 45 | void updateStatus(MiLightStatus status); 46 | void toggleStatus(); 47 | virtual void updateStatus(MiLightStatus status, uint8_t groupId); 48 | virtual void command(uint8_t command, uint8_t arg); 49 | 50 | virtual void setHeld(bool held); 51 | 52 | // Mode 53 | virtual void updateMode(uint8_t value); 54 | virtual void modeSpeedDown(); 55 | virtual void modeSpeedUp(); 56 | virtual void nextMode(); 57 | virtual void previousMode(); 58 | 59 | virtual void pair(); 60 | virtual void unpair(); 61 | 62 | // Color 63 | virtual void updateHue(uint16_t value); 64 | virtual void updateColorRaw(uint8_t value); 65 | virtual void updateColorWhite(); 66 | 67 | // White temperature 68 | virtual void increaseTemperature(); 69 | virtual void decreaseTemperature(); 70 | virtual void updateTemperature(uint8_t value); 71 | 72 | // Brightness 73 | virtual void updateBrightness(uint8_t value); 74 | virtual void increaseBrightness(); 75 | virtual void decreaseBrightness(); 76 | virtual void enableNightMode(); 77 | 78 | virtual void updateSaturation(uint8_t value); 79 | 80 | virtual void reset(); 81 | 82 | virtual PacketStream& buildPackets(); 83 | virtual void prepare(uint16_t deviceId, uint8_t groupId); 84 | virtual void format(uint8_t const* packet, char* buffer); 85 | 86 | virtual BulbId parsePacket(const uint8_t* packet, JsonObject result); 87 | virtual BulbId currentBulbId() const; 88 | 89 | static void formatV1Packet(uint8_t const* packet, char* buffer); 90 | 91 | size_t getPacketLength() const; 92 | 93 | protected: 94 | const MiLightRemoteType deviceType; 95 | size_t packetLength; 96 | size_t numPackets; 97 | uint8_t* currentPacket; 98 | bool held; 99 | uint16_t deviceId; 100 | uint8_t groupId; 101 | uint8_t sequenceNum; 102 | PacketStream packetStream; 103 | GroupStateStore* stateStore = NULL; 104 | const Settings* settings = NULL; 105 | 106 | void pushPacket(); 107 | 108 | // Get field into a desired state using only increment/decrement commands. Do this by: 109 | // 1. Driving it down to its minimum value 110 | // 2. Applying the appropriate number of increase commands to get it to the desired 111 | // value. 112 | // If the current state is already known, take that into account and apply the exact 113 | // number of rpeeats for the appropriate command. 114 | void valueByStepFunction(StepFunction increase, StepFunction decrease, uint8_t numSteps, uint8_t targetValue, int8_t knownValue = -1); 115 | 116 | virtual void initializePacket(uint8_t* packetStart) = 0; 117 | virtual void finalizePacket(uint8_t* packet); 118 | }; 119 | 120 | #endif 121 | -------------------------------------------------------------------------------- /lib/WebServer/MiLightHttpServer.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | 10 | #ifndef _MILIGHT_HTTP_SERVER 11 | #define _MILIGHT_HTTP_SERVER 12 | 13 | #define MAX_DOWNLOAD_ATTEMPTS 3 14 | 15 | typedef std::function SettingsSavedHandler; 16 | typedef std::function GroupDeletedHandler; 17 | 18 | using RichHttpConfig = RichHttp::Generics::Configs::EspressifBuiltin; 19 | using RequestContext = RichHttpConfig::RequestContextType; 20 | 21 | const char TEXT_PLAIN[] PROGMEM = "text/plain"; 22 | const char APPLICATION_JSON[] = "application/json"; 23 | 24 | class MiLightHttpServer { 25 | public: 26 | MiLightHttpServer( 27 | Settings& settings, 28 | MiLightClient*& milightClient, 29 | GroupStateStore*& stateStore, 30 | PacketSender*& packetSender, 31 | RadioSwitchboard*& radios, 32 | TransitionController& transitions 33 | ) 34 | : authProvider(settings) 35 | , server(80, authProvider) 36 | , wsServer(WebSocketsServer(81)) 37 | , numWsClients(0) 38 | , milightClient(milightClient) 39 | , settings(settings) 40 | , stateStore(stateStore) 41 | , packetSender(packetSender) 42 | , radios(radios) 43 | , transitions(transitions) 44 | { } 45 | 46 | void begin(); 47 | void handleClient(); 48 | void onSettingsSaved(SettingsSavedHandler handler); 49 | void onGroupDeleted(GroupDeletedHandler handler); 50 | void on(const char* path, HTTPMethod method, ESP8266WebServer::THandlerFunction handler); 51 | void handlePacketSent(uint8_t* packet, const MiLightRemoteConfig& config); 52 | WiFiClient client(); 53 | 54 | protected: 55 | 56 | bool serveFile(const char* file, const char* contentType = "text/html"); 57 | void handleServe_P(const char* data, size_t length); 58 | void sendGroupState(BulbId& bulbId, GroupState* state, RichHttp::Response& response); 59 | 60 | void serveSettings(); 61 | void handleUpdateSettings(RequestContext& request); 62 | void handleUpdateSettingsPost(RequestContext& request); 63 | void handleUpdateFile(const char* filename); 64 | 65 | void handleGetRadioConfigs(RequestContext& request); 66 | 67 | void handleAbout(RequestContext& request); 68 | void handleSystemPost(RequestContext& request); 69 | void handleFirmwareUpload(); 70 | void handleFirmwarePost(); 71 | void handleListenGateway(RequestContext& request); 72 | void handleSendRaw(RequestContext& request); 73 | 74 | void handleUpdateGroup(RequestContext& request); 75 | void handleUpdateGroupAlias(RequestContext& request); 76 | 77 | void handleGetGroup(RequestContext& request); 78 | void handleGetGroupAlias(RequestContext& request); 79 | void _handleGetGroup(BulbId bulbId, RequestContext& request); 80 | 81 | void handleDeleteGroup(RequestContext& request); 82 | void handleDeleteGroupAlias(RequestContext& request); 83 | void _handleDeleteGroup(BulbId bulbId, RequestContext& request); 84 | 85 | void handleGetTransition(RequestContext& request); 86 | void handleDeleteTransition(RequestContext& request); 87 | void handleCreateTransition(RequestContext& request); 88 | void handleListTransitions(RequestContext& request); 89 | 90 | void handleRequest(const JsonObject& request); 91 | void handleWsEvent(uint8_t num, WStype_t type, uint8_t * payload, size_t length); 92 | 93 | File updateFile; 94 | 95 | PassthroughAuthProvider authProvider; 96 | RichHttpServer server; 97 | WebSocketsServer wsServer; 98 | size_t numWsClients; 99 | MiLightClient*& milightClient; 100 | Settings& settings; 101 | GroupStateStore*& stateStore; 102 | SettingsSavedHandler settingsSavedHandler; 103 | GroupDeletedHandler groupDeletedHandler; 104 | ESP8266WebServer::THandlerFunction _handleRootPage; 105 | PacketSender*& packetSender; 106 | RadioSwitchboard*& radios; 107 | TransitionController& transitions; 108 | 109 | }; 110 | 111 | #endif 112 | -------------------------------------------------------------------------------- /test/remote/helpers/transition_helpers.rb: -------------------------------------------------------------------------------- 1 | require 'chroma' 2 | 3 | module TransitionHelpers 4 | module Defaults 5 | DURATION = 4500 6 | PERIOD = 450 7 | NUM_PERIODS = 10 8 | end 9 | 10 | def highlight_value(a, highlight_ix) 11 | str = a 12 | .each_with_index 13 | .map do |x, i| 14 | i == highlight_ix ? ">>#{x}<<" : x 15 | end 16 | .join(', ') 17 | "[#{str}]" 18 | end 19 | 20 | def color_transitions_are_equal(expected:, seen:) 21 | %i(hue saturation).each do |label| 22 | e = expected.map { |x| x[label] } 23 | s = seen.map { |x| x[label] } 24 | 25 | transitions_are_equal(expected: e, seen: s, label: label, allowed_variation: label == :saturation ? 5 : 20) 26 | end 27 | end 28 | 29 | def transitions_are_equal(expected:, seen:, allowed_variation: 0, label: nil) 30 | generate_msg = ->(a, b, i) do 31 | s = "Transition step value" 32 | 33 | if !label.nil? 34 | s << " for #{label} " 35 | end 36 | 37 | s << "at index #{i} " 38 | 39 | s << if allowed_variation == 0 40 | "should be equal to expected value. Expected: #{a}, saw: #{b}." 41 | else 42 | "should be within #{allowed_variation} of expected value. Expected: #{a}, saw: #{b}." 43 | end 44 | 45 | s << " Steps:\n" 46 | s << " Expected : #{highlight_value(expected, i)},\n" 47 | s << " Seen : #{highlight_value(seen, i)}" 48 | end 49 | 50 | expect(expected.length).to eq(seen.length) 51 | 52 | expected.zip(seen).each_with_index do |x, i| 53 | a, b = x 54 | diff = (a - b).abs 55 | expect(diff).to be <= allowed_variation, generate_msg.call(a, b, i) 56 | end 57 | end 58 | 59 | def rgb_to_hs(*color) 60 | if color.length > 1 61 | r, g, b = color 62 | else 63 | r, g, b = coerce_color(color.first) 64 | end 65 | 66 | hsv = Chroma::Converters::HsvConverter.convert_rgb(Chroma::ColorModes::Rgb.new(r, g, b)) 67 | { hue: hsv.h.round, saturation: (100*hsv.s).round } 68 | end 69 | 70 | def coerce_color(c) 71 | c.split(',').map(&:to_i) unless c.is_a?(Array) 72 | end 73 | 74 | def calculate_color_transition_steps(start_color:, end_color:, duration: nil, period: nil, num_periods: Defaults::NUM_PERIODS) 75 | start_color = coerce_color(start_color) 76 | end_color = coerce_color(end_color) 77 | 78 | part_transitions = start_color.zip(end_color).map do |c| 79 | s, e = c 80 | calculate_transition_steps(start_value: s, end_value: e, duration: duration, period: period, num_periods: num_periods) 81 | end 82 | 83 | # If some colors don't transition, they'll stay at the same value while others move. 84 | # Turn this: [[1,2,3], [0], [4,5,6]] 85 | # Into this: [[1,2,3], [0,0,0], [4,5,6]] 86 | longest = part_transitions.max_by { |x| x.length }.length 87 | part_transitions.map! { |x| x + [x.last]*(longest-x.length) } 88 | 89 | # Zip individual parts into 3-tuples 90 | # Turn this: [[1,2,3], [0,0,0], [4,5,6]] 91 | # Into this: [[1,0,4], [2,0,5], [3,0,6]] 92 | transition_colors = part_transitions.first.zip(*part_transitions[1..part_transitions.length]) 93 | 94 | # Undergo the RGB -> HSV w/ value = 100 95 | transition_colors.map do |x| 96 | r, g, b = x 97 | rgb_to_hs(r, g, b) 98 | end 99 | end 100 | 101 | def calculate_transition_steps(start_value:, end_value:, duration: nil, period: nil, num_periods: Defaults::NUM_PERIODS) 102 | if !duration.nil? || !period.nil? 103 | period ||= Defaults::PERIOD 104 | duration ||= Defaults::DURATION 105 | num_periods = [1, (duration / period.to_f).ceil].max 106 | end 107 | 108 | diff = end_value - start_value 109 | step_size = [1, (diff.abs / num_periods.to_f).ceil].max 110 | step_size = -step_size if end_value < start_value 111 | 112 | steps = [] 113 | val = start_value 114 | 115 | while val != end_value 116 | steps << val 117 | 118 | if (end_value - val).abs < step_size.abs 119 | val += (end_value - val) 120 | else 121 | val += step_size 122 | end 123 | end 124 | 125 | steps << end_value 126 | steps 127 | end 128 | end -------------------------------------------------------------------------------- /lib/MiLight/PacketSender.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | PacketSender::PacketSender( 5 | RadioSwitchboard& radioSwitchboard, 6 | Settings& settings, 7 | PacketSentHandler packetSentHandler 8 | ) : radioSwitchboard(radioSwitchboard) 9 | , settings(settings) 10 | , currentPacket(nullptr) 11 | , packetRepeatsRemaining(0) 12 | , packetSentHandler(packetSentHandler) 13 | , lastSend(0) 14 | , currentResendCount(settings.packetRepeats) 15 | , throttleMultiplier( 16 | std::ceil( 17 | (settings.packetRepeatThrottleSensitivity / 1000.0) * settings.packetRepeats 18 | ) 19 | ) 20 | { } 21 | 22 | void PacketSender::enqueue(uint8_t* packet, const MiLightRemoteConfig* remoteConfig, const size_t repeatsOverride) { 23 | #ifdef DEBUG_PRINTF 24 | Serial.println("Enqueuing packet"); 25 | #endif 26 | size_t repeats = repeatsOverride == DEFAULT_PACKET_SENDS_VALUE 27 | ? this->currentResendCount 28 | : repeatsOverride; 29 | 30 | queue.push(packet, remoteConfig, repeats); 31 | } 32 | 33 | void PacketSender::loop() { 34 | // Switch to the next packet if we're done with the current one 35 | if (packetRepeatsRemaining == 0 && !queue.isEmpty()) { 36 | nextPacket(); 37 | } 38 | 39 | // If there's a packet we're handling, deal with it 40 | if (currentPacket != nullptr && packetRepeatsRemaining > 0) { 41 | handleCurrentPacket(); 42 | } 43 | } 44 | 45 | bool PacketSender::isSending() { 46 | return packetRepeatsRemaining > 0 || !queue.isEmpty(); 47 | } 48 | 49 | void PacketSender::nextPacket() { 50 | #ifdef DEBUG_PRINTF 51 | Serial.printf("Switching to next packet, %d packets in queue\n", queue.size()); 52 | #endif 53 | currentPacket = queue.pop(); 54 | 55 | if (currentPacket->repeatsOverride > 0) { 56 | packetRepeatsRemaining = currentPacket->repeatsOverride; 57 | } else { 58 | packetRepeatsRemaining = settings.packetRepeats; 59 | } 60 | 61 | // Adjust resend count according to throttling rules 62 | updateResendCount(); 63 | } 64 | 65 | void PacketSender::handleCurrentPacket() { 66 | // Always switch radio. could've been listening in another context 67 | radioSwitchboard.switchRadio(currentPacket->remoteConfig); 68 | 69 | size_t numToSend = std::min(packetRepeatsRemaining, settings.packetRepeatsPerLoop); 70 | sendRepeats(numToSend); 71 | packetRepeatsRemaining -= numToSend; 72 | 73 | // If we're done sending this packet, fire the sent packet callback 74 | if (packetRepeatsRemaining == 0 && packetSentHandler != nullptr) { 75 | packetSentHandler(currentPacket->packet, *currentPacket->remoteConfig); 76 | } 77 | } 78 | 79 | size_t PacketSender::queueLength() const { 80 | return queue.size(); 81 | } 82 | 83 | size_t PacketSender::droppedPackets() const { 84 | return queue.getDroppedPacketCount(); 85 | } 86 | 87 | void PacketSender::sendRepeats(size_t num) { 88 | size_t len = currentPacket->remoteConfig->packetFormatter->getPacketLength(); 89 | 90 | #ifdef DEBUG_PRINTF 91 | Serial.printf_P(PSTR("Sending packet (%d repeats): \n"), num); 92 | for (size_t i = 0; i < len; i++) { 93 | Serial.printf_P(PSTR("%02X "), currentPacket->packet[i]); 94 | } 95 | Serial.println(); 96 | int iStart = millis(); 97 | #endif 98 | 99 | for (size_t i = 0; i < num; ++i) { 100 | radioSwitchboard.write(currentPacket->packet, len); 101 | } 102 | 103 | #ifdef DEBUG_PRINTF 104 | int iElapsed = millis() - iStart; 105 | Serial.print("Elapsed: "); 106 | Serial.println(iElapsed); 107 | #endif 108 | } 109 | 110 | void PacketSender::updateResendCount() { 111 | unsigned long now = millis(); 112 | long millisSinceLastSend = now - lastSend; 113 | long x = (millisSinceLastSend - settings.packetRepeatThrottleThreshold); 114 | long delta = x * throttleMultiplier; 115 | int signedResends = static_cast(this->currentResendCount) + delta; 116 | 117 | if (signedResends < static_cast(settings.packetRepeatMinimum)) { 118 | signedResends = settings.packetRepeatMinimum; 119 | } else if (signedResends > static_cast(settings.packetRepeats)) { 120 | signedResends = settings.packetRepeats; 121 | } 122 | 123 | this->currentResendCount = signedResends; 124 | this->lastSend = now; 125 | } -------------------------------------------------------------------------------- /lib/MiLight/RgbPacketFormatter.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | void RgbPacketFormatter::initializePacket(uint8_t *packet) { 6 | size_t packetPtr = 0; 7 | 8 | packet[packetPtr++] = 0xA4; 9 | packet[packetPtr++] = deviceId >> 8; 10 | packet[packetPtr++] = deviceId & 0xFF; 11 | packet[packetPtr++] = 0; 12 | packet[packetPtr++] = 0; 13 | packet[packetPtr++] = sequenceNum++; 14 | } 15 | 16 | void RgbPacketFormatter::pair() { 17 | for (size_t i = 0; i < 5; i++) { 18 | command(RGB_SPEED_UP, 0); 19 | } 20 | } 21 | 22 | void RgbPacketFormatter::unpair() { 23 | for (size_t i = 0; i < 5; i++) { 24 | command(RGB_SPEED_UP | 0x10, 0); 25 | } 26 | } 27 | 28 | void RgbPacketFormatter::updateStatus(MiLightStatus status, uint8_t groupId) { 29 | command(status == ON ? RGB_ON : RGB_OFF, 0); 30 | } 31 | 32 | void RgbPacketFormatter::command(uint8_t command, uint8_t arg) { 33 | pushPacket(); 34 | if (held) { 35 | command |= 0x80; 36 | } 37 | currentPacket[RGB_COMMAND_INDEX] = command; 38 | } 39 | 40 | void RgbPacketFormatter::updateHue(uint16_t value) { 41 | const int16_t remappedColor = (value + 40) % 360; 42 | updateColorRaw(Units::rescale(remappedColor, 255, 360)); 43 | } 44 | 45 | void RgbPacketFormatter::updateColorRaw(uint8_t value) { 46 | command(0, 0); 47 | currentPacket[RGB_COLOR_INDEX] = value; 48 | } 49 | 50 | void RgbPacketFormatter::updateBrightness(uint8_t value) { 51 | const GroupState* state = this->stateStore->get(deviceId, groupId, MiLightRemoteType::REMOTE_TYPE_RGB); 52 | int8_t knownValue = (state != NULL && state->isSetBrightness()) ? state->getBrightness() : -1; 53 | 54 | valueByStepFunction( 55 | &PacketFormatter::increaseBrightness, 56 | &PacketFormatter::decreaseBrightness, 57 | RGB_INTERVALS, 58 | value / RGB_INTERVALS, 59 | knownValue / RGB_INTERVALS 60 | ); 61 | } 62 | 63 | void RgbPacketFormatter::increaseBrightness() { 64 | command(RGB_BRIGHTNESS_UP, 0); 65 | } 66 | 67 | void RgbPacketFormatter::decreaseBrightness() { 68 | command(RGB_BRIGHTNESS_DOWN, 0); 69 | } 70 | 71 | void RgbPacketFormatter::modeSpeedDown() { 72 | command(RGB_SPEED_DOWN, 0); 73 | } 74 | 75 | void RgbPacketFormatter::modeSpeedUp() { 76 | command(RGB_SPEED_UP, 0); 77 | } 78 | 79 | void RgbPacketFormatter::nextMode() { 80 | command(RGB_MODE_UP, 0); 81 | } 82 | 83 | void RgbPacketFormatter::previousMode() { 84 | command(RGB_MODE_DOWN, 0); 85 | } 86 | 87 | BulbId RgbPacketFormatter::parsePacket(const uint8_t* packet, JsonObject result) { 88 | uint8_t command = packet[RGB_COMMAND_INDEX] & 0x7F; 89 | 90 | BulbId bulbId( 91 | (packet[1] << 8) | packet[2], 92 | 0, 93 | REMOTE_TYPE_RGB 94 | ); 95 | 96 | if (command == RGB_ON) { 97 | result[GroupStateFieldNames::STATE] = "ON"; 98 | } else if (command == RGB_OFF) { 99 | result[GroupStateFieldNames::STATE] = "OFF"; 100 | } else if (command == 0) { 101 | uint16_t remappedColor = Units::rescale(packet[RGB_COLOR_INDEX], 360.0, 255.0); 102 | remappedColor = (remappedColor + 320) % 360; 103 | result[GroupStateFieldNames::HUE] = remappedColor; 104 | } else if (command == RGB_MODE_DOWN) { 105 | result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::PREVIOUS_MODE; 106 | } else if (command == RGB_MODE_UP) { 107 | result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::NEXT_MODE; 108 | } else if (command == RGB_SPEED_DOWN) { 109 | result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::MODE_SPEED_DOWN; 110 | } else if (command == RGB_SPEED_UP) { 111 | result[GroupStateFieldNames::COMMAND] = MiLightCommandNames::MODE_SPEED_UP; 112 | } else if (command == RGB_BRIGHTNESS_DOWN) { 113 | result[GroupStateFieldNames::COMMAND] = "brightness_down"; 114 | } else if (command == RGB_BRIGHTNESS_UP) { 115 | result[GroupStateFieldNames::COMMAND] = "brightness_up"; 116 | } else { 117 | result["button_id"] = command; 118 | } 119 | 120 | return bulbId; 121 | } 122 | 123 | void RgbPacketFormatter::format(uint8_t const* packet, char* buffer) { 124 | buffer += sprintf_P(buffer, PSTR("b0 : %02X\n"), packet[0]); 125 | buffer += sprintf_P(buffer, PSTR("ID : %02X%02X\n"), packet[1], packet[2]); 126 | buffer += sprintf_P(buffer, PSTR("Color : %02X\n"), packet[3]); 127 | buffer += sprintf_P(buffer, PSTR("Command : %02X\n"), packet[4]); 128 | buffer += sprintf_P(buffer, PSTR("Sequence : %02X\n"), packet[5]); 129 | } 130 | -------------------------------------------------------------------------------- /web/src/css/style.css: -------------------------------------------------------------------------------- 1 | .header-row { border-bottom: 1px solid #ccc; } 2 | label { display: block; } 3 | .radio-option { padding: 0 5px; cursor: pointer; } 4 | .command-buttons { list-style: none; margin: 0; padding: 0; } 5 | .command-buttons li { display: inline-block; margin-right: 1em; overflow: auto; } 6 | .form-entry { margin: 0 0 20px 0; } 7 | .form-entry .form-control { width: 20em; } 8 | .form-entry label { display: inline-block; } 9 | label:not(.error) .error-info { display: none; } 10 | .error-info { color: #f00; font-size: 0.7em; } 11 | .error-info:before { content: '('; } 12 | .error-info:after { content: ')'; } 13 | .header-btn { margin: 20px; } 14 | .gateway-add-btn { float: right; } 15 | #content .dropdown { position: initial; overflow: auto; } 16 | #content .dropdown-menu li { display: block; } 17 | #traffic-sniff { display: none; } 18 | #sniffed-traffic { max-height: 50em; overflow-y: auto; } 19 | .dropdown label { display: inline-block; } 20 | .navbar { background-color: rgba(41,41,45,.9) !important; border-radius: 0 } 21 | .navbar-brand, .navbar-brand:hover { color: white; } 22 | .btn-secondary { 23 | background-color: #fff; 24 | border: 1px solid #ccc; 25 | } 26 | .inline { display: inline-block; } 27 | .white-temp-picker { 28 | height: 2em; 29 | background: linear-gradient(to right, 30 | rgb(166, 209, 255) 0%, 31 | rgb(255, 255, 255) 50%, 32 | rgb(255, 160, 0) 100% 33 | ); 34 | display: inline-block; 35 | padding: 3px 0; 36 | } 37 | .hue-picker { 38 | height: 2em; 39 | width: 100%; 40 | display: block; 41 | } 42 | .hue-picker-inner { 43 | height: 2em; 44 | width: calc(100% - 3em); 45 | display: inline-block; 46 | cursor: pointer; 47 | background: linear-gradient(to right, 48 | rgb(255, 0, 0) 0%, 49 | rgb(255, 255, 0) 16.6667%, 50 | rgb(0, 255, 0) 33.3333%, 51 | rgb(0, 255, 255) 50%, 52 | rgb(0, 0, 255) 66.6667%, 53 | rgb(255, 0, 255) 83.3333%, 54 | rgb(255, 0, 0) 100% 55 | ); 56 | } 57 | .hue-value-display { 58 | border: 1px solid #000; 59 | margin-left: 0.5em; 60 | width: 2em; 61 | height: 2em; 62 | display: inline-block; 63 | } 64 | .plus-minus-group { 65 | overflow: auto; 66 | width: 100%; 67 | clear: both; 68 | display: block; 69 | } 70 | .plus-minus-group button:first-of-type { 71 | border-bottom-right-radius: 0; 72 | border-top-right-radius: 0; 73 | float: left; 74 | display: block; 75 | } 76 | .plus-minus-group button:last-of-type { 77 | border-bottom-left-radius: 0; 78 | border-top-left-radius: 0; 79 | display: block; 80 | } 81 | .plus-minus-group .title { 82 | border-width: 1px 0; 83 | border-color: #ccc; 84 | border-style: solid; 85 | padding: 5px 5px 5px 7px; 86 | margin: 0; 87 | height: 34px; 88 | line-height: 1.49; 89 | float: left; 90 | display: block; 91 | } 92 | .field-help { 93 | display: inline-block; 94 | font-size: 1.25em; 95 | margin-left: 0.5em; 96 | } 97 | .glyphicon.spinning { 98 | animation: spin 1s infinite linear; 99 | -webkit-animation: spin2 1s infinite linear; 100 | } 101 | 102 | html { 103 | position: relative; 104 | min-height: 100%; 105 | } 106 | body { 107 | margin-bottom: 200px; 108 | } 109 | footer { 110 | height: 200px; 111 | } 112 | @media only screen and (min-width : 320px) { 113 | body { 114 | margin-bottom: 250px; 115 | } 116 | } 117 | @media only screen and (min-width : 480px) { 118 | body { 119 | margin-bottom: 200px; 120 | } 121 | } 122 | @media only screen and (min-width : 768px) { 123 | body { 124 | margin-bottom: 158px; 125 | } 126 | } 127 | @media only screen and (min-width : 992px) { 128 | body { 129 | margin-bottom: 116px; 130 | } 131 | } 132 | 133 | .modal-body .section-title { 134 | border-bottom: 1px solid #eee; 135 | padding-bottom: 0.2em; 136 | } 137 | 138 | #sniff .glyphicon-play { 139 | color: #6f6; 140 | } 141 | 142 | #sniff .glyphicon-stop { 143 | color: #f66; 144 | } 145 | 146 | ul.action-buttons { 147 | list-style: none; 148 | padding: 0; 149 | } 150 | 151 | .action-buttons li { 152 | display: inline-block; 153 | } 154 | 155 | .action-buttons li:not(:first-child) { 156 | margin-left: 0.5em; 157 | } 158 | 159 | @keyframes spin { 160 | from { transform: scale(1) rotate(0deg); } 161 | to { transform: scale(1) rotate(360deg); } 162 | } 163 | 164 | @-webkit-keyframes spin2 { 165 | from { -webkit-transform: rotate(0deg); } 166 | to { -webkit-transform: rotate(360deg); } 167 | } 168 | 169 | .selectize-delete { float: right; } 170 | .c-selectize-item { margin: 0 1em; } -------------------------------------------------------------------------------- /lib/Udp/V5MiLightUdpServer.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | void V5MiLightUdpServer::handlePacket(uint8_t* packet, size_t packetSize) { 5 | if (packetSize == 2 || packetSize == 3) { 6 | handleCommand(packet[0], packet[1]); 7 | } else { 8 | Serial.print(F("V5MilightUdpServer: unexpected packet length. Should always be 2-3, was: ")); 9 | Serial.println(packetSize); 10 | } 11 | } 12 | 13 | void V5MiLightUdpServer::handleCommand(uint8_t command, uint8_t commandArg) { 14 | // On/off for RGBW 15 | if (command >= UDP_RGBW_GROUP_1_ON && command <= UDP_RGBW_GROUP_4_OFF) { 16 | const MiLightStatus status = (command % 2) == 1 ? ON : OFF; 17 | const uint8_t groupId = (command - UDP_RGBW_GROUP_1_ON + 2)/2; 18 | 19 | client->prepare(&FUT096Config, deviceId, groupId); 20 | client->updateStatus(status); 21 | 22 | this->lastGroup = groupId; 23 | // Command set_white for RGBW 24 | } else if (command == UDP_RGBW_GROUP_ALL_WHITE || command == UDP_RGBW_GROUP_1_WHITE || command == UDP_RGBW_GROUP_2_WHITE || command == UDP_RGBW_GROUP_3_WHITE || command == UDP_RGBW_GROUP_4_WHITE) { 25 | const uint8_t groupId = (command - UDP_RGBW_GROUP_ALL_WHITE)/2; 26 | client->prepare(&FUT096Config, deviceId, groupId); 27 | client->updateColorWhite(); 28 | 29 | this->lastGroup = groupId; 30 | // Set night_mode for RGBW 31 | } else if (command == UDP_RGBW_GROUP_ALL_NIGHT || command == UDP_RGBW_GROUP_1_NIGHT || command == UDP_RGBW_GROUP_2_NIGHT || command == UDP_RGBW_GROUP_3_NIGHT || command == UDP_RGBW_GROUP_4_NIGHT) { 32 | uint8_t groupId = (command - UDP_RGBW_GROUP_1_NIGHT + 2)/2; 33 | if (command == UDP_RGBW_GROUP_ALL_NIGHT) { 34 | groupId = 0; 35 | } 36 | 37 | client->prepare(&FUT096Config, deviceId, groupId); 38 | client->enableNightMode(); 39 | 40 | this->lastGroup = groupId; 41 | } else { 42 | client->prepare(&FUT096Config, deviceId, lastGroup); 43 | bool handled = true; 44 | 45 | switch (command) { 46 | case UDP_RGBW_ALL_ON: 47 | client->updateStatus(ON, 0); 48 | break; 49 | 50 | case UDP_RGBW_ALL_OFF: 51 | client->updateStatus(OFF, 0); 52 | break; 53 | 54 | case UDP_RGBW_COLOR: 55 | // UDP color is shifted by 0xC8 from 2.4 GHz color, and the spectrum is 56 | // flipped (R->B->G instead of R->G->B) 57 | client->updateColorRaw(0xFF-(commandArg + 0x35)); 58 | break; 59 | 60 | case UDP_RGBW_DISCO_MODE: 61 | client->nextMode(); 62 | break; 63 | 64 | case UDP_RGBW_SPEED_DOWN: 65 | pressButton(RGBW_SPEED_DOWN); 66 | break; 67 | 68 | case UDP_RGBW_SPEED_UP: 69 | pressButton(RGBW_SPEED_UP); 70 | break; 71 | 72 | case UDP_RGBW_BRIGHTNESS: 73 | // map [2, 27] --> [0, 100] 74 | client->updateBrightness( 75 | round(((commandArg - 2) / 25.0)*100) 76 | ); 77 | break; 78 | 79 | default: 80 | handled = false; 81 | } 82 | 83 | if (handled) { 84 | return; 85 | } 86 | 87 | uint8_t onOffGroup = CctPacketFormatter::cctCommandIdToGroup(command); 88 | 89 | if (onOffGroup != 255) { 90 | client->prepare(&FUT007Config, deviceId, onOffGroup); 91 | // Night mode commands are same as off commands with MSB set 92 | if ((command & 0x80) == 0x80) { 93 | client->enableNightMode(); 94 | } else { 95 | client->updateStatus(CctPacketFormatter::cctCommandToStatus(command)); 96 | } 97 | return; 98 | } 99 | 100 | client->prepare(&FUT007Config, deviceId, lastGroup); 101 | 102 | switch(command) { 103 | case UDP_CCT_BRIGHTNESS_DOWN: 104 | client->decreaseBrightness(); 105 | break; 106 | 107 | case UDP_CCT_BRIGHTNESS_UP: 108 | client->increaseBrightness(); 109 | break; 110 | 111 | case UDP_CCT_TEMPERATURE_DOWN: 112 | client->decreaseTemperature(); 113 | break; 114 | 115 | case UDP_CCT_TEMPERATURE_UP: 116 | client->increaseTemperature(); 117 | break; 118 | 119 | case UDP_CCT_NIGHT_MODE: 120 | client->enableNightMode(); 121 | break; 122 | 123 | default: 124 | if (!handled) { 125 | Serial.print(F("V5MiLightUdpServer - Unhandled command: ")); 126 | Serial.println(command); 127 | } 128 | } 129 | } 130 | } 131 | 132 | void V5MiLightUdpServer::pressButton(uint8_t button) { 133 | client->command(button, 0); 134 | } 135 | -------------------------------------------------------------------------------- /lib/Transitions/Transition.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | Transition::Builder::Builder(size_t id, const BulbId& bulbId, TransitionFn callback) 6 | : id(id) 7 | , bulbId(bulbId) 8 | , callback(callback) 9 | , duration(0) 10 | , period(0) 11 | , numPeriods(0) 12 | { } 13 | 14 | Transition::Builder& Transition::Builder::setDuration(float duration) { 15 | this->duration = duration * DURATION_UNIT_MULTIPLIER; 16 | return *this; 17 | } 18 | 19 | void Transition::Builder::setDurationRaw(size_t duration) { 20 | this->duration = duration; 21 | } 22 | 23 | Transition::Builder& Transition::Builder::setPeriod(size_t period) { 24 | this->period = period; 25 | return *this; 26 | } 27 | 28 | Transition::Builder& Transition::Builder::setNumPeriods(size_t numPeriods) { 29 | this->numPeriods = numPeriods; 30 | return *this; 31 | } 32 | 33 | size_t Transition::Builder::getNumPeriods() const { 34 | return this->numPeriods; 35 | } 36 | 37 | size_t Transition::Builder::getDuration() const { 38 | return this->duration; 39 | } 40 | 41 | size_t Transition::Builder::getPeriod() const { 42 | return this->period; 43 | } 44 | 45 | bool Transition::Builder::isSetDuration() const { 46 | return this->duration > 0; 47 | } 48 | 49 | bool Transition::Builder::isSetPeriod() const { 50 | return this->period > 0; 51 | } 52 | 53 | bool Transition::Builder::isSetNumPeriods() const { 54 | return this->numPeriods > 0; 55 | } 56 | 57 | size_t Transition::Builder::numSetParams() const { 58 | size_t setCount = 0; 59 | 60 | if (isSetDuration()) { ++setCount; } 61 | if (isSetPeriod()) { ++setCount; } 62 | if (isSetNumPeriods()) { ++setCount; } 63 | 64 | return setCount; 65 | } 66 | 67 | size_t Transition::Builder::getOrComputePeriod() const { 68 | if (period > 0) { 69 | return period; 70 | } else if (duration > 0 && numPeriods > 0) { 71 | size_t computed = floor(duration / static_cast(numPeriods)); 72 | return max(MIN_PERIOD, computed); 73 | } else { 74 | return 0; 75 | } 76 | } 77 | 78 | size_t Transition::Builder::getOrComputeDuration() const { 79 | if (duration > 0) { 80 | return duration; 81 | } else if (period > 0 && numPeriods > 0) { 82 | return period * numPeriods; 83 | } else { 84 | return 0; 85 | } 86 | } 87 | 88 | size_t Transition::Builder::getOrComputeNumPeriods() const { 89 | if (numPeriods > 0) { 90 | return numPeriods; 91 | } else if (period > 0 && duration > 0) { 92 | size_t _numPeriods = ceil(duration / static_cast(period)); 93 | return max(static_cast(1), _numPeriods); 94 | } else { 95 | return 0; 96 | } 97 | } 98 | 99 | std::shared_ptr Transition::Builder::build() { 100 | // Set defaults for underspecified transitions 101 | size_t numSet = numSetParams(); 102 | 103 | if (numSet == 0) { 104 | setDuration(DEFAULT_DURATION); 105 | setPeriod(DEFAULT_PERIOD); 106 | } else if (numSet == 1) { 107 | // If duration is unbound, bind it 108 | if (! isSetDuration()) { 109 | setDurationRaw(DEFAULT_DURATION); 110 | // Otherwise, bind the period 111 | } else { 112 | setPeriod(DEFAULT_PERIOD); 113 | } 114 | } 115 | 116 | return _build(); 117 | } 118 | 119 | Transition::Transition( 120 | size_t id, 121 | const BulbId& bulbId, 122 | size_t period, 123 | TransitionFn callback 124 | ) : id(id) 125 | , bulbId(bulbId) 126 | , callback(callback) 127 | , period(period) 128 | , lastSent(0) 129 | { } 130 | 131 | void Transition::tick() { 132 | unsigned long now = millis(); 133 | 134 | if ((lastSent + period) <= now 135 | && ((!isFinished() || lastSent == 0))) { // always send at least once 136 | 137 | step(); 138 | lastSent = now; 139 | } 140 | } 141 | 142 | size_t Transition::calculatePeriod(int16_t distance, size_t stepSize, size_t duration) { 143 | float fPeriod = 144 | distance != 0 145 | ? (duration / (distance / static_cast(stepSize))) 146 | : 0; 147 | 148 | return static_cast(round(fPeriod)); 149 | } 150 | 151 | void Transition::stepValue(int16_t& current, int16_t end, int16_t stepSize) { 152 | int16_t delta = end - current; 153 | if (std::abs(delta) < std::abs(stepSize)) { 154 | current += delta; 155 | } else { 156 | current += stepSize; 157 | } 158 | } 159 | 160 | void Transition::serialize(JsonObject& json) { 161 | json[F("id")] = id; 162 | json[F("period")] = period; 163 | json[F("last_sent")] = lastSent; 164 | 165 | JsonObject bulbParams = json.createNestedObject("bulb"); 166 | bulbId.serialize(bulbParams); 167 | 168 | childSerialize(json); 169 | } -------------------------------------------------------------------------------- /lib/MiLightState/GroupStateStore.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | GroupStateStore::GroupStateStore(const size_t maxSize, const size_t flushRate) 5 | : cache(GroupStateCache(maxSize)), 6 | flushRate(flushRate), 7 | lastFlush(0) 8 | { } 9 | 10 | GroupState* GroupStateStore::get(const BulbId& id) { 11 | GroupState* state = cache.get(id); 12 | 13 | if (state == NULL) { 14 | #if STATE_DEBUG 15 | printf( 16 | "Couldn't fetch state for 0x%04X / %d / %s in the cache, getting it from persistence\n", 17 | id.deviceId, 18 | id.groupId, 19 | MiLightRemoteConfig::fromType(id.deviceType)->name.c_str() 20 | ); 21 | #endif 22 | trackEviction(); 23 | GroupState loadedState = GroupState::defaultState(id.deviceType); 24 | 25 | const MiLightRemoteConfig* remoteConfig = MiLightRemoteConfig::fromType(id.deviceType); 26 | 27 | if (remoteConfig == NULL) { 28 | return NULL; 29 | } 30 | 31 | persistence.get(id, loadedState); 32 | state = cache.set(id, loadedState); 33 | } 34 | 35 | return state; 36 | } 37 | 38 | GroupState* GroupStateStore::get(const uint16_t deviceId, const uint8_t groupId, const MiLightRemoteType deviceType) { 39 | BulbId bulbId(deviceId, groupId, deviceType); 40 | return get(bulbId); 41 | } 42 | 43 | // Save state for a bulb. 44 | // 45 | // Notes: 46 | // 47 | // * For device types with groups, group 0 is a "virtual" group. All devices paired with the same ID will 48 | // respond to group 0. When state for an individual (i.e., != 0) group is changed, the state for 49 | // group 0 becomes out of sync and should be cleared. 50 | // 51 | // * If id.groupId == 0, will iterate across all groups and individually save each group (recursively) 52 | // 53 | GroupState* GroupStateStore::set(const BulbId &id, const GroupState& state) { 54 | BulbId otherId(id); 55 | GroupState* storedState = get(id); 56 | storedState->patch(state); 57 | 58 | if (id.groupId == 0) { 59 | const MiLightRemoteConfig* remote = MiLightRemoteConfig::fromType(id.deviceType); 60 | 61 | #ifdef STATE_DEBUG 62 | Serial.printf_P(PSTR("Fanning out group 0 state for device ID 0x%04X (%d groups in total)\n"), id.deviceId, remote->numGroups); 63 | state.debugState("group 0 state = "); 64 | #endif 65 | 66 | for (size_t i = 1; i <= remote->numGroups; i++) { 67 | otherId.groupId = i; 68 | 69 | GroupState* individualState = get(otherId); 70 | individualState->patch(state); 71 | } 72 | } else { 73 | otherId.groupId = 0; 74 | GroupState* group0State = get(otherId); 75 | 76 | group0State->clearNonMatchingFields(state); 77 | } 78 | 79 | return storedState; 80 | } 81 | 82 | GroupState* GroupStateStore::set(const uint16_t deviceId, const uint8_t groupId, const MiLightRemoteType deviceType, const GroupState& state) { 83 | BulbId bulbId(deviceId, groupId, deviceType); 84 | return set(bulbId, state); 85 | } 86 | 87 | void GroupStateStore::clear(const BulbId& bulbId) { 88 | GroupState* state = get(bulbId); 89 | 90 | if (state != NULL) { 91 | state->initFields(); 92 | state->patch(GroupState::defaultState(bulbId.deviceType)); 93 | } 94 | } 95 | 96 | void GroupStateStore::trackEviction() { 97 | if (cache.isFull()) { 98 | evictedIds.add(cache.getLru()); 99 | 100 | #ifdef STATE_DEBUG 101 | BulbId bulbId = evictedIds.getLast(); 102 | printf( 103 | "Evicting from cache: 0x%04X / %d / %s\n", 104 | bulbId.deviceId, 105 | bulbId.groupId, 106 | MiLightRemoteConfig::fromType(bulbId.deviceType)->name.c_str() 107 | ); 108 | #endif 109 | } 110 | } 111 | 112 | bool GroupStateStore::flush() { 113 | ListNode* curr = cache.getHead(); 114 | bool anythingFlushed = false; 115 | 116 | while (curr != NULL && curr->data->state.isDirty() && !anythingFlushed) { 117 | persistence.set(curr->data->id, curr->data->state); 118 | curr->data->state.clearDirty(); 119 | 120 | #ifdef STATE_DEBUG 121 | BulbId bulbId = curr->data->id; 122 | printf( 123 | "Flushing dirty state for 0x%04X / %d / %s\n", 124 | bulbId.deviceId, 125 | bulbId.groupId, 126 | MiLightRemoteConfig::fromType(bulbId.deviceType)->name.c_str() 127 | ); 128 | #endif 129 | 130 | curr = curr->next; 131 | anythingFlushed = true; 132 | } 133 | 134 | while (evictedIds.size() > 0 && !anythingFlushed) { 135 | persistence.clear(evictedIds.shift()); 136 | anythingFlushed = true; 137 | } 138 | 139 | return anythingFlushed; 140 | } 141 | 142 | void GroupStateStore::limitedFlush() { 143 | unsigned long now = millis(); 144 | 145 | if ((lastFlush + flushRate) < now) { 146 | if (flush()) { 147 | lastFlush = now; 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /test/remote/spec/udp_spec.rb: -------------------------------------------------------------------------------- 1 | require 'api_client' 2 | require 'milight' 3 | 4 | RSpec.describe 'UDP servers' do 5 | before(:all) do 6 | @host = ENV.fetch('ESPMH_HOSTNAME') 7 | @client = ApiClient.new(@host, ENV.fetch('ESPMH_TEST_DEVICE_ID_BASE')) 8 | @client.upload_json('/settings', 'settings.json') 9 | 10 | @client.patch_settings( mqtt_parameters() ) 11 | @client.patch_settings( mqtt_update_topic_pattern: '' ) 12 | end 13 | 14 | before(:each) do 15 | @id_params = { 16 | id: @client.generate_id, 17 | type: 'rgbw', 18 | group_id: 1 19 | } 20 | @v6_id_params = { 21 | id: @client.generate_id, 22 | type: 'rgbw', 23 | group_id: 1 24 | } 25 | @client.delete_state(@id_params) 26 | 27 | @v5_udp_port = ENV.fetch('ESPMH_V5_UDP_PORT') 28 | @v6_udp_port = ENV.fetch('ESPMH_V6_UDP_PORT') 29 | @discovery_port = ENV.fetch('ESPMH_DISCOVERY_PORT') 30 | 31 | @client.patch_settings( 32 | gateway_configs: [ 33 | [ 34 | @id_params[:id], # device ID 35 | @v5_udp_port, 36 | 5 # protocol version (gem uses v5) 37 | ], 38 | [ 39 | @v6_id_params[:id], # device ID 40 | @v6_udp_port, 41 | 6 # protocol version 42 | ] 43 | ] 44 | ) 45 | @udp_client = Milight::Controller.new(ENV.fetch('ESPMH_HOSTNAME'), @v5_udp_port) 46 | @mqtt_client = create_mqtt_client() 47 | end 48 | 49 | context 'on/off commands' do 50 | it 'should result in state changes' do 51 | @udp_client.group(@id_params[:group_id]).on 52 | 53 | # Wait for packet to be processed 54 | sleep 1 55 | 56 | state = @client.get_state(@id_params) 57 | expect(state['status']).to eq('ON') 58 | 59 | @udp_client.group(@id_params[:group_id]).off 60 | 61 | # Wait for packet to be processed 62 | sleep 1 63 | 64 | state = @client.get_state(@id_params) 65 | expect(state['status']).to eq('OFF') 66 | end 67 | 68 | it 'should result in an MQTT update' do 69 | desired_state = { 70 | 'status' => 'ON', 71 | 'level' => 48 72 | } 73 | seen_state = false 74 | 75 | @mqtt_client.on_state(@id_params) do |id, message| 76 | seen_state = desired_state.all? { |k,v| v == message[k] } 77 | end 78 | @udp_client.group(@id_params[:group_id]).on.brightness(48) 79 | @mqtt_client.wait_for_listeners 80 | 81 | expect(seen_state).to eq(true) 82 | end 83 | end 84 | 85 | context 'color and brightness commands' do 86 | it 'should result in state changes' do 87 | desired_state = { 88 | 'status' => 'ON', 89 | 'level' => 48, 90 | 'hue' => 357 91 | } 92 | seen_state = false 93 | 94 | @mqtt_client.on_state(@id_params) do |id, message| 95 | seen_state = desired_state.all? { |k,v| v == message[k] } 96 | end 97 | 98 | @udp_client.group(@id_params[:group_id]) 99 | .on 100 | .colour('#ff0000') 101 | .brightness(48) 102 | 103 | @mqtt_client.wait_for_listeners 104 | 105 | expect(seen_state).to eq(true) 106 | end 107 | end 108 | 109 | context 'discovery' do 110 | before(:all) do 111 | @client.patch_settings( 112 | discovery_port: ENV.fetch('ESPMH_DISCOVERY_PORT') 113 | ) 114 | 115 | @discovery_host = '' 116 | 117 | @discovery_socket = UDPSocket.new 118 | @discovery_socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_BROADCAST, true) 119 | @discovery_socket.bind('0.0.0.0', 0) 120 | end 121 | 122 | it 'should respond to v5 discovery' do 123 | @discovery_socket.send('Link_Wi-Fi', 0, @discovery_host, @discovery_port) 124 | 125 | # wait for response 126 | sleep 1 127 | 128 | response, _ = @discovery_socket.recvfrom_nonblock(1024) 129 | response = response.split(',') 130 | 131 | expect(response.length).to eq(2), "Should be a comma-separated list with two elements" 132 | expect(response[0]).to eq(@host) 133 | expect(response[1].to_i(16)).to eq(@id_params[:id]) 134 | end 135 | 136 | it 'should respond to v6 discovery' do 137 | @discovery_socket.send('HF-A11ASSISTHREAD', 0, @host, @discovery_port) 138 | 139 | # wait for response 140 | sleep 1 141 | 142 | response, _ = @discovery_socket.recvfrom_nonblock(1024) 143 | response = response.split(',') 144 | 145 | expect(response.length).to eq(3), "Should be a comma-separated list with three elements" 146 | expect(response[0]).to eq(@host) 147 | expect(response[1].to_i(16)).to eq(@v6_id_params[:id]) 148 | expect(response[2]).to eq('HF-LPB100') 149 | end 150 | end 151 | end -------------------------------------------------------------------------------- /lib/SSDP/New_ESP8266SSDP.h: -------------------------------------------------------------------------------- 1 | /* 2 | ESP8266 Simple Service Discovery 3 | Copyright (c) 2015 Hristo Gochkov 4 | 5 | Original (Arduino) version by Filippo Sallemi, July 23, 2014. 6 | Can be found at: https://github.com/nomadnt/uSSDP 7 | 8 | License (MIT license): 9 | Permission is hereby granted, free of charge, to any person obtaining a copy 10 | of this software and associated documentation files (the "Software"), to deal 11 | in the Software without restriction, including without limitation the rights 12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the Software is 14 | furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in 17 | all copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | THE SOFTWARE. 26 | 27 | */ 28 | 29 | #ifndef ESP8266SSDP_H 30 | #define ESP8266SSDP_H 31 | 32 | #include 33 | #include 34 | #include 35 | 36 | class UdpContext; 37 | 38 | #define SSDP_UUID_SIZE 37 39 | #define SSDP_SCHEMA_URL_SIZE 64 40 | #define SSDP_DEVICE_TYPE_SIZE 64 41 | #define SSDP_FRIENDLY_NAME_SIZE 64 42 | #define SSDP_SERIAL_NUMBER_SIZE 32 43 | #define SSDP_PRESENTATION_URL_SIZE 128 44 | #define SSDP_MODEL_NAME_SIZE 64 45 | #define SSDP_MODEL_URL_SIZE 128 46 | #define SSDP_MODEL_VERSION_SIZE 32 47 | #define SSDP_MANUFACTURER_SIZE 64 48 | #define SSDP_MANUFACTURER_URL_SIZE 128 49 | 50 | typedef enum { 51 | NONE, 52 | SEARCH, 53 | NOTIFY 54 | } ssdp_method_t; 55 | 56 | 57 | struct SSDPTimer; 58 | 59 | class SSDPClass{ 60 | public: 61 | SSDPClass(); 62 | ~SSDPClass(); 63 | 64 | bool begin(); 65 | 66 | void schema(WiFiClient client); 67 | 68 | void setDeviceType(const String& deviceType) { setDeviceType(deviceType.c_str()); } 69 | void setDeviceType(const char *deviceType); 70 | void setName(const String& name) { setName(name.c_str()); } 71 | void setName(const char *name); 72 | void setURL(const String& url) { setURL(url.c_str()); } 73 | void setURL(const char *url); 74 | void setSchemaURL(const String& url) { setSchemaURL(url.c_str()); } 75 | void setSchemaURL(const char *url); 76 | void setSerialNumber(const String& serialNumber) { setSerialNumber(serialNumber.c_str()); } 77 | void setSerialNumber(const char *serialNumber); 78 | void setSerialNumber(const uint32_t serialNumber); 79 | void setModelName(const String& name) { setModelName(name.c_str()); } 80 | void setModelName(const char *name); 81 | void setModelNumber(const String& num) { setModelNumber(num.c_str()); } 82 | void setModelNumber(const char *num); 83 | void setModelURL(const String& url) { setModelURL(url.c_str()); } 84 | void setModelURL(const char *url); 85 | void setManufacturer(const String& name) { setManufacturer(name.c_str()); } 86 | void setManufacturer(const char *name); 87 | void setManufacturerURL(const String& url) { setManufacturerURL(url.c_str()); } 88 | void setManufacturerURL(const char *url); 89 | void setHTTPPort(uint16_t port); 90 | void setTTL(uint8_t ttl); 91 | 92 | protected: 93 | void _send(ssdp_method_t method); 94 | void _update(); 95 | void _startTimer(); 96 | static void _onTimerStatic(SSDPClass* self); 97 | 98 | UdpContext* _server; 99 | SSDPTimer* _timer; 100 | uint16_t _port; 101 | uint8_t _ttl; 102 | 103 | IPAddress _respondToAddr; 104 | uint16_t _respondToPort; 105 | 106 | bool _pending; 107 | unsigned short _delay; 108 | unsigned long _process_time; 109 | unsigned long _notify_time; 110 | 111 | char _schemaURL[SSDP_SCHEMA_URL_SIZE]; 112 | char _uuid[SSDP_UUID_SIZE]; 113 | char _deviceType[SSDP_DEVICE_TYPE_SIZE]; 114 | char _friendlyName[SSDP_FRIENDLY_NAME_SIZE]; 115 | char _serialNumber[SSDP_SERIAL_NUMBER_SIZE]; 116 | char _presentationURL[SSDP_PRESENTATION_URL_SIZE]; 117 | char _manufacturer[SSDP_MANUFACTURER_SIZE]; 118 | char _manufacturerURL[SSDP_MANUFACTURER_URL_SIZE]; 119 | char _modelName[SSDP_MODEL_NAME_SIZE]; 120 | char _modelURL[SSDP_MODEL_URL_SIZE]; 121 | char _modelNumber[SSDP_MODEL_VERSION_SIZE]; 122 | }; 123 | 124 | #if !defined(NO_GLOBAL_INSTANCES) && !defined(NO_GLOBAL_SSDP) 125 | extern SSDPClass SSDP; 126 | #endif 127 | 128 | #endif 129 | --------------------------------------------------------------------------------