From 38178274344d22dfd47ae7337003a2de0cc37e65 Mon Sep 17 00:00:00 2001 From: radu Date: Sun, 9 Aug 2026 09:44:14 +0700 Subject: [PATCH] Added lamp --- .DS_Store | Bin 0 -> 8196 bytes board/lamp.yaml | 116 ++++++ components/xiaomi_bslamp2/.clang-format | 137 +++++++ components/xiaomi_bslamp2/.clang-tidy | 127 ++++++ components/xiaomi_bslamp2/__init__.py | 116 ++++++ .../xiaomi_bslamp2/binary_sensor/__init__.py | 69 ++++ .../binary_sensor/touch_binary_sensor.h | 56 +++ components/xiaomi_bslamp2/common.h | 14 + components/xiaomi_bslamp2/front_panel_hal.h | 346 ++++++++++++++++ components/xiaomi_bslamp2/light/__init__.py | 331 +++++++++++++++ components/xiaomi_bslamp2/light/automation.h | 109 +++++ .../xiaomi_bslamp2/light/color_handler.h | 28 ++ .../light/color_handler_chain.h | 68 ++++ .../light/color_handler_color_temperature.h | 131 ++++++ .../light/color_handler_night_light.h | 85 ++++ .../xiaomi_bslamp2/light/color_handler_off.h | 33 ++ .../xiaomi_bslamp2/light/color_handler_rgb.h | 379 ++++++++++++++++++ components/xiaomi_bslamp2/light/interfaces.h | 40 ++ .../xiaomi_bslamp2/light/light_output.h | 102 +++++ components/xiaomi_bslamp2/light/light_state.h | 66 +++ .../xiaomi_bslamp2/light/light_transformer.h | 109 +++++ components/xiaomi_bslamp2/light/presets.h | 190 +++++++++ components/xiaomi_bslamp2/light_hal.h | 109 +++++ components/xiaomi_bslamp2/output/__init__.py | 158 ++++++++ components/xiaomi_bslamp2/output/automation.h | 69 ++++ components/xiaomi_bslamp2/output/output.h | 57 +++ components/xiaomi_bslamp2/sensor/__init__.py | 36 ++ .../xiaomi_bslamp2/sensor/slider_sensor.h | 71 ++++ .../xiaomi_bslamp2/text_sensor/__init__.py | 36 ++ .../text_sensor/light_mode_text_sensor.h | 35 ++ services/lampBehaviour.yaml | 172 ++++++++ 31 files changed, 3395 insertions(+) create mode 100644 .DS_Store create mode 100644 board/lamp.yaml create mode 100644 components/xiaomi_bslamp2/.clang-format create mode 100644 components/xiaomi_bslamp2/.clang-tidy create mode 100644 components/xiaomi_bslamp2/__init__.py create mode 100644 components/xiaomi_bslamp2/binary_sensor/__init__.py create mode 100644 components/xiaomi_bslamp2/binary_sensor/touch_binary_sensor.h create mode 100644 components/xiaomi_bslamp2/common.h create mode 100644 components/xiaomi_bslamp2/front_panel_hal.h create mode 100644 components/xiaomi_bslamp2/light/__init__.py create mode 100644 components/xiaomi_bslamp2/light/automation.h create mode 100644 components/xiaomi_bslamp2/light/color_handler.h create mode 100644 components/xiaomi_bslamp2/light/color_handler_chain.h create mode 100644 components/xiaomi_bslamp2/light/color_handler_color_temperature.h create mode 100644 components/xiaomi_bslamp2/light/color_handler_night_light.h create mode 100644 components/xiaomi_bslamp2/light/color_handler_off.h create mode 100644 components/xiaomi_bslamp2/light/color_handler_rgb.h create mode 100644 components/xiaomi_bslamp2/light/interfaces.h create mode 100644 components/xiaomi_bslamp2/light/light_output.h create mode 100644 components/xiaomi_bslamp2/light/light_state.h create mode 100644 components/xiaomi_bslamp2/light/light_transformer.h create mode 100644 components/xiaomi_bslamp2/light/presets.h create mode 100644 components/xiaomi_bslamp2/light_hal.h create mode 100644 components/xiaomi_bslamp2/output/__init__.py create mode 100644 components/xiaomi_bslamp2/output/automation.h create mode 100644 components/xiaomi_bslamp2/output/output.h create mode 100644 components/xiaomi_bslamp2/sensor/__init__.py create mode 100644 components/xiaomi_bslamp2/sensor/slider_sensor.h create mode 100644 components/xiaomi_bslamp2/text_sensor/__init__.py create mode 100644 components/xiaomi_bslamp2/text_sensor/light_mode_text_sensor.h create mode 100644 services/lampBehaviour.yaml diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..4ceec133e15d970e1d51df800dafc850e8e1b231 GIT binary patch literal 8196 zcmeHM%T5$Q6umWo_JB(B6p1DsW@7?TzzLcd!#H9THGwb$!PnT`Qw%LVRg><95ednB zfVyz$2lxT5j4L-T-MVn&54dpcMsGcaH;HbH#JZ&F+^)K(s_RUut8W7UB)?J`0q6q& z3yVOz4Xb@j^oz2`Qo1FLNRSUunhd$`h1}Z{YZ{CKMggOMQNSo*6!k1Veh@u10GZUSm5IH;Y92pKIr_j_!0i(cP1w`)N0v>R1A=;6@i;a}W za_*-zNj)JWIV5lg0w}{0dgovbxfKYv<^F>Eb{Dq|e4M3%e;n9BEHbc*)v8%l$)ddG$LSuNGJJmx=dYH4Nfpc=$6(H8r;!IcgoX+N_6#C0;8eZn6^R-RQo!n&mw7 z8oukz1+-SQyRUJUxB;V)Bm^ajArI~bti)@1zQRheRn?A?MP??8Zjg>dBnHJX2f_D~vzKm7o#*kb~pudb56k-oAFO1->Lpnr;B!%=laX#NZj`|9`Yz3uF(0w=d2Js zqw@)Unh2+8ckYUtp`}M#O|xrNjOpHLkYnA5C}sD{QBay%T#RWlzs+AL+U>C)@i3g` zagYR@Ej)-(^2vl&CfOp#8COy6sjBFT=V%lo?%~Wo3|)_gQ!LMugjW{aC~{eu=GR1| z7!4OP9Z@looV!qU{eYD-2q;|D*Z z$J0P!boTwhR|^pfIV)qo;HGg~xws_{+*m1bUT)vY7Q{et%daBKM{n1rN<};4b9gBx zsw' + Priority: 2 + - Regex: '^<.*\.h>' + Priority: 1 + - Regex: '^<.*' + Priority: 2 + - Regex: '.*' + Priority: 3 +IncludeIsMainRegex: '([-_](test|unittest))?$' +IndentCaseLabels: true +IndentPPDirectives: None +IndentWidth: 2 +IndentWrappedFunctionNames: false +KeepEmptyLinesAtTheStartOfBlocks: false +MacroBlockBegin: '' +MacroBlockEnd: '' +MaxEmptyLinesToKeep: 1 +NamespaceIndentation: None +PenaltyBreakAssignment: 2 +PenaltyBreakBeforeFirstCallParameter: 1 +PenaltyBreakComment: 300 +PenaltyBreakFirstLessLess: 120 +PenaltyBreakString: 1000 +PenaltyBreakTemplateDeclaration: 10 +PenaltyExcessCharacter: 1000000 +PenaltyReturnTypeOnItsOwnLine: 2000 +PointerAlignment: Right +RawStringFormats: + - Language: Cpp + Delimiters: + - cc + - CC + - cpp + - Cpp + - CPP + - 'c++' + - 'C++' + CanonicalDelimiter: '' + BasedOnStyle: google + - Language: TextProto + Delimiters: + - pb + - PB + - proto + - PROTO + EnclosingFunctions: + - EqualsProto + - EquivToProto + - PARSE_PARTIAL_TEXT_PROTO + - PARSE_TEST_PROTO + - PARSE_TEXT_PROTO + - ParseTextOrDie + - ParseTextProtoOrDie + CanonicalDelimiter: '' + BasedOnStyle: google +ReflowComments: true +SortIncludes: false +SortUsingDeclarations: false +SpaceAfterCStyleCast: true +SpaceAfterTemplateKeyword: false +SpaceBeforeAssignmentOperators: true +SpaceBeforeCpp11BracedList: false +SpaceBeforeCtorInitializerColon: true +SpaceBeforeInheritanceColon: true +SpaceBeforeParens: ControlStatements +SpaceBeforeRangeBasedForLoopColon: true +SpaceInEmptyParentheses: false +SpacesBeforeTrailingComments: 2 +SpacesInAngles: false +SpacesInContainerLiterals: false +SpacesInCStyleCastParentheses: false +SpacesInParentheses: false +SpacesInSquareBrackets: false +Standard: Auto +TabWidth: 2 +UseTab: Never diff --git a/components/xiaomi_bslamp2/.clang-tidy b/components/xiaomi_bslamp2/.clang-tidy new file mode 100644 index 0000000..5e486e6 --- /dev/null +++ b/components/xiaomi_bslamp2/.clang-tidy @@ -0,0 +1,127 @@ +--- +Checks: >- + *, + -abseil-*, + -android-*, + -boost-*, + -bugprone-macro-parentheses, + -cert-dcl50-cpp, + -cert-err58-cpp, + -clang-analyzer-core.CallAndMessage, + -clang-analyzer-osx.*, + -clang-analyzer-security.*, + -cppcoreguidelines-avoid-goto, + -cppcoreguidelines-c-copy-assignment-signature, + -cppcoreguidelines-owning-memory, + -cppcoreguidelines-pro-bounds-array-to-pointer-decay, + -cppcoreguidelines-pro-bounds-constant-array-index, + -cppcoreguidelines-pro-bounds-pointer-arithmetic, + -cppcoreguidelines-pro-type-const-cast, + -cppcoreguidelines-pro-type-cstyle-cast, + -cppcoreguidelines-pro-type-member-init, + -cppcoreguidelines-pro-type-reinterpret-cast, + -cppcoreguidelines-pro-type-static-cast-downcast, + -cppcoreguidelines-pro-type-union-access, + -cppcoreguidelines-pro-type-vararg, + -cppcoreguidelines-special-member-functions, + -fuchsia-*, + -fuchsia-default-arguments, + -fuchsia-multiple-inheritance, + -fuchsia-overloaded-operator, + -fuchsia-statically-constructed-objects, + -google-build-using-namespace, + -google-explicit-constructor, + -google-readability-braces-around-statements, + -google-readability-casting, + -google-readability-todo, + -google-runtime-int, + -google-runtime-references, + -hicpp-*, + -llvm-header-guard, + -llvm-include-order, + -misc-unconventional-assign-operator, + -misc-unused-parameters, + -modernize-deprecated-headers, + -modernize-pass-by-value, + -modernize-pass-by-value, + -modernize-return-braced-init-list, + -modernize-use-auto, + -modernize-use-default-member-init, + -modernize-use-equals-default, + -mpi-*, + -objc-*, + -performance-unnecessary-value-param, + -readability-braces-around-statements, + -readability-else-after-return, + -readability-implicit-bool-conversion, + -readability-named-parameter, + -readability-redundant-member-init, + -warnings-as-errors, + -zircon-* +WarningsAsErrors: '*' +HeaderFilterRegex: '^.*/src/esphome/.*' +AnalyzeTemporaryDtors: false +FormatStyle: google +CheckOptions: + - key: google-readability-braces-around-statements.ShortStatementLines + value: '1' + - key: google-readability-function-size.StatementThreshold + value: '800' + - key: google-readability-namespace-comments.ShortNamespaceLines + value: '10' + - key: google-readability-namespace-comments.SpacesBeforeComments + value: '2' + - key: modernize-loop-convert.MaxCopySize + value: '16' + - key: modernize-loop-convert.MinConfidence + value: reasonable + - key: modernize-loop-convert.NamingStyle + value: CamelCase + - key: modernize-pass-by-value.IncludeStyle + value: llvm + - key: modernize-replace-auto-ptr.IncludeStyle + value: llvm + - key: modernize-use-nullptr.NullMacros + value: 'NULL' + - key: readability-identifier-naming.LocalVariableCase + value: 'lower_case' + - key: readability-identifier-naming.ClassCase + value: 'CamelCase' + - key: readability-identifier-naming.StructCase + value: 'CamelCase' + - key: readability-identifier-naming.EnumCase + value: 'CamelCase' + - key: readability-identifier-naming.EnumConstantCase + value: 'UPPER_CASE' + - key: readability-identifier-naming.StaticConstantCase + value: 'UPPER_CASE' + - key: readability-identifier-naming.StaticVariableCase + value: 'UPPER_CASE' + - key: readability-identifier-naming.GlobalConstantCase + value: 'UPPER_CASE' + - key: readability-identifier-naming.ParameterCase + value: 'lower_case' + - key: readability-identifier-naming.PrivateMemberPrefix + value: 'NO_PRIVATE_MEMBERS_ALWAYS_USE_PROTECTED' + - key: readability-identifier-naming.PrivateMethodPrefix + value: 'NO_PRIVATE_METHODS_ALWAYS_USE_PROTECTED' + - key: readability-identifier-naming.ClassMemberCase + value: 'lower_case' + - key: readability-identifier-naming.ClassMemberCase + value: 'lower_case' + - key: readability-identifier-naming.ProtectedMemberCase + value: 'lower_case' + - key: readability-identifier-naming.ProtectedMemberSuffix + value: '_' + - key: readability-identifier-naming.FunctionCase + value: 'lower_case' + - key: readability-identifier-naming.ClassMethodCase + value: 'lower_case' + - key: readability-identifier-naming.ProtectedMethodCase + value: 'lower_case' + - key: readability-identifier-naming.ProtectedMethodSuffix + value: '_' + - key: readability-identifier-naming.VirtualMethodCase + value: 'lower_case' + - key: readability-identifier-naming.VirtualMethodSuffix + value: '' diff --git a/components/xiaomi_bslamp2/__init__.py b/components/xiaomi_bslamp2/__init__.py new file mode 100644 index 0000000..6a977ff --- /dev/null +++ b/components/xiaomi_bslamp2/__init__.py @@ -0,0 +1,116 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome import pins +from esphome.components.ledc.output import LEDCOutput +from esphome.components.gpio.output import GPIOBinaryOutput +from esphome.components.i2c import I2CBus, I2CDevice +from esphome.const import ( + CONF_LIGHT, + CONF_RED, + CONF_GREEN, + CONF_BLUE, + CONF_WHITE, + CONF_I2C, + CONF_ADDRESS, + CONF_TRIGGER_PIN, +) +from esphome.util import parse_esphome_version +from voluptuous import Invalid + +CODEOWNERS = ["@mmakaay"] + +CONF_MASTER1 = "master1" +CONF_MASTER2 = "master2" +CONF_FRONT_PANEL = "front_panel" +CONF_LIGHT_HAL_ID = "light_hal_id" +CONF_FRONT_PANEL_HAL_ID = "front_panel_hal_id" + +xiaomi_ns = cg.esphome_ns.namespace("xiaomi") +bslamp2_ns = xiaomi_ns.namespace("bslamp2") +LightHAL = bslamp2_ns.class_("LightHAL", cg.Component) +FrontPanelHAL = bslamp2_ns.class_("FrontPanelHAL", cg.Component, I2CDevice) + +FrontPanelLEDs = bslamp2_ns.enum("FrontPanelLEDs") +FRONT_PANEL_LED_OPTIONS = { + "NONE": FrontPanelLEDs.LED_NONE, + "ALL": FrontPanelLEDs.LED_ALL, + "POWER": FrontPanelLEDs.LED_POWER, + "COLOR": FrontPanelLEDs.LED_COLOR, + "1": FrontPanelLEDs.LED_1, + "2": FrontPanelLEDs.LED_2, + "3": FrontPanelLEDs.LED_3, + "4": FrontPanelLEDs.LED_4, + "5": FrontPanelLEDs.LED_5, + "6": FrontPanelLEDs.LED_6, + "7": FrontPanelLEDs.LED_7, + "8": FrontPanelLEDs.LED_8, + "9": FrontPanelLEDs.LED_9, + "10": FrontPanelLEDs.LED_10, +} + + +def check_version_compatibility(config): + esphome_version = parse_esphome_version() + if esphome_version < (2026, 4, 0): + raise Invalid( + "This xiaomi_bslamp2 component requires at least ESPHome " + + "version 2026.4.0; Please upgrade ESPHome and try again." + ) + return config + + +CONFIG_SCHEMA = cv.All( + check_version_compatibility, + cv.COMPONENT_SCHEMA.extend( + { + # RGBWW Light + cv.Required(CONF_LIGHT): cv.Schema( + { + cv.GenerateID(CONF_LIGHT_HAL_ID): cv.declare_id(LightHAL), + cv.Required(CONF_RED): cv.use_id(LEDCOutput), + cv.Required(CONF_GREEN): cv.use_id(LEDCOutput), + cv.Required(CONF_BLUE): cv.use_id(LEDCOutput), + cv.Required(CONF_WHITE): cv.use_id(LEDCOutput), + cv.Required(CONF_MASTER1): cv.use_id(GPIOBinaryOutput), + cv.Required(CONF_MASTER2): cv.use_id(GPIOBinaryOutput), + } + ), + # Front panel I2C + cv.Required(CONF_FRONT_PANEL): cv.Schema( + { + cv.GenerateID(CONF_FRONT_PANEL_HAL_ID): cv.declare_id(FrontPanelHAL), + cv.Required(CONF_I2C): cv.use_id(I2CBus), + cv.Required(CONF_ADDRESS): cv.i2c_address, + cv.Required(CONF_TRIGGER_PIN): cv.All(pins.internal_gpio_input_pin_schema), + } + ), + } + ), +) + + +async def make_light_hal(config): + light_hal = cg.new_Pvariable(config[CONF_LIGHT][CONF_LIGHT_HAL_ID]) + await cg.register_component(light_hal, config) + cg.add(light_hal.set_red_pin(await cg.get_variable(config[CONF_LIGHT][CONF_RED]))) + cg.add(light_hal.set_green_pin(await cg.get_variable(config[CONF_LIGHT][CONF_GREEN]))) + cg.add(light_hal.set_blue_pin(await cg.get_variable(config[CONF_LIGHT][CONF_BLUE]))) + cg.add(light_hal.set_white_pin(await cg.get_variable(config[CONF_LIGHT][CONF_WHITE]))) + cg.add(light_hal.set_master1_pin(await cg.get_variable(config[CONF_LIGHT][CONF_MASTER1]))) + cg.add(light_hal.set_master2_pin(await cg.get_variable(config[CONF_LIGHT][CONF_MASTER2]))) + + +async def make_front_panel_hal(config): + fp_hal = cg.new_Pvariable(config[CONF_FRONT_PANEL][CONF_FRONT_PANEL_HAL_ID]) + await cg.register_component(fp_hal, config) + trigger_pin = await cg.gpio_pin_expression(config[CONF_FRONT_PANEL][CONF_TRIGGER_PIN]) + cg.add(fp_hal.set_trigger_pin(trigger_pin)) + + fp_i2c_var = await cg.get_variable(config[CONF_FRONT_PANEL][CONF_I2C]) + cg.add(fp_hal.set_i2c_bus(fp_i2c_var)) + cg.add(fp_hal.set_i2c_address(config[CONF_FRONT_PANEL][CONF_ADDRESS])) + + +async def to_code(config): + await make_light_hal(config) + await make_front_panel_hal(config) diff --git a/components/xiaomi_bslamp2/binary_sensor/__init__.py b/components/xiaomi_bslamp2/binary_sensor/__init__.py new file mode 100644 index 0000000..b571d9d --- /dev/null +++ b/components/xiaomi_bslamp2/binary_sensor/__init__.py @@ -0,0 +1,69 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import binary_sensor +from esphome.const import CONF_ID, CONF_FOR +from .. import bslamp2_ns, CODEOWNERS, CONF_FRONT_PANEL_HAL_ID, FrontPanelHAL + +__all__ = ["CODEOWNERS"] + +DEPENDENCIES = ["xiaomi_bslamp2"] + +CONF_PART = "part" + +# The identifier values match the bit values of the events as defined +# in ../front_panel_hal.h. +PARTS = { + "POWER_BUTTON": 0b001 << 1, + "POWER": 0b001 << 1, + "COLOR_BUTTON": 0b010 << 1, + "COLOR": 0b010 << 1, + "SLIDER": 0b100 << 1, +} + +XiaomiBslamp2TouchBinarySensor = bslamp2_ns.class_( + "XiaomiBslamp2TouchBinarySensor", binary_sensor.BinarySensor, cg.Component +) + + +def validate_for(value): + value = cv.string(value) + return cv.enum(PARTS, upper=True, space="_")(value) + + +def validate_binary_sensor(conf): + if CONF_PART in conf and CONF_FOR in conf: + raise cv.Invalid("Specify only one of [part] or [for]") + if CONF_PART in conf and CONF_FOR not in conf: + # Backward compatibility. + conf[CONF_FOR] = conf[CONF_PART] + if CONF_FOR not in conf: + raise cv.Invalid("'for' is a required option for [binary_sensor.xiaomi_bslamp2]") + return conf + + +CONFIG_SCHEMA = cv.All( + binary_sensor.binary_sensor_schema() + .extend( + { + cv.GenerateID(): cv.declare_id(XiaomiBslamp2TouchBinarySensor), + cv.GenerateID(CONF_FRONT_PANEL_HAL_ID): cv.use_id(FrontPanelHAL), + # This option is not advertised in the documentation. It must be + # considered deprecated. I'm not announcing it as such yet. Not + # sure if it's useful to do so. + cv.Optional(CONF_PART): validate_for, + cv.Optional(CONF_FOR): validate_for, + } + ) + .extend(cv.COMPONENT_SCHEMA), + validate_binary_sensor, +) + + +def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + yield cg.register_component(var, config) + yield binary_sensor.register_binary_sensor(var, config) + + front_panel_hal_var = yield cg.get_variable(config[CONF_FRONT_PANEL_HAL_ID]) + cg.add(var.set_parent(front_panel_hal_var)) + cg.add(var.set_for(config[CONF_FOR])) diff --git a/components/xiaomi_bslamp2/binary_sensor/touch_binary_sensor.h b/components/xiaomi_bslamp2/binary_sensor/touch_binary_sensor.h new file mode 100644 index 0000000..93ea254 --- /dev/null +++ b/components/xiaomi_bslamp2/binary_sensor/touch_binary_sensor.h @@ -0,0 +1,56 @@ +#pragma once + +#include "../common.h" +#include "../front_panel_hal.h" +#include "esphome/components/binary_sensor/binary_sensor.h" + +namespace esphome { +namespace xiaomi { +namespace bslamp2 { + +/** + * This class implements a binary sensor for the touch buttons + * and touch slider on the front panel of the Xiaomi Mijia Bedside Lamp 2. + */ +class XiaomiBslamp2TouchBinarySensor : public binary_sensor::BinarySensor, public Component { + public: + void set_parent(FrontPanelHAL *front_panel) { front_panel_ = front_panel; } + + void set_for(int part) { for_ = part; } + + void setup() { + front_panel_->add_on_event_callback([this](EVENT ev) { + auto part_in_event = ev & FLAG_PART_MASK; + if (for_ == 0 || part_in_event == for_) { + auto new_state = (ev & FLAG_TYPE_MASK) == FLAG_TYPE_TOUCH; + this->publish_state(new_state); + } + }); + } + + void dump_config() { + ESP_LOGCONFIG(TAG, "Front panel binary_sensor:"); + ESP_LOGCONFIG(TAG, " For: %s", format_part()); + } + + protected: + FrontPanelHAL *front_panel_; + EVENT for_ = 0; + + const char *format_part() { + switch (for_) { + case FLAG_PART_POWER: + return "power button"; + case FLAG_PART_COLOR: + return "color button"; + case FLAG_PART_SLIDER: + return "slider"; + default: + return "ERR"; + } + } +}; + +} // namespace bslamp2 +} // namespace xiaomi +} // namespace esphome diff --git a/components/xiaomi_bslamp2/common.h b/components/xiaomi_bslamp2/common.h new file mode 100644 index 0000000..914e8c7 --- /dev/null +++ b/components/xiaomi_bslamp2/common.h @@ -0,0 +1,14 @@ +#pragma once + +#include "esphome/core/defines.h" + +namespace esphome { +namespace xiaomi { +namespace bslamp2 { + +// Used for logging purposes. +static const char *TAG = "xiaomi_bslamp2"; + +} // namespace bslamp2 +} // namespace xiaomi +} // namespace esphome diff --git a/components/xiaomi_bslamp2/front_panel_hal.h b/components/xiaomi_bslamp2/front_panel_hal.h new file mode 100644 index 0000000..9fdc26d --- /dev/null +++ b/components/xiaomi_bslamp2/front_panel_hal.h @@ -0,0 +1,346 @@ +#pragma once + +#include "common.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include +#include + +namespace esphome { +namespace xiaomi { +namespace bslamp2 { + +static const uint8_t MSG_LEN = 7; +using MSG = uint8_t[MSG_LEN]; +using LED = uint16_t; +using EVENT = uint16_t; + +// clang-format off + +// Bit flags that are used for indicating the LEDs in the front panel. +// LED_1 is the slider LED closest to the power button. +// LED_10 is the one closest to the color button. +enum FrontPanelLEDs { + LED_ALL = 16384 + 4096 + 1023, + LED_ALL_SLIDER = 512 + 256 + 128 + 64 + 32 + 16 + 8 + 4 + 2 + 1, + LED_POWER = 16384, + LED_COLOR = 4096, + LED_1 = 512, + LED_2 = 256, + LED_3 = 128, + LED_4 = 64, + LED_5 = 32, + LED_6 = 16, + LED_7 = 8, + LED_8 = 4, + LED_9 = 2, + LED_10 = 1, + LED_NONE = 0, +}; + +// This I2C command is used during front panel event handling. +static const MSG READY_FOR_EV = {0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}; + +// Bit flags that are used for specifying an event. +// Events are registered using the following bit pattern +// (bit 1 being the least significant bit): +// +// BITS INDICATE PATTERN RESULT +// 1 status 0 parsing event failed +// 1 parsing event successful +// 2-4 part 000 part unknown +// 001 power button +// 010 color button +// 100 slider +// 5-6 type 00 type unknown +// 01 touch +// 10 release +// 7-11 slider 00000 level known (or part is not "slider") +// level 00001 level 1 +// ... up to +// 10101 level 21 +// +static const EVENT FLAG_INIT = 0b00000000000; + +static const EVENT FLAG_ERR = 0b00000000000; +static const EVENT FLAG_OK = 0b00000000001; + +static const EVENT FLAG_PART_SHIFT = 1; +static const EVENT FLAG_PART_MASK = 0b00000001110; +static const EVENT FLAG_PART_UNKNOWN = 0b00000000000; +static const EVENT FLAG_PART_POWER = 0b00000000010; +static const EVENT FLAG_PART_COLOR = 0b00000000100; +static const EVENT FLAG_PART_SLIDER = 0b00000001000; + +static const EVENT FLAG_TYPE_SHIFT = 4; +static const EVENT FLAG_TYPE_MASK = 0b00000110000; +static const EVENT FLAG_TYPE_UNKNOWN = 0b00000000000; +static const EVENT FLAG_TYPE_TOUCH = 0b00000010000; +static const EVENT FLAG_TYPE_RELEASE = 0b00000100000; + +static const EVENT FLAG_LEVEL_SHIFT = 6; +static const EVENT FLAG_LEVEL_MASK = 0b11111000000; +static const EVENT FLAG_LEVEL_UNKNOWN = 0b00000000000; + +// clang-format on + +/** + * This class implements a parser that translates event byte codes from the + * Xiaomi Mijia Bedside Lamp 2 into usable events. + */ +class FrontPanelEventParser { + public: + /** + * Parse the provided event byte code (7 bytes long). + * Returns a unique integer event code that describes the parsed event. + */ + EVENT parse(uint8_t *m) { + EVENT ev = FLAG_INIT; + + // All events use the prefix [04:04:01:00]. + if (m[0] != 0x04 || m[1] != 0x04 || m[2] != 0x01 || m[3] != 0x00) { + return this->error_(ev, m, "prefix is not 04:04:01:00"); + } + + // The next byte determines the part that is touched. + // All remaining bytes specify the event for that part. + switch (m[4]) { + case 0x01: // power button + case 0x02: // color button + ev |= (m[4] == 0x01 ? FLAG_PART_POWER : FLAG_PART_COLOR); + if (m[5] == 0x01 && m[6] == (0x02 + m[4])) + ev |= FLAG_TYPE_TOUCH; + else if (m[5] == 0x02 && m[6] == (0x03 + m[4])) + ev |= FLAG_TYPE_RELEASE; + else + return this->error_(ev, m, "invalid event type for button"); + break; + case 0x03: // slider touch + case 0x04: // slider release + ev |= FLAG_PART_SLIDER; + ev |= (m[4] == 0x03 ? FLAG_TYPE_TOUCH : FLAG_TYPE_RELEASE); + if ((m[6] - m[5] - m[4] - 0x01) != 0) + return this->error_(ev, m, "invalid slider level crc"); + else if (m[5] > 0x16 || m[5] < 0x01) + return this->error_(ev, m, "out of bounds slider value"); + else { + auto level = 0x17 - m[5]; + ev |= (level << FLAG_LEVEL_SHIFT); + } + break; + default: + return this->error_(ev, m, "invalid part id"); + return ev; + } + + // All parsing rules passed. This event is valid. + ESP_LOGD(TAG, "Front panel I2C event parsed: code=%d", ev); + ev |= FLAG_OK; + + return ev; + } + + protected: + bool has_(EVENT ev, EVENT mask, EVENT flag) { return (ev & mask) == flag; } + + EVENT error_(EVENT ev, uint8_t *m, const char *msg) { + ESP_LOGE(TAG, "Front panel I2C event error:"); + ESP_LOGE(TAG, " Error: %s", msg); + ESP_LOGE(TAG, " Event: [%02x:%02x:%02x:%02x:%02x:%02x:%02x]", m[0], m[1], m[2], m[3], m[4], m[5], m[6]); + ESP_LOGE(TAG, " Parsed part: %s", this->format_part_(ev)); + ESP_LOGE(TAG, " Parsed event type: %s", this->format_event_type_(ev)); + if (has_(ev, FLAG_PART_MASK, FLAG_PART_SLIDER)) { + auto level = (ev & FLAG_LEVEL_MASK) >> FLAG_LEVEL_SHIFT; + if (level > 0) { + ESP_LOGE(TAG, " Parsed slider level: %d", level); + } + } + + return ev; + } + + const char *format_part_(EVENT ev) { + if (has_(ev, FLAG_PART_MASK, FLAG_PART_POWER)) + return "power button"; + if (has_(ev, FLAG_PART_MASK, FLAG_PART_COLOR)) + return "color button"; + if (has_(ev, FLAG_PART_MASK, FLAG_PART_SLIDER)) + return "slider"; + return "n/a"; + } + + const char *format_event_type_(EVENT ev) { + if (has_(ev, FLAG_TYPE_MASK, FLAG_TYPE_TOUCH)) + return "touch"; + if (has_(ev, FLAG_TYPE_MASK, FLAG_TYPE_RELEASE)) + return "release"; + return "n/a"; + } +}; + +struct FrontPanelTriggerStore { + volatile int event_id{0}; + static void gpio_intr(FrontPanelTriggerStore *store); +}; + +/** + * This ISR is used to handle IRQ triggers from the front panel. + * + * The front panel pulls the trigger pin low for a short period of time + * when a new event is available. All we do here to handle the interrupt, + * is increment a simple event id counter. The main loop of the component + * will take care of actually reading and processing the event. + */ +void IRAM_ATTR HOT FrontPanelTriggerStore::gpio_intr(FrontPanelTriggerStore *store) { + store->event_id = store->event_id + 1; +} + +/** + * This is a hardware abstraction layer that communicates with with front + * panel of the Xiaomi Mijia Bedside Lamp 2. + * + * It serves as a hub component for other components that implement + * the actual buttons and slider components. + */ +class FrontPanelHAL : public Component, public i2c::I2CDevice { + public: + FrontPanelEventParser event; + + /** + * Set the GPIO pin that is used by the front panel to notify the ESP + * that a touch/release event can be read using I2C. + */ + void set_trigger_pin(InternalGPIOPin *pin) { + trigger_pin_ = pin; + } + + void add_on_event_callback(std::function &&callback) { + event_callback_.add(std::move(callback)); + } + + void setup() { + ESP_LOGCONFIG(TAG, "Setting up I2C trigger pin interrupt..."); + this->trigger_pin_->setup(); + this->trigger_pin_->attach_interrupt( + FrontPanelTriggerStore::gpio_intr, + &this->store_, + gpio::INTERRUPT_FALLING_EDGE); + } + + void dump_config() { + ESP_LOGCONFIG(TAG, "FrontPanelHAL:"); + LOG_I2C_DEVICE(this); + LOG_PIN(" I2C interrupt pin: ", trigger_pin_); + } + + void loop() { + // Read and publish front panel events. + auto current_event_id = this->store_.event_id; + if (current_event_id != this->last_event_id_) { + this->last_event_id_ = current_event_id; + if (this->write(READY_FOR_EV, MSG_LEN) != i2c::ERROR_OK) { + ESP_LOGW(TAG, "Writing READY_FOR_EV to front panel failed"); + } + MSG message; + if (this->read(message, MSG_LEN) != i2c::ERROR_OK) { + ESP_LOGW(TAG, "Reading message from front panel failed"); + return; + } + auto ev = event.parse(message); + if (ev & FLAG_OK) { + this->event_callback_.call(ev); + } else { + ESP_LOGW(TAG, "Skipping unsupported message from front panel"); + } + } + + if (led_state_ != last_led_state_) { + update_leds(); + } + } + + /** + * Turn on one or more LEDs (leaving the state of the other LEDs intact). + * The input value is a bitwise OR-ed set of LED constants. + * Only after a call to update_leds() (handled by default from the main loop), + * the new state will be activated. + */ + void turn_on_leds(uint16_t leds) { + led_state_ = led_state_ | 0b0000110000000000 | leds; + } + + /** + * Turn off one or more LEDs (leaving the state of the other LEDs intact). + * The input value is a bitwise OR-ed set of LED constants. + * Only after a call to update_leds() (handled by default from the main loop), + * the new state will be activated. + */ + void turn_off_leds(uint16_t leds) { + led_state_ = (led_state_ | 0b0000110000000000) & ~leds; + } + + /** + * Updates the state of the LEDs according to the provided input. + * The input value is a bitwise OR-ed set of LED constants, representing the + * LEDs that must be turned on. All other LEDs are turned off. + * Only after a call to update_leds() (handled by default from the main loop), + * the new state will be activated. + */ + void set_leds(uint16_t leds) { + turn_off_leds(LED_ALL); + turn_on_leds(leds); + } + + /** + * Activate the LEDs according to the currently stored LED state. This method + * will be called automatically by the main loop. You can call this method, + * in case you need to update the LED state right away. + */ + void update_leds() { + led_msg_[2] = led_state_ >> 8; + led_msg_[3] = led_state_ & 0xff; + write(led_msg_, MSG_LEN); + last_led_state_ = led_state_; + } + + /** + * Sets the front panel slider illumination to the provided level, + * based on a float input (0.0 - 1.0). + * + * This implements the behavior of the original firmware for representing + * the lamp's brightness. + * + * Level 0.0 means: turn off the slider illumination. + * The other levels are translated to one of the available levels. + */ + void set_slider_level(float level) { + turn_off_leds(LED_ALL_SLIDER); + if (level == 0.00f) return; + if (level > 0.00f) turn_on_leds(LED_1); + if (level > 0.15f) turn_on_leds(LED_2); + if (level > 0.25f) turn_on_leds(LED_3); + if (level > 0.35f) turn_on_leds(LED_4); + if (level > 0.45f) turn_on_leds(LED_5); + if (level > 0.55f) turn_on_leds(LED_6); + if (level > 0.65f) turn_on_leds(LED_7); + if (level > 0.75f) turn_on_leds(LED_8); + if (level > 0.85f) turn_on_leds(LED_9); + if (level > 0.95f) turn_on_leds(LED_10); + } + + protected: + InternalGPIOPin *trigger_pin_; + FrontPanelTriggerStore store_{}; + int last_event_id_ = 0; + CallbackManager event_callback_{}; + + uint16_t led_state_ = 0; + uint16_t last_led_state_ = 0; + MSG led_msg_ = {0x02, 0x03, 0x00, 0x00, 0x64, 0x00, 0x00}; +}; + +} // namespace bslamp2 +} // namespace xiaomi +} // namespace esphome diff --git a/components/xiaomi_bslamp2/light/__init__.py b/components/xiaomi_bslamp2/light/__init__.py new file mode 100644 index 0000000..0becc61 --- /dev/null +++ b/components/xiaomi_bslamp2/light/__init__.py @@ -0,0 +1,331 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import light +from esphome import automation +from esphome.core import Lambda +from esphome.const import ( + CONF_RED, + CONF_GREEN, + CONF_BLUE, + CONF_COLOR_TEMPERATURE, + CONF_STATE, + CONF_OUTPUT_ID, + CONF_TRIGGER_ID, + CONF_ID, + CONF_TRANSITION_LENGTH, + CONF_BRIGHTNESS, + CONF_EFFECT, + CONF_FLASH_LENGTH, + CONF_RESTORE_MODE, +) +from .. import bslamp2_ns, CODEOWNERS, CONF_LIGHT_HAL_ID, LightHAL + +__all__ = ["CODEOWNERS"] + +DEPENDENCIES = ["xiaomi_bslamp2"] + +CONF_ON_BRIGHTNESS = "on_brightness" +CONF_PRESET_ID = "preset_id" +CONF_PRESETS_ID = "presets_id" +CONF_PRESET = "preset" +CONF_PRESETS = "presets" +CONF_NEXT = "next" +CONF_GROUP = "group" +CONF_NIGHT_CALIBRATION = "night_calibration" + +MIRED_MIN = 153 +MIRED_MAX = 588 +DEFAULT_NIGHT_LIGHT_RED = 0.968 +DEFAULT_NIGHT_LIGHT_GREEN = 0.968 +DEFAULT_NIGHT_LIGHT_BLUE = 0.972 + +XiaomiBslamp2LightState = bslamp2_ns.class_("XiaomiBslamp2LightState", light.LightState) +XiaomiBslamp2LightOutput = bslamp2_ns.class_("XiaomiBslamp2LightOutput", light.LightOutput) +PresetsContainer = bslamp2_ns.class_("PresetsContainer", cg.Component) +Preset = bslamp2_ns.class_("Preset", cg.Component) +BrightnessTrigger = bslamp2_ns.class_("BrightnessTrigger", automation.Trigger.template()) +ActivatePresetAction = bslamp2_ns.class_("ActivatePresetAction", automation.Action) +DiscoAction = bslamp2_ns.class_("DiscoAction", automation.Action) + +PRESETS_SCHEMA = cv.Schema({str.lower: cv.Schema({str.lower: light.automation.LIGHT_TURN_ON_ACTION_SCHEMA})}) + + +def validate_preset(config): + has_rgb = CONF_RED in config or CONF_GREEN in config or CONF_BLUE in config + has_white = CONF_COLOR_TEMPERATURE in config + has_effect = CONF_EFFECT in config + + # Check mutual exclusivity of preset options. + if (has_rgb + has_white + has_effect) > 1: + raise cv.Invalid("Use only one of RGB light, white (color temperature) light or an effect") + + # Check the color temperature value range. + if has_white: + if config[CONF_COLOR_TEMPERATURE] < MIRED_MIN or config[CONF_COLOR_TEMPERATURE] > MIRED_MAX: + raise cv.Invalid(f"The color temperature must be in the range {MIRED_MIN} - {MIRED_MAX}") + + # When defining an RGB color, it is allowed to omit RGB components that have value 0. + if has_rgb: + if CONF_RED not in config: + config[CONF_RED] = 0 + if CONF_GREEN not in config: + config[CONF_GREEN] = 0 + if CONF_BLUE not in config: + config[CONF_BLUE] = 0 + + return config + + +PRESET_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_ID): cv.use_id(XiaomiBslamp2LightState), + cv.GenerateID(CONF_PRESET_ID): cv.declare_id(Preset), + cv.Optional(CONF_EFFECT): cv.string, + cv.Optional(CONF_COLOR_TEMPERATURE): cv.color_temperature, + cv.Optional(CONF_RED): cv.percentage, + cv.Optional(CONF_GREEN): cv.percentage, + cv.Optional(CONF_BLUE): cv.percentage, + cv.Optional(CONF_BRIGHTNESS): cv.percentage, + cv.Optional(CONF_TRANSITION_LENGTH): cv.positive_time_period_milliseconds, + } + ), + validate_preset, +) + +CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_ID): cv.declare_id(XiaomiBslamp2LightState), + cv.GenerateID(CONF_LIGHT_HAL_ID): cv.use_id(LightHAL), + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(XiaomiBslamp2LightOutput), + cv.Optional(CONF_RESTORE_MODE, default="RESTORE_DEFAULT_OFF"): cv.enum( + light.RESTORE_MODES, upper=True, space="_" + ), + cv.Optional(CONF_NIGHT_CALIBRATION, default={}): cv.Schema( + { + cv.Optional(CONF_RED, default=DEFAULT_NIGHT_LIGHT_RED): cv.float_range(min=0.0, max=1.0), + cv.Optional(CONF_GREEN, default=DEFAULT_NIGHT_LIGHT_GREEN): cv.float_range(min=0.0, max=1.0), + cv.Optional(CONF_BLUE, default=DEFAULT_NIGHT_LIGHT_BLUE): cv.float_range(min=0.0, max=1.0), + } + ), + cv.Optional(CONF_ON_BRIGHTNESS): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(BrightnessTrigger), + } + ), + cv.GenerateID(CONF_PRESETS_ID): cv.declare_id(PresetsContainer), + cv.Optional(CONF_PRESETS): cv.Schema({str.lower: cv.Schema({str.lower: PRESET_SCHEMA})}), + } +) + + +def is_preset_group(value): + return value + + +def is_preset(value): + return value + + +def maybe_simple_preset_action(schema): + def validator(value): + if isinstance(value, dict): + return schema(value) + value = value.lower() + config = {} + if value == "next_group": + config[CONF_NEXT] = CONF_GROUP + elif value == "next_preset": + config[CONF_NEXT] = CONF_PRESET + elif "." not in value: + config[CONF_GROUP] = value + else: + group, preset = value.split(".", 2) + config[CONF_GROUP] = group + config[CONF_PRESET] = preset + return schema(config) + + return validator + + +@automation.register_action( + "light.disco_on", + DiscoAction, + light.automation.LIGHT_TURN_ON_ACTION_SCHEMA, + synchronous=True, +) +def disco_action_on_to_code(config, action_id, template_arg, args): + light_var = yield cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, light_var) + + if CONF_STATE in config: + template_ = yield cg.templatable(config[CONF_STATE], args, bool) + cg.add(var.set_state(template_)) + if CONF_TRANSITION_LENGTH in config: + template_ = yield cg.templatable(config[CONF_TRANSITION_LENGTH], args, cg.uint32) + cg.add(var.set_transition_length(template_)) + if CONF_FLASH_LENGTH in config: + template_ = yield cg.templatable(config[CONF_FLASH_LENGTH], args, cg.uint32) + cg.add(var.set_flash_length(template_)) + if CONF_BRIGHTNESS in config: + template_ = yield cg.templatable(config[CONF_BRIGHTNESS], args, float) + cg.add(var.set_brightness(template_)) + if CONF_RED in config: + template_ = yield cg.templatable(config[CONF_RED], args, float) + cg.add(var.set_red(template_)) + if CONF_GREEN in config: + template_ = yield cg.templatable(config[CONF_GREEN], args, float) + cg.add(var.set_green(template_)) + if CONF_BLUE in config: + template_ = yield cg.templatable(config[CONF_BLUE], args, float) + cg.add(var.set_blue(template_)) + if CONF_COLOR_TEMPERATURE in config: + template_ = yield cg.templatable(config[CONF_COLOR_TEMPERATURE], args, float) + cg.add(var.set_color_temperature(template_)) + if CONF_EFFECT in config: + template_ = yield cg.templatable(config[CONF_EFFECT], args, cg.std_string) + cg.add(var.set_effect(template_)) + yield var + + +@automation.register_action( + "light.disco_off", + DiscoAction, + light.automation.LIGHT_TURN_OFF_ACTION_SCHEMA, + synchronous=True, +) +async def disco_action_off_to_code(config, action_id, template_arg, args): + light_var = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, light_var) + template_ = await cg.templatable(False, args, bool) + cg.add(var.set_disco_state(template_)) + return var + + +USED_PRESETS = [] + + +def register_preset_action(value): + if "group" in value and not isinstance(value["group"], Lambda): + if "preset" in value and not isinstance(value["preset"], Lambda): + preset_data = [value["group"], value["preset"]] + else: + preset_data = [value["group"], None] + USED_PRESETS.append(preset_data) + return value + + +@automation.register_action( + "preset.activate", + ActivatePresetAction, + cv.All( + maybe_simple_preset_action( + cv.Any( + cv.Schema( + { + cv.GenerateID(CONF_PRESETS_ID): cv.use_id(PresetsContainer), + cv.Required(CONF_GROUP): cv.templatable(cv.string), + cv.Optional(CONF_PRESET): cv.templatable(cv.string), + } + ), + cv.Schema( + { + cv.GenerateID(CONF_PRESETS_ID): cv.use_id(PresetsContainer), + cv.Required(CONF_NEXT): cv.one_of(CONF_GROUP, CONF_PRESET, lower=True), + } + ), + ) + ), + register_preset_action, + ), + synchronous=True, +) +def preset_activate_to_code(config, action_id, template_arg, args): + presets_var = yield cg.get_variable(config[CONF_PRESETS_ID]) + action_var = cg.new_Pvariable(action_id, template_arg, presets_var) + if CONF_NEXT in config: + cg.add(action_var.set_operation(f"next_{config[CONF_NEXT]}")) + elif CONF_PRESET in config: + cg.add(action_var.set_operation("activate_preset")) + group_template_ = yield cg.templatable(config[CONF_GROUP], args, cg.std_string) + cg.add(action_var.set_group(group_template_)) + preset_template_ = yield cg.templatable(config[CONF_PRESET], args, cg.std_string) + cg.add(action_var.set_preset(preset_template_)) + else: + cg.add(action_var.set_operation("activate_group")) + group_template_ = yield cg.templatable(config[CONF_GROUP], args, cg.std_string) + cg.add(action_var.set_group(group_template_)) + yield action_var + + +async def light_output_to_code(config): + light_output_var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) + await light.register_light(light_output_var, config) + light_hal_var = await cg.get_variable(config[CONF_LIGHT_HAL_ID]) + cg.add(light_output_var.set_parent(light_hal_var)) + night_calibration = config[CONF_NIGHT_CALIBRATION] + cg.add( + light_output_var.set_night_light_color_temperature_calibration( + night_calibration[CONF_RED], + night_calibration[CONF_GREEN], + night_calibration[CONF_BLUE], + ) + ) + + +async def on_brightness_to_code(config): + light_output_var = await cg.get_variable(config[CONF_OUTPUT_ID]) + for config_ in config.get(CONF_ON_BRIGHTNESS, []): + trigger = cg.new_Pvariable(config_[CONF_TRIGGER_ID], light_output_var) + await automation.build_automation(trigger, [(float, "x")], config_) + + +async def preset_to_code(config, preset_group, preset_name): + light_var = await cg.get_variable(config[CONF_ID]) + preset_var = cg.new_Pvariable(config[CONF_PRESET_ID], light_var, preset_group, preset_name) + if CONF_TRANSITION_LENGTH in config: + cg.add(preset_var.set_transition_length(config[CONF_TRANSITION_LENGTH])) + if CONF_BRIGHTNESS in config: + cg.add(preset_var.set_brightness(config[CONF_BRIGHTNESS])) + if CONF_RED in config: + cg.add(preset_var.set_red(config[CONF_RED])) + if CONF_GREEN in config: + cg.add(preset_var.set_green(config[CONF_GREEN])) + if CONF_BLUE in config: + cg.add(preset_var.set_blue(config[CONF_BLUE])) + if CONF_COLOR_TEMPERATURE in config: + cg.add(preset_var.set_color_temperature(config[CONF_COLOR_TEMPERATURE])) + if CONF_EFFECT in config: + cg.add(preset_var.set_effect(config[CONF_EFFECT])) + else: + cg.add(preset_var.set_effect("None")) + return await cg.register_component(preset_var, config) + + +async def presets_to_code(config): + presets_var = cg.new_Pvariable(config[CONF_PRESETS_ID]) + await cg.register_component(presets_var, config) + + for preset_group, presets in config.get(CONF_PRESETS, {}).items(): + for preset_name, preset_config in presets.items(): + preset = await preset_to_code(preset_config, preset_group, preset_name) + cg.add(presets_var.add_preset(preset)) + + +async def to_code(config): + await light_output_to_code(config) + await on_brightness_to_code(config) + await presets_to_code(config) + + +def validate(config): + valid_presets = config.get(CONF_PRESETS, {}) + for group, preset in USED_PRESETS: + if group not in valid_presets: + raise cv.Invalid(f"Invalid light preset group '{group}' used") + if preset is not None and preset not in valid_presets[group]: + raise cv.Invalid(f"Invalid light preset '{group}.{preset}' used") + return config + + +FINAL_VALIDATE_SCHEMA = cv.Schema(validate) diff --git a/components/xiaomi_bslamp2/light/automation.h b/components/xiaomi_bslamp2/light/automation.h new file mode 100644 index 0000000..9768efe --- /dev/null +++ b/components/xiaomi_bslamp2/light/automation.h @@ -0,0 +1,109 @@ +#pragma once + +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "interfaces.h" +#include "light_output.h" +#include "light_state.h" +#include "presets.h" +#include + +namespace esphome { +namespace xiaomi { +namespace bslamp2 { + +class BrightnessTrigger : public Trigger { + public: + explicit BrightnessTrigger(XiaomiBslamp2LightOutput *parent) { + parent->add_on_state_callback([this](light::LightColorValues values) { + auto new_brightness = values.get_brightness(); + if (values.get_state() == 0) + new_brightness = 0.0f; + new_brightness = roundf(new_brightness * 100.0f) / 100.0f; + if (last_brightness_ != new_brightness) { + trigger(new_brightness); + last_brightness_ = new_brightness; + } + }); + } + + protected: + float last_brightness_ = -1.0f; +}; + +template class DiscoAction : public Action { + public: + explicit DiscoAction(LightStateDiscoSupport *parent) : parent_(parent) {} + + TEMPLATABLE_VALUE(bool, disco_state) + TEMPLATABLE_VALUE(bool, state) + TEMPLATABLE_VALUE(uint32_t, transition_length) + TEMPLATABLE_VALUE(uint32_t, flash_length) + TEMPLATABLE_VALUE(float, brightness) + TEMPLATABLE_VALUE(float, red) + TEMPLATABLE_VALUE(float, green) + TEMPLATABLE_VALUE(float, blue) + TEMPLATABLE_VALUE(float, color_temperature) + TEMPLATABLE_VALUE(std::string, effect) + + void play(const Ts &...x) override { + if (this->disco_state_.has_value()) { + auto p = this->disco_state_.optional_value(x...); + if (!*p) { + parent_->disco_stop(); + return; + } + } + + auto call = this->parent_->make_disco_call(false); + call.set_state(this->state_.optional_value(x...)); + call.set_brightness(this->brightness_.optional_value(x...)); + call.set_red(this->red_.optional_value(x...)); + call.set_green(this->green_.optional_value(x...)); + call.set_blue(this->blue_.optional_value(x...)); + call.set_color_temperature(this->color_temperature_.optional_value(x...)); + call.set_effect(this->effect_.optional_value(x...)); + call.set_flash_length(this->flash_length_.optional_value(x...)); + call.set_transition_length(this->transition_length_.optional_value(x...)); + + // Force the light to update right now, not in the next loop. + call.perform(); + parent_->disco_apply(); + } + + protected: + LightStateDiscoSupport *parent_; +}; + +template class ActivatePresetAction : public Action { + public: + explicit ActivatePresetAction(PresetsContainer *presets) : presets_(presets) {} + + TEMPLATABLE_VALUE(std::string, operation); + TEMPLATABLE_VALUE(std::string, group); + TEMPLATABLE_VALUE(std::string, preset); + + void play(const Ts &...x) override { + auto operation = this->operation_.value(x...); + + if (operation == "next_group") { + presets_->activate_next_group(); + } else if (operation == "next_preset") { + presets_->activate_next_preset(); + } else if (operation == "activate_group") { + auto group = this->group_.value(x...); + presets_->activate_group(group); + } else if (operation == "activate_preset") { + auto group = this->group_.value(x...); + auto preset = this->preset_.value(x...); + presets_->activate_preset(group, preset); + } + } + + protected: + PresetsContainer *presets_; +}; + +} // namespace bslamp2 +} // namespace xiaomi +} // namespace esphome diff --git a/components/xiaomi_bslamp2/light/color_handler.h b/components/xiaomi_bslamp2/light/color_handler.h new file mode 100644 index 0000000..7d8cdea --- /dev/null +++ b/components/xiaomi_bslamp2/light/color_handler.h @@ -0,0 +1,28 @@ +#pragma once + +#include "../light_hal.h" + +namespace esphome { +namespace xiaomi { +namespace bslamp2 { + +/** + * This abstract class is used for implementing classes that translate + * LightColorValues into the required GPIO PWM duty cycle levels to represent + * the requested color on the physical device. + */ +class ColorHandler : public GPIOOutputValues { + public: + /** + * Sets the red, green, blue, white fields to the PWM duty cycles + * that are required to represent the requested light color for + * the provided LightColorValues input. + * + * Returns true when the input can be handled, false otherwise. + */ + virtual bool set_light_color_values(light::LightColorValues v) = 0; +}; + +} // namespace bslamp2 +} // namespace xiaomi +} // namespace esphome diff --git a/components/xiaomi_bslamp2/light/color_handler_chain.h b/components/xiaomi_bslamp2/light/color_handler_chain.h new file mode 100644 index 0000000..f957397 --- /dev/null +++ b/components/xiaomi_bslamp2/light/color_handler_chain.h @@ -0,0 +1,68 @@ +#pragma once + +#include +#include + +#include "../common.h" +#include "color_handler.h" +#include "color_handler_off.h" +#include "color_handler_rgb.h" +#include "color_handler_color_temperature.h" +#include "color_handler_night_light.h" + +namespace esphome { +namespace xiaomi { +namespace bslamp2 { + +/** + * This class translates LightColorValues into GPIO duty cycles that can be + * used for representing a requested light color on the physical device. + * + * The code handles all known light modes for the device: + * + * - off: the light is off + * - night light: based on RGB or white mode + lowest possible brightness + * - white light: based on color temperature + brightness + * - RGB light: based on RGB values + brightness + */ +class ColorHandlerChain : public ColorHandler { + public: + void set_night_light_color_temperature_calibration(NightLightCalibration calibration) { + night_light_.set_color_temperature_calibration(calibration); + } + + void set_night_light_color_temperature_calibration(float red, float green, float blue) { + set_night_light_color_temperature_calibration({red, green, blue}); + } + + bool set_light_color_values(light::LightColorValues v) { + // The actual implementation of the various light modes is in separate + // targeted classes. These classes are called here in a chain of + // command-like pattern, to let the first one that can handle the light + // settings do the honours. + if (off_light_.set_light_color_values(v)) + off_light_.copy_to(this); + else if (night_light_.set_light_color_values(v)) + night_light_.copy_to(this); + else if (white_light_.set_light_color_values(v)) + white_light_.copy_to(this); + else if (rgb_light_.set_light_color_values(v)) + rgb_light_.copy_to(this); + else { + ESP_LOGE(TAG, "Light color error: (None of the ColorHandler classes handles the requested light state; defaulting to 'off'"); + off_light_.copy_to(this); + } + + return true; + } + + protected: + ColorHandlerOff off_light_; + ColorHandlerRGB rgb_light_; + ColorHandlerColorTemperature white_light_; + ColorHandlerNightLight night_light_; +}; + +} // namespace bslamp2 +} // namespace xiaomi +} // namespace esphome diff --git a/components/xiaomi_bslamp2/light/color_handler_color_temperature.h b/components/xiaomi_bslamp2/light/color_handler_color_temperature.h new file mode 100644 index 0000000..cc62a83 --- /dev/null +++ b/components/xiaomi_bslamp2/light/color_handler_color_temperature.h @@ -0,0 +1,131 @@ +#pragma once + +#include +#include + +#include "../common.h" +#include "../light_hal.h" +#include "color_handler.h" + +namespace esphome { +namespace xiaomi { +namespace bslamp2 { + +/** + * The minimum color temperature in mired. + * Same as supported by the original firmware. + */ +static const int MIRED_MIN = 153; + +/** + * The maximum color temperature in mired. + * Same as supported by the original firmware. + */ +static const int MIRED_MAX = 588; + +struct RGBWLevelsByTemperature { + float from_temperature; + float red; + float green; + float blue; + float white; +}; + +using RGBWLevelsTable = std::array; + +// clang-format off +static const RGBWLevelsTable rgbw_levels_1_ {{ + { 501.0f, 0.873f, 0.907f, 1.000f, 0.063f }, + { 455.0f, 0.873f, 0.896f, 1.000f, 0.063f }, + { 417.0f, 0.873f, 0.891f, 1.000f, 0.068f }, + { 371.0f, 0.873f, 0.880f, 1.000f, 0.070f }, + { 334.0f, 0.873f, 0.887f, 1.000f, 0.088f }, + { 313.0f, 0.882f, 0.904f, 1.000f, 0.128f }, + { 295.0f, 0.947f, 1.000f, 0.968f, 0.145f }, + { 251.0f, 0.999f, 1.000f, 1.000f, 0.155f }, + { 223.0f, 1.000f, 0.899f, 0.921f, 0.130f }, + { 201.0f, 1.000f, 0.873f, 0.908f, 0.115f }, + { 182.0f, 1.000f, 0.873f, 0.901f, 0.103f }, + { 173.0f, 1.000f, 0.873f, 0.904f, 0.094f }, + { 167.0f, 1.000f, 0.873f, 0.891f, 0.098f }, + { 154.0f, 1.000f, 0.873f, 0.894f, 0.090f }, + { 153.0f, 1.000f, 0.873f, 0.892f, 0.088f } +}}; + +static const RGBWLevelsTable rgbw_levels_100_ {{ + { 501.0f, 0.000f, 0.344f, 1.000f, 0.068f }, + { 455.0f, 0.000f, 0.237f, 1.000f, 0.093f }, + { 417.0f, 0.000f, 0.186f, 1.000f, 0.120f }, + { 371.0f, 0.000f, 0.149f, 1.000f, 0.167f }, + { 334.0f, 0.000f, 0.135f, 1.000f, 0.325f }, + { 313.0f, 0.097f, 0.314f, 1.000f, 0.740f }, + { 295.0f, 0.745f, 1.000f, 0.953f, 0.905f }, + { 251.0f, 1.000f, 1.000f, 1.000f, 1.000f }, + { 223.0f, 1.000f, 0.267f, 0.485f, 0.765f }, + { 201.0f, 1.000f, 0.000f, 0.355f, 0.609f }, + { 182.0f, 1.000f, 0.000f, 0.282f, 0.489f }, + { 173.0f, 1.000f, 0.000f, 0.313f, 0.392f }, + { 167.0f, 1.000f, 0.000f, 0.180f, 0.422f }, + { 154.0f, 1.000f, 0.000f, 0.218f, 0.368f }, + { 153.0f, 1.000f, 0.000f, 0.187f, 0.335f } +}}; +// clang-format on + +/** + * This class can handle the GPIO outputs for the white light mode, + * based on color temperature + brightness. + */ +class ColorHandlerColorTemperature : public ColorHandler { + public: + bool set_light_color_values(light::LightColorValues v) { + light_mode = LIGHT_MODE_WHITE; + + if (v.get_color_mode() != light::ColorMode::COLOR_TEMPERATURE) { + return false; + } + + auto temperature = clamp_temperature_(v.get_color_temperature()); + auto brightness = clamp_brightness_(v.get_brightness()); + + auto levels_1 = lookup_in_table_(rgbw_levels_1_, temperature); + auto levels_100 = lookup_in_table_(rgbw_levels_100_, temperature); + + red = std::lerp(levels_1.red, levels_100.red, brightness); + green = std::lerp(levels_1.green, levels_100.green, brightness); + blue = std::lerp(levels_1.blue, levels_100.blue, brightness); + white = std::lerp(levels_1.white, levels_100.white, brightness); + + return true; + } + + protected: + float clamp_temperature_(float temperature) { + if (temperature > MIRED_MAX) + temperature = MIRED_MAX; + else if (temperature < MIRED_MIN) + temperature = MIRED_MIN; + return temperature; + } + + float clamp_brightness_(float brightness) { + if (brightness < 0.01f) + brightness = 0.01f; + else if (brightness > 1.00f) + brightness = 1.00f; + return brightness; + } + + RGBWLevelsByTemperature lookup_in_table_(RGBWLevelsTable table, float temperature) { + for (RGBWLevelsByTemperature& item : table) + if (temperature >= item.from_temperature) + return item; + // Temperature too low. Shouldn't happen, because of validation + // at higher levels. But when it happens, simply return the data + // for lowest available temperature. + return table[0]; + } +}; + +} // namespace bslamp2 +} // namespace xiaomi +} // namespace esphome diff --git a/components/xiaomi_bslamp2/light/color_handler_night_light.h b/components/xiaomi_bslamp2/light/color_handler_night_light.h new file mode 100644 index 0000000..f2e275c --- /dev/null +++ b/components/xiaomi_bslamp2/light/color_handler_night_light.h @@ -0,0 +1,85 @@ +#pragma once + +#include "../common.h" +#include "../light_hal.h" +#include "color_handler.h" +#include "esphome/core/helpers.h" + +namespace esphome { +namespace xiaomi { +namespace bslamp2 { + +struct NightLightCalibration { + float red; + float green; + float blue; +}; + +static const NightLightCalibration DEFAULT_NIGHT_LIGHT_CALIBRATION{0.968f, 0.968f, 0.972f}; + +/** + * This class can handle the GPIO outputs for the night light mode. + * + * At the lowest brightness setting, the light will switch to night light + * mode. In the Yeelight integration in Home Assistant, this feature is + * exposed trough a separate switch. I have found that the switch is both + * confusing and made me run into issues when automating the lights. + * Using the lowest brightness for triggering the night light feels a lot + * more natural. + * + * Note that if a switch is still the preferred way to handle the night + * light mode toggle, then this still could be implemented through the + * device's yaml configuration. + */ +class ColorHandlerNightLight : public ColorHandler { + public: + void set_color_temperature_calibration(NightLightCalibration calibration) { + color_temperature_calibration_.red = clamp(calibration.red, 0.0f, 1.0f); + color_temperature_calibration_.green = clamp(calibration.green, 0.0f, 1.0f); + color_temperature_calibration_.blue = clamp(calibration.blue, 0.0f, 1.0f); + } + + void set_color_temperature_calibration(float red, float green, float blue) { + set_color_temperature_calibration({red, green, blue}); + } + + bool set_light_color_values(light::LightColorValues v) { + light_mode = LIGHT_MODE_NIGHT; + + // Note: I do not check for a brightness at or below 0.01 (1%) here, + // because the lowest brightness setting from Home Assistant turns + // up as 0.011765 in here (which is 3/255 and not 1/100). + if (v.get_brightness() >= 0.012f) + return false; + + // This night light mode is activated when white light is selected. + // Based on measurements using the original device firmware, so it + // matches the night light of the original firmware. + if (v.get_color_mode() == light::ColorMode::COLOR_TEMPERATURE) { + red = color_temperature_calibration_.red; + green = color_temperature_calibration_.green; + blue = color_temperature_calibration_.blue; + white = 0.0f; + } + // In RGB mode, the selected color is used to give the night light a + // specific color, instead of the default. This is a nice extra for + // this firmware, as the original firmware does not support it. + else { + red = std::lerp(0.9997f, 0.9680f, v.get_red()); + green = std::lerp(0.9997f, 0.9680f, v.get_green()); + auto blue_scale = (v.get_red() + v.get_green()) / 2.0f; + auto blue_max = std::lerp(0.9640f, 0.9720f, blue_scale); + blue = std::lerp(0.9997f, blue_max, v.get_blue()); + white = 0.0f; + } + + return true; + } + + protected: + NightLightCalibration color_temperature_calibration_{DEFAULT_NIGHT_LIGHT_CALIBRATION}; +}; + +} // namespace bslamp2 +} // namespace xiaomi +} // namespace esphome diff --git a/components/xiaomi_bslamp2/light/color_handler_off.h b/components/xiaomi_bslamp2/light/color_handler_off.h new file mode 100644 index 0000000..64f6042 --- /dev/null +++ b/components/xiaomi_bslamp2/light/color_handler_off.h @@ -0,0 +1,33 @@ +#pragma once + +#include "../common.h" +#include "../light_hal.h" +#include "color_handler.h" + +namespace esphome { +namespace xiaomi { +namespace bslamp2 { + +/** + * This class can handle the GPIO outputs in case the light is turned off. + */ +class ColorHandlerOff : public ColorHandler { + public: + bool set_light_color_values(light::LightColorValues v) { + light_mode = LIGHT_MODE_OFF; + + if (v.get_state() != 0.0f && v.get_brightness() != 0.0f) + return false; + + red = 1.0f; + green = 1.0f; + blue = 1.0f; + white = 0.0f; + + return true; + } +}; + +} // namespace bslamp2 +} // namespace xiaomi +} // namespace esphome diff --git a/components/xiaomi_bslamp2/light/color_handler_rgb.h b/components/xiaomi_bslamp2/light/color_handler_rgb.h new file mode 100644 index 0000000..fe6159e --- /dev/null +++ b/components/xiaomi_bslamp2/light/color_handler_rgb.h @@ -0,0 +1,379 @@ +#pragma once + +#include +#include +#include + +#include "../common.h" +#include "../light_hal.h" +#include "color_handler.h" + +namespace esphome { +namespace xiaomi { +namespace bslamp2 { + +struct RGB { + float red; + float green; + float blue; +}; + +struct RGBPoint { + RGB low; + RGB high; +}; + +using RGBRing = std::array; + +using RGBCircle = std::array; + +/** + * The following table contains GPIO PWM duty cycles as used for driving + * the LEDs in the device in RGB mode. + * + * The base for this table are measurements against the original device + * firmware, using the RGB color circle as used in Home Assistant as the + * color space model. + * + * This circle has 7 colored rings around a white center point. The outer + * ring, with the highest saturation, is numbered as 0. The inner ring + * around the white center point is numbered as 6. The white center point + * itself is numbered as 7, although this one cannot really be called "a + * ring". + * + * For each ring, there are 24 color positions, starting at the color red + * (0°), going around the circle clockwise via green (120°) and blue (240°). + * + * For each color position, two duty cycle measurements are registered: + * - one defining the duty cycles at 1% brightness + * - one defining the duty cycles at 100% brightness Duty cycles for + * in-between brightnesses can be derived from these values by means of + * linear interpolation. + */ +// clang-format off +static const RGBCircle rgb_circle_ {{ + // Ring 0, min value RGB component value = 0 + {{ + {{ 0.8998, 0.9997, 0.9997 }, { 0.0000, 0.9997, 0.9997 }}, // 0° [255,0,0] (red) + {{ 0.8727, 0.9404, 0.9682 }, { 0.0000, 0.6758, 0.9539 }}, // 15° [255,0,63] + {{ 0.8727, 0.8967, 0.9677 }, { 0.0000, 0.2389, 0.9506 }}, // 30° [255,0,126] + {{ 0.9030, 0.8727, 0.9682 }, { 0.3040, 0.0000, 0.9536 }}, // 45° [255,0,190] + {{ 0.9270, 0.8727, 0.9685 }, { 0.5426, 0.0000, 0.9570 }}, // 60° [255,0,255] + {{ 0.9404, 0.8727, 0.9687 }, { 0.6753, 0.0000, 0.9590 }}, // 75° [190,0,255] + {{ 0.9491, 0.8727, 0.9687 }, { 0.7638, 0.0000, 0.9601 }}, // 90° [126,0,255] + {{ 0.9539, 0.8727, 0.9689 }, { 0.8115, 0.0000, 0.9609 }}, // 105° [63,0,255] + {{ 0.9997, 0.8998, 0.9997 }, { 0.9997, 0.0000, 0.9997 }}, // 120° [0,0,255] (green) + {{ 0.9553, 0.8727, 0.9672 }, { 0.8264, 0.0000, 0.9452 }}, // 135° [0,63,255] + {{ 0.9555, 0.8727, 0.9621 }, { 0.8266, 0.0000, 0.8937 }}, // 150° [0,126,255] + {{ 0.9555, 0.8727, 0.9524 }, { 0.8273, 0.0000, 0.7964 }}, // 165° [0,190,255] + {{ 0.9555, 0.8727, 0.9375 }, { 0.8285, 0.0000, 0.6469 }}, // 180° [0,255,255] + {{ 0.9557, 0.8727, 0.9091 }, { 0.8301, 0.0000, 0.3648 }}, // 195° [0,255,190] + {{ 0.9606, 0.9037, 0.8727 }, { 0.8782, 0.3091, 0.0000 }}, // 210° [0,255,126] + {{ 0.9677, 0.9514, 0.8727 }, { 0.9486, 0.7856, 0.0000 }}, // 225° [0,255,63] + {{ 0.9997, 0.9997, 0.8998 }, { 0.9997, 0.9997, 0.0000 }}, // 240° [0,255,0] (blue) + {{ 0.9652, 0.9652, 0.8727 }, { 0.9245, 0.9252, 0.0000 }}, // 255° [63,255,0] + {{ 0.9501, 0.9631, 0.8727 }, { 0.7746, 0.9029, 0.0000 }}, // 270° [126,255,0] + {{ 0.9219, 0.9587, 0.8727 }, { 0.4919, 0.8601, 0.0000 }}, // 285° [190,255,0] + {{ 0.8786, 0.9521, 0.8727 }, { 0.0584, 0.7946, 0.0000 }}, // 300° [255,255,0] + {{ 0.8727, 0.9531, 0.9152 }, { 0.0000, 0.8022, 0.4250 }}, // 315° [255,190,0] + {{ 0.8727, 0.9542, 0.9467 }, { 0.0000, 0.8145, 0.7889 }}, // 330° [255,126,0] + {{ 0.8728, 0.9547, 0.9631 }, { 0.0000, 0.8207, 0.9044 }} // 345° [255,63,0] + }}, + // Ring 1, min value RGB component value = 35 + {{ + {{ 0.8727, 0.9499, 0.9660 }, { 0.0000, 0.7714, 0.9337 }}, // 0° [255,35,39] (red) + {{ 0.8727, 0.9255, 0.9665 }, { 0.0000, 0.5268, 0.9365 }}, // 15° [255,35,90] + {{ 0.8727, 0.8793, 0.9662 }, { 0.0000, 0.0664, 0.9345 }}, // 30° [255,35,145] + {{ 0.9079, 0.8727, 0.9672 }, { 0.3515, 0.0000, 0.9455 }}, // 45° [255,35,200] + {{ 0.9270, 0.8727, 0.9680 }, { 0.5429, 0.0000, 0.9519 }}, // 60° [255,35,255] + {{ 0.9386, 0.8727, 0.9680 }, { 0.6593, 0.0000, 0.9534 }}, // 75° [200,35,255] + {{ 0.9470, 0.8727, 0.9682 }, { 0.7433, 0.0000, 0.9546 }}, // 90° [145,35,255] + {{ 0.9524, 0.8727, 0.9682 }, { 0.7961, 0.0000, 0.9552 }}, // 105° [90,35,255] + {{ 0.9547, 0.8727, 0.9682 }, { 0.8212, 0.0000, 0.9555 }}, // 120° [39,35,255] (green) + {{ 0.9547, 0.8727, 0.9655 }, { 0.8215, 0.0000, 0.9284 }}, // 135° [35,90,255] + {{ 0.9550, 0.8727, 0.9600 }, { 0.8217, 0.0000, 0.8712 }}, // 150° [35,145,255] + {{ 0.9550, 0.8727, 0.9506 }, { 0.8225, 0.0000, 0.7788 }}, // 165° [35,200,255] + {{ 0.9551, 0.8727, 0.9375 }, { 0.8235, 0.0000, 0.6469 }}, // 180° [35,255,255] + {{ 0.9547, 0.8727, 0.9150 }, { 0.8212, 0.0000, 0.4226 }}, // 195° [35,255,200] + {{ 0.9560, 0.8828, 0.8727 }, { 0.8322, 0.1003, 0.0000 }}, // 210° [35,255,145] + {{ 0.9639, 0.9357, 0.8727 }, { 0.9104, 0.6289, 0.0000 }}, // 225° [35,255,90] + {{ 0.9675, 0.9610, 0.8727 }, { 0.9475, 0.8803, 0.0000 }}, // 240° [35,255,35] (blue) + {{ 0.9596, 0.9597, 0.8727 }, { 0.8686, 0.8686, 0.0000 }}, // 255° [90,255,35] + {{ 0.9430, 0.9570, 0.8727 }, { 0.7023, 0.8435, 0.0000 }}, // 270° [145,255,35] + {{ 0.9160, 0.9530, 0.8727 }, { 0.4332, 0.8032, 0.0000 }}, // 285° [200,255,35] + {{ 0.8780, 0.9472, 0.8728 }, { 0.0518, 0.7458, 0.0000 }}, // 300° [255,255,35] + {{ 0.8727, 0.9477, 0.9099 }, { 0.0000, 0.7497, 0.3717 }}, // 315° [255,200,35] + {{ 0.8727, 0.9490, 0.9396 }, { 0.0000, 0.7612, 0.6689 }}, // 330° [255,145,35] + {{ 0.8727, 0.9496, 0.9580 }, { 0.0000, 0.7683, 0.8512 }} // 345° [255,90,35] + }}, + // Ring 2, min value RGB component value = 73 + {{ + {{ 0.8727, 0.9352, 0.9609 }, { 0.0000, 0.6244, 0.8822 }}, // 0° [255,73,76] (red) + {{ 0.8727, 0.9035, 0.9616 }, { 0.0000, 0.3068, 0.8888 }}, // 15° [255,73,119] + {{ 0.8847, 0.8727, 0.9629 }, { 0.1189, 0.0000, 0.9004 }}, // 30° [255,73,164] + {{ 0.9121, 0.8727, 0.9650 }, { 0.3936, 0.0000, 0.9237 }}, // 45° [255,73,209] + {{ 0.9270, 0.8727, 0.9665 }, { 0.5431, 0.0000, 0.9367 }}, // 60° [255,73,255] + {{ 0.9370, 0.8727, 0.9665 }, { 0.6428, 0.0000, 0.9377 }}, // 75° [209,73,255] + {{ 0.9445, 0.8727, 0.9665 }, { 0.7182, 0.0000, 0.9386 }}, // 90° [164,73,255] + {{ 0.9499, 0.8727, 0.9667 }, { 0.7722, 0.0000, 0.9391 }}, // 105° [119,73,255] + {{ 0.9534, 0.8727, 0.9667 }, { 0.8066, 0.0000, 0.9396 }}, // 120° [73,73,255] (green) + {{ 0.9534, 0.8727, 0.9631 }, { 0.8068, 0.0000, 0.9024 }}, // 135° [73,119,255] + {{ 0.9536, 0.8727, 0.9570 }, { 0.8073, 0.0000, 0.8435 }}, // 150° [73,164,255] + {{ 0.9536, 0.8727, 0.9487 }, { 0.8081, 0.0000, 0.7602 }}, // 165° [73,209,255] + {{ 0.9536, 0.8727, 0.9375 }, { 0.8088, 0.0000, 0.6474 }}, // 180° [73,255,255] + {{ 0.9526, 0.8727, 0.9201 }, { 0.7990, 0.0000, 0.4736 }}, // 195° [73,255,209] + {{ 0.9509, 0.8727, 0.8866 }, { 0.7804, 0.0000, 0.1383 }}, // 210° [73,255,164] + {{ 0.9557, 0.9109, 0.8727 }, { 0.8291, 0.3813, 0.0000 }}, // 225° [73,255,119] + {{ 0.9606, 0.9455, 0.8727 }, { 0.8798, 0.7268, 0.0000 }}, // 240° [73,255,73] (blue) + {{ 0.9499, 0.9439, 0.8727 }, { 0.7714, 0.7107, 0.0000 }}, // 255° [119,255,73] + {{ 0.9329, 0.9414, 0.8727 }, { 0.6005, 0.6855, 0.0000 }}, // 270° [164,255,73] + {{ 0.9086, 0.9377, 0.8727 }, { 0.5381, 0.6496, 0.0000 }}, // 285° [209,255,73] + {{ 0.8760, 0.9329, 0.8727 }, { 0.0314, 0.6013, 0.0000 }}, // 300° [255,255,73] + {{ 0.8727, 0.9331, 0.9052 }, { 0.0000, 0.6027, 0.3248 }}, // 315° [255,209,73] + {{ 0.8727, 0.9340, 0.9316 }, { 0.0000, 0.6130, 0.5876 }}, // 330° [255,164,73] + {{ 0.8727, 0.9347, 0.9499 }, { 0.0000, 0.6202, 0.7717 }} // 345° [255,119,73] + }}, + // Ring 3, min value RGB component value = 109 + {{ + {{ 0.8727, 0.9114, 0.9526 }, { 0.0000, 0.3850, 0.7983 }}, // 0° [255,109,112] (red) + {{ 0.8727, 0.8788, 0.9541 }, { 0.0000, 0.0615, 0.8125 }}, // 15° [255,109,145] + {{ 0.8979, 0.8727, 0.9587 }, { 0.2526, 0.0000, 0.8586 }}, // 30° [255,109,182] + {{ 0.9160, 0.8727, 0.9619 }, { 0.4311, 0.0000, 0.8906 }}, // 45° [255,109,218] + {{ 0.9272, 0.8727, 0.9639 }, { 0.6254, 0.0000, 0.9111 }}, // 60° [255,109,255] + {{ 0.9352, 0.8727, 0.9639 }, { 0.6254, 0.0000, 0.9111 }}, // 75° [218,109,255] + {{ 0.9419, 0.8727, 0.9639 }, { 0.6909, 0.0000, 0.9116 }}, // 90° [182,109,255] + {{ 0.9472, 0.8727, 0.9639 }, { 0.7438, 0.0000, 0.9119 }}, // 105° [145,109,255] + {{ 0.9509, 0.8727, 0.9640 }, { 0.7814, 0.0000, 0.9121 }}, // 120° [109,109,255] (green) + {{ 0.9509, 0.7827, 0.9599 }, { 0.7819, 0.0000, 0.7813 }}, // 135° [109,145,255] + {{ 0.9509, 0.7827, 0.9541 }, { 0.7824, 0.0000, 0.8135 }}, // 150° [109,182,255] + {{ 0.9511, 0.7827, 0.9467 }, { 0.7829, 0.0000, 0.7409 }}, // 165° [109,218,255] + {{ 0.9512, 0.7827, 0.9376 }, { 0.7837, 0.0000, 0.6484 }}, // 180° [109,255,255] + {{ 0.9494, 0.7827, 0.9245 }, { 0.7661, 0.0000, 0.5188 }}, // 195° [109,255,218] + {{ 0.9465, 0.7827, 0.9035 }, { 0.7373, 0.0000, 0.3073 }}, // 210° [109,255,182] + {{ 0.9435, 0.8810, 0.8727 }, { 0.7077, 0.0083, 0.0000 }}, // 225° [109,255,145] + {{ 0.9491, 0.9190, 0.8727 }, { 0.7633, 0.4624, 0.0000 }}, // 240° [109,255,109] (blue) + {{ 0.9372, 0.9171, 0.8727 }, { 0.6443, 0.4450, 0.0000 }}, // 255° [145,255,109] + {{ 0.9204, 0.9147, 0.8727 }, { 0.4760, 0.4209, 0.0000 }}, // 270° [182,255,109] + {{ 0.8991, 0.9119, 0.8727 }, { 0.2651, 0.3906, 0.0000 }}, // 285° [218,255,109] + {{ 0.8727, 0.9081, 0.8730 }, { 0.0000, 0.3540, 0.0034 }}, // 300° [255,255,109] + {{ 0.8727, 0.9094, 0.9016 }, { 0.0000, 0.3652, 0.2871 }}, // 315° [255,218,109] + {{ 0.8727, 0.9101, 0.9236 }, { 0.0000, 0.3737, 0.5083 }}, // 330° [255,182,109] + {{ 0.8727, 0.9109, 0.9411 }, { 0.0000, 0.3805, 0.6833 }} // 345° [255,145,109] + }}, + // Ring 4, min value RGB component value = 145 + {{ + {{ 0.8727, 0.8783, 0.9416 }, { 0.0000, 0.0566, 0.6879 }}, // 0° [255,145,147] (red) + {{ 0.8916, 0.8727, 0.9484 }, { 0.1869, 0.0000, 0.7575 }}, // 15° [255,145,172] + {{ 0.9081, 0.8727, 0.9539 }, { 0.3550, 0.0000, 0.8110 }}, // 30° [255,145,200] + {{ 0.9191, 0.8727, 0.9572 }, { 0.4646, 0.0000, 0.8460 }}, // 45° [255,145,227] + {{ 0.9272, 0.8727, 0.9600 }, { 0.5444, 0.0000, 0.8712 }}, // 60° [255,145,255] + {{ 0.9336, 0.8727, 0.9600 }, { 0.6074, 0.0000, 0.8713 }}, // 75° [227,145,255] + {{ 0.9387, 0.8727, 0.9600 }, { 0.6603, 0.0000, 0.8712 }}, // 90° [200,145,255] + {{ 0.9435, 0.8727, 0.9600 }, { 0.7071, 0.0000, 0.8712 }}, // 105° [172,145,255] + {{ 0.9472, 0.8727, 0.9600 }, { 0.7442, 0.0000, 0.8713 }}, // 120° [145,145,255] (green) + {{ 0.9472, 0.8727, 0.9557 }, { 0.7446, 0.0000, 0.8311 }}, // 135° [145,172,255] + {{ 0.9472, 0.8727, 0.9506 }, { 0.7451, 0.0000, 0.7798 }}, // 150° [145,200,255] + {{ 0.9472, 0.8727, 0.9447 }, { 0.7456, 0.0000, 0.7208 }}, // 165° [145,227,255] + {{ 0.9475, 0.8727, 0.9377 }, { 0.7463, 0.0000, 0.6498 }}, // 180° [145,255,255] + {{ 0.9450, 0.8727, 0.9286 }, { 0.7228, 0.0000, 0.5588 }}, // 195° [145,255,227] + {{ 0.9419, 0.8727, 0.9160 }, { 0.6904, 0.0000, 0.4319 }}, // 210° [145,255,200] + {{ 0.9367, 0.8727, 0.8960 }, { 0.6393, 0.0000, 0.2328 }}, // 225° [145,255,172] + {{ 0.9316, 0.8793, 0.8727 }, { 0.5890, 0.0666, 0.0000 }}, // 240° [145,255,145] (blue) + {{ 0.9199, 0.8778, 0.8727 }, { 0.4716, 0.0505, 0.0000 }}, // 255° [172,255,145] + {{ 0.9050, 0.8757, 0.8727 }, { 0.3227, 0.0003, 0.0000 }}, // 270° [200,255,145] + {{ 0.8878, 0.8735, 0.8727 }, { 0.1513, 0.0064, 0.0000 }}, // 285° [227,255,145] + {{ 0.8727, 0.8760, 0.8781 }, { 0.0000, 0.0316, 0.0528 }}, // 300° [255,255,145] + {{ 0.8727, 0.8766, 0.8990 }, { 0.0000, 0.0398, 0.2607 }}, // 315° [255,227,145] + {{ 0.8727, 0.8775, 0.9160 }, { 0.0000, 0.0465, 0.4316 }}, // 330° [255,200,145] + {{ 0.8727, 0.8778, 0.9306 }, { 0.0000, 0.0523, 0.5798 }} // 345° [255,172,145] + }}, + // Ring 5, min value RGB component value = 181 + {{ + {{ 0.8980, 0.8727, 0.9391 }, { 0.5784, 0.4409, 0.8030 }}, // 0° [255,181,182] (red) + {{ 0.9079, 0.8727, 0.9445 }, { 0.6330, 0.4409, 0.8327 }}, // 15° [255,181,199] + {{ 0.9162, 0.8727, 0.9486 }, { 0.6774, 0.4409, 0.8556 }}, // 30° [255,181,218] + {{ 0.9221, 0.8727, 0.9519 }, { 0.7104, 0.4409, 0.8725 }}, // 45° [255,181,236] + {{ 0.9272, 0.8727, 0.9545 }, { 0.7382, 0.4409, 0.8866 }}, // 60° [255,181,255] + {{ 0.9316, 0.8727, 0.9545 }, { 0.7618, 0.4409, 0.8863 }}, // 75° [236,181,255] + {{ 0.9355, 0.8727, 0.9544 }, { 0.7827, 0.4409, 0.8861 }}, // 90° [218,181,255] + {{ 0.9391, 0.8727, 0.9542 }, { 0.8025, 0.4409, 0.8859 }}, // 105° [199,181,255] + {{ 0.9421, 0.8727, 0.9542 }, { 0.8191, 0.4409, 0.8859 }}, // 120° [181,181,255] (green) + {{ 0.9421, 0.8727, 0.9509 }, { 0.8193, 0.4409, 0.8676 }}, // 135° [181,199,255] + {{ 0.9421, 0.8727, 0.9470 }, { 0.8195, 0.4409, 0.8458 }}, // 150° [181,218,255] + {{ 0.9424, 0.8727, 0.9429 }, { 0.8197, 0.4409, 0.8230 }}, // 165° [181,236,255] + {{ 0.9424, 0.8727, 0.9380 }, { 0.8202, 0.4409, 0.7961 }}, // 180° [181,255,255] + {{ 0.9399, 0.8727, 0.9321 }, { 0.8071, 0.4409, 0.7651 }}, // 195° [181,255,236] + {{ 0.9370, 0.8727, 0.9252 }, { 0.7912, 0.4409, 0.7274 }}, // 210° [181,255,218] + {{ 0.9331, 0.8727, 0.9160 }, { 0.7697, 0.4409, 0.6767 }}, // 225° [181,255,199] + {{ 0.9282, 0.8727, 0.9045 }, { 0.7431, 0.4409, 0.6135 }}, // 240° [181,255,181] (blue) + {{ 0.9221, 0.8727, 0.9050 }, { 0.7094, 0.4409, 0.6164 }}, // 255° [199,255,181] + {{ 0.9147, 0.8727, 0.9057 }, { 0.6700, 0.4409, 0.6200 }}, // 270° [218,255,181] + {{ 0.9074, 0.8727, 0.9062 }, { 0.6296, 0.4409, 0.6240 }}, // 285° [236,255,181] + {{ 0.8988, 0.8727, 0.9072 }, { 0.5834, 0.4409, 0.6282 }}, // 300° [255,255,181] + {{ 0.8986, 0.8727, 0.9166 }, { 0.5820, 0.4409, 0.6807 }}, // 315° [255,236,181] + {{ 0.8985, 0.8727, 0.9250 }, { 0.5805, 0.4409, 0.7258 }}, // 330° [255,218,181] + {{ 0.8981, 0.8727, 0.9329 }, { 0.5793, 0.4409, 0.7687 }} // 345° [255,199,181] + }}, + // Ring 6, min value RGB component value = 219 + {{ + {{ 0.9167, 0.8727, 0.9389 }, { 0.4399, 0.0000, 0.6606 }}, // 0° [255,219,219] (red) + {{ 0.9199, 0.8727, 0.9414 }, { 0.4711, 0.0000, 0.6850 }}, // 15° [255,219,228] + {{ 0.9226, 0.8727, 0.9432 }, { 0.4992, 0.0000, 0.7068 }}, // 30° [255,219,237] + {{ 0.9252, 0.8727, 0.9452 }, { 0.5241, 0.0000, 0.7262 }}, // 45° [255,219,246] + {{ 0.9275, 0.8727, 0.9472 }, { 0.5465, 0.0000, 0.7437 }}, // 60° [255,219,255] + {{ 0.9294, 0.8727, 0.9470 }, { 0.5675, 0.0000, 0.7432 }}, // 75° [246,219,255] + {{ 0.9316, 0.8727, 0.9470 }, { 0.5879, 0.0000, 0.7427 }}, // 90° [237,219,255] + {{ 0.9335, 0.8727, 0.9470 }, { 0.6074, 0.0000, 0.7424 }}, // 105° [228,219,255] + {{ 0.9352, 0.8727, 0.9470 }, { 0.6259, 0.0000, 0.7419 }}, // 120° [219,219,255] (green) + {{ 0.9352, 0.8727, 0.9450 }, { 0.6262, 0.0000, 0.7217 }}, // 135° [219,228,255] + {{ 0.9355, 0.8727, 0.9429 }, { 0.6264, 0.0000, 0.7002 }}, // 150° [219,237,255] + {{ 0.9355, 0.8727, 0.9406 }, { 0.6269, 0.0000, 0.6777 }}, // 165° [219,246,255] + {{ 0.9355, 0.8727, 0.9381 }, { 0.6272, 0.0000, 0.6540 }}, // 180° [219,255,255] + {{ 0.9339, 0.8727, 0.9357 }, { 0.6111, 0.0000, 0.6291 }}, // 195° [219,255,246] + {{ 0.9321, 0.8727, 0.9329 }, { 0.5929, 0.0000, 0.6013 }}, // 210° [219,255,237] + {{ 0.9301, 0.8727, 0.9299 }, { 0.5729, 0.0000, 0.5703 }}, // 225° [219,255,228] + {{ 0.9277, 0.8727, 0.9263 }, { 0.5503, 0.0000, 0.5354 }}, // 240° [219,255,219] (blue) + {{ 0.9252, 0.8727, 0.9265 }, { 0.5249, 0.0000, 0.5368 }}, // 255° [228,255,219] + {{ 0.9226, 0.8727, 0.9265 }, { 0.4985, 0.0000, 0.5383 }}, // 270° [237,255,219] + {{ 0.9199, 0.8727, 0.9267 }, { 0.4711, 0.0000, 0.5399 }}, // 285° [246,255,219] + {{ 0.9170, 0.8727, 0.9270 }, { 0.4426, 0.0000, 0.5414 }}, // 300° [255,255,219] + {{ 0.9170, 0.8727, 0.9301 }, { 0.4419, 0.0000, 0.5734 }}, // 315° [255,246,219] + {{ 0.9170, 0.8727, 0.9331 }, { 0.4411, 0.0000, 0.6039 }}, // 330° [255,237,219] + {{ 0.9167, 0.8727, 0.9360 }, { 0.4406, 0.0000, 0.6330 }} // 345° [255,228,219] + }} +}}; +// clang-format on + +/** + * This class can handle the GPIO outputs for the RGB light mode, + * based on RGB color values + brightness. + */ +class ColorHandlerRGB : public ColorHandler { + public: + bool set_light_color_values(light::LightColorValues v) { + light_mode = LIGHT_MODE_RGB; + + if (v.get_color_mode() != light::ColorMode::RGB) { + return false; + } + + // Determine the ring level for the color. This is a value between 0 + // and 7, determining in what ring of the RGB circle the requested + // color resides. + auto rgb_min = std::min(std::min(v.get_red(), v.get_green()), v.get_blue()); + auto level = 7.0f * rgb_min; + + // While the default color circle in Home Assistant presents only a + // subset of colors, it is possible to request colors outside this + // subset as well. Therefore, the ring level might contain a + // fractional value instead of a plain integer. To accomodate for + // this, interpolation will be done to get the final outputs. + + // Determine duty cycle measurements for the outer ring. + auto level_a = floor(level); + set_duty_cycles_(&rgbp_a_, level_a, v.get_red(), v.get_green(), v.get_blue(), v.get_brightness(), &rgb_a_); + + // Determine duty cycle measurements for the inner ring. + set_duty_cycles_(&rgbp_b_, level_a, v.get_red(), v.get_green(), v.get_blue(), v.get_brightness(), &rgb_b_); + + // Almost there! We now have the correct duty cycles for the + // two rings that we were looking at. In this last step, the + // two values are interpolated based on the ring level. + auto d = level - level_a; + red = std::lerp(rgb_a_.red, rgb_b_.red, d); + green = std::lerp(rgb_a_.green, rgb_b_.green, d); + blue = std::lerp(rgb_a_.blue, rgb_b_.blue, d); + + // The white output channel will always be 0 for RGB. + white = 0.0f; + + return true; + } + + RGBPoint rgbp_a_; + RGBPoint rgbp_b_; + RGB rgb_a_; + RGB rgb_b_; + + void set_duty_cycles_(RGBPoint *p, int ring_level, float r, float g, float b, float brightness, RGB *rgb) { + // Ring level 7 = white light center. The duty cycles for this level + // can be computed using a few basic functions. + if (ring_level == 7) { + rgb->red = 0.932101 - 0.383377 * brightness; + rgb->green = 0.883185 - 0.881623 * brightness; + rgb->blue = 0.94188 - 0.284498 * brightness; + return; + } + + // Other ring levels are more complex. Start by retrieving the duty + // cycle measurement data for the ring at hand. + auto ring = rgb_circle_[ring_level]; + + // Because we only have a subset of all colors in the RGB ring + // available in the configuration table, some interpolation will + // have to be done. + // First, compute the position on the ring for the requested RGB + // color. This is basically a hue representation of the requested + // color. It is expressed as a number of degrees around the ring, + // starting with red (at 0°). + auto pos = ring_pos_(r, g, b) / 15.0f; + + // Since there are 24 measurements for each ring, each measurement + // covers 360°/24 = 15°. Using that knowledge, the measurements to + // use for interpolation can be picked from the ring data. + auto pos_x = floor(pos); + auto x = ring[pos_x]; + auto pos_y = ceil(pos); + auto y = ring[pos_y > 23 ? 0 : pos_y]; + + // Interpolate based on the ring position. + auto d = pos - pos_x; + p->low.red = std::lerp(x.low.red, y.low.red, d); + p->low.green = std::lerp(x.low.green, y.low.green, d); + p->low.blue = std::lerp(x.low.blue, y.low.blue, d); + p->high.red = std::lerp(x.high.red, y.high.red, d); + p->high.green = std::lerp(x.high.green, y.high.green, d); + p->high.blue = std::lerp(x.high.blue, y.high.blue, d); + + // Interpolate based on brightness level. + apply_brightness_(p, brightness, rgb); + } + + protected: + /** + * Returns the position on an RGB ring in degrees (0 - 359). + */ + float ring_pos_(float red, float green, float blue) { + auto rgb_min = std::min(std::min(red, green), blue); + auto rgb_max = std::max(std::max(red, green), blue); + auto delta = rgb_max - rgb_min; + float pos; + if (delta == 0.0f) + pos = 0.0f; + else if (red == rgb_max) + pos = 60.0f * fmod((green - blue) / delta, 6); + else if (green == rgb_max) + pos = 60.0f * ((blue - red) / delta + 2.0f); + else + pos = 60.0f * ((red - green) / delta + 4.0f); + if (pos < 0) + pos = pos + 360; + return pos; + } + + /** + * Apply brightness interpolation to the duty cycle measurements. We + * have the low (0.01) and high (1.00) brightness measurements in the + * data. Brightness can be applied by means of linear interpolation. + */ + void apply_brightness_(RGBPoint *p, float brightness, RGB *rgb) { + auto d = brightness - 0.01f; + rgb->red = std::lerp(p->low.red, p->high.red, d); + rgb->green = std::lerp(p->low.green, p->high.green, d); + rgb->blue = std::lerp(p->low.blue, p->high.blue, d); + } +}; + +} // namespace bslamp2 +} // namespace xiaomi +} // namespace esphome diff --git a/components/xiaomi_bslamp2/light/interfaces.h b/components/xiaomi_bslamp2/light/interfaces.h new file mode 100644 index 0000000..1c69a41 --- /dev/null +++ b/components/xiaomi_bslamp2/light/interfaces.h @@ -0,0 +1,40 @@ +#pragma once + +namespace esphome { +namespace xiaomi { +namespace bslamp2 { + +/** + * This is an interface definition that is used to extend the LightState + * class with functionality for disco actions (immediate light updates, + * not publishing or saving the light state). + * + * This interface is required by the DiscoAction class. + */ +class LightStateDiscoSupport { + public: + /** + * Stop the disco, by restoring the previously remembered light state. + */ + virtual void disco_stop() = 0; + + /** + * Do not wait until the next loop() call for the light to write the + * requested state to the light output, but write the new state + * right away. + * + * This allows us to update the state of the light, even when we are + * being called in the middle of another component's loop(). + */ + virtual void disco_apply() = 0; + + /** + * Create a light::LightCall object, with some properties already + * configured for using it as a disco call. + */ + virtual light::LightCall make_disco_call(bool save_and_publish) = 0; +}; + +} // namespace bslamp2 +} // namespace xiaomi +} // namespace esphome diff --git a/components/xiaomi_bslamp2/light/light_output.h b/components/xiaomi_bslamp2/light/light_output.h new file mode 100644 index 0000000..e4c7a36 --- /dev/null +++ b/components/xiaomi_bslamp2/light/light_output.h @@ -0,0 +1,102 @@ +#pragma once + +#include "../common.h" +#include "../light_hal.h" +#include "color_handler_chain.h" +#include "light_transformer.h" +#include "esphome/core/component.h" +#include "esphome/components/ledc/ledc_output.h" + +namespace esphome { +namespace xiaomi { +namespace bslamp2 { + +/** + * A LightOutput class for the Xiaomi Mijia Bedside Lamp 2. + * + * The function of this class is to translate a required light state + * into actual physicial GPIO output signals to drive the device's LED + * circuitry. It forms the glue between the physical device and the + * logical light color input. + */ +class XiaomiBslamp2LightOutput : public Component, public light::LightOutput { + public: + void set_parent(LightHAL *light) { light_ = light; } + + void set_night_light_color_temperature_calibration(float red, float green, float blue) { + night_light_calibration_ = {red, green, blue}; + color_handler_chain.set_night_light_color_temperature_calibration(night_light_calibration_); + apply_current_state(); + } + + /** + * Returns a LightTraits object, which is used to explain to the outside + * world (e.g. Home Assistant) what features are supported by this device. + */ + light::LightTraits get_traits() override { + auto traits = light::LightTraits(); + traits.set_supported_color_modes({light::ColorMode::RGB, light::ColorMode::COLOR_TEMPERATURE}); + traits.set_min_mireds(MIRED_MIN); + traits.set_max_mireds(MIRED_MAX); + return traits; + } + + std::unique_ptr create_default_transition() override { + return make_unique( + light_, + &light_mode_callback_, + &state_callback_, + night_light_calibration_); + } + + void add_on_light_mode_callback(std::function &&callback) { + light_mode_callback_.add(std::move(callback)); + } + + void add_on_state_callback(std::function &&callback) { + state_callback_.add(std::move(callback)); + } + + /** + * Applies a requested light state to the physicial GPIO outputs. + */ + void write_state(light::LightState *state) { + state_ = state; + auto values = state->current_values; + + color_handler_chain.set_light_color_values(values); + light_mode_callback_.call(color_handler_chain.light_mode); + state_callback_.call(values); + + // Note: one might think that it is more logical to turn on the LED + // circuitry master switch after setting the individual channels, + // but this is the order that was used by the original firmware. I + // tried to stay as close as possible to the original behavior, so + // that's why these GPIOs are turned on at this point. + if (values.get_state() != 0) + light_->turn_on(); + + // Apply the GPIO output levels as defined by the color handler. + light_->set_state(&color_handler_chain); + + if (values.get_state() == 0) + light_->turn_off(); + } + + void apply_current_state() { + if (state_ != nullptr) + write_state(state_); + } + + protected: + LightHAL *light_; + light::LightState *state_{nullptr}; + ColorHandlerChain color_handler_chain; + CallbackManager light_mode_callback_{}; + CallbackManager state_callback_{}; + NightLightCalibration night_light_calibration_{DEFAULT_NIGHT_LIGHT_CALIBRATION}; +}; + +} // namespace bslamp2 +} // namespace xiaomi +} // namespace esphome diff --git a/components/xiaomi_bslamp2/light/light_state.h b/components/xiaomi_bslamp2/light/light_state.h new file mode 100644 index 0000000..0cb9870 --- /dev/null +++ b/components/xiaomi_bslamp2/light/light_state.h @@ -0,0 +1,66 @@ +#pragma once + +#include "../common.h" +#include "interfaces.h" +#include "light_output.h" +#include "esphome/components/light/light_state.h" + +namespace esphome { +namespace xiaomi { +namespace bslamp2 { + +/** + * A custom LightState class for the Xiaomi Bedside Lamp 2. + * + * It is used by the DiscoAction to apply immediate light output + * updates, without saving or publishing the new state. + */ +class XiaomiBslamp2LightState : public light::LightState, public LightStateDiscoSupport { + public: + XiaomiBslamp2LightState(XiaomiBslamp2LightOutput *output) : light::LightState(output) { } + + void disco_stop() { + light::LightStateRTCState recovered{}; + if (this->rtc_.load(&recovered)) { + auto call = make_disco_call(true); + call.set_state(recovered.state); + call.set_brightness_if_supported(recovered.brightness); + call.set_color_brightness_if_supported(recovered.color_brightness); + call.set_red_if_supported(recovered.red); + call.set_green_if_supported(recovered.green); + call.set_blue_if_supported(recovered.blue); + call.set_white_if_supported(recovered.white); + call.set_color_temperature_if_supported(recovered.color_temp); + call.set_cold_white_if_supported(recovered.cold_white); + call.set_warm_white_if_supported(recovered.warm_white); + if (recovered.effect != 0) { + call.set_effect(recovered.effect); + } else { + call.set_transition_length_if_supported(0); + } + call.set_color_mode_if_supported(recovered.color_mode); + call.perform(); + } + } + + void disco_apply() { + this->output_->write_state(this); + this->next_write_ = false; + } + + void set_night_light_color_temperature_calibration(float red, float green, float blue) { + auto *output = static_cast(this->output_); + output->set_night_light_color_temperature_calibration(red, green, blue); + } + + light::LightCall make_disco_call(bool save_and_publish) { + auto call = this->make_call(); + call.set_save(save_and_publish); + call.set_publish(save_and_publish); + return call; + } +}; + +} // namespace bslamp2 +} // namespace xiaomi +} // namespace esphome diff --git a/components/xiaomi_bslamp2/light/light_transformer.h b/components/xiaomi_bslamp2/light/light_transformer.h new file mode 100644 index 0000000..3e0c223 --- /dev/null +++ b/components/xiaomi_bslamp2/light/light_transformer.h @@ -0,0 +1,109 @@ +#pragma once + +#include "../common.h" +#include "../light_hal.h" +#include "color_handler_chain.h" +#include "esphome/components/light/light_transformer.h" +#include "esphome/components/light/light_color_values.h" + +namespace esphome { +namespace xiaomi { +namespace bslamp2 { + +/** + * A LightTransitionTransformer class for the Xiaomi Mijia Bedside Lamp 2. + */ +class XiaomiBslamp2LightTransitionTransformer : public light::LightTransitionTransformer { + public: + explicit XiaomiBslamp2LightTransitionTransformer( + LightHAL *light, + CallbackManager *light_mode_callback, + CallbackManager *state_callback, + NightLightCalibration night_light_calibration) : + light_(light), + light_mode_callback_(light_mode_callback), + state_callback_(state_callback), + night_light_calibration_(night_light_calibration) { } + + bool is_finished() override { + return force_finish_ || get_progress_() >= 1.0f; + } + + void start() override { + // Determine the GPIO outputs to use for the start and end point. + // This light transition transformer will then transition linearly between them. + light_->copy_to(&start_); + end_.set_night_light_color_temperature_calibration(night_light_calibration_); + end_.set_light_color_values(target_values_); + + // Update the light mode of the light HAL to the target state, unless + // this is night mode. For night mode, the update is done after the + // state has been reached. This makes sure that forcing instant + // transitions for night light to night light is only done when the + // night light status has actually been reached. E.g. when in RGB mode + // and transitioning to night light in 10 seconds, interrupting this + // after 5 seconds with a new night light setting should not make the + // transition instant. + if (end_.light_mode != LIGHT_MODE_NIGHT) { + light_->set_light_mode(end_.light_mode); + } + + // Run callbacks. These are normally called from the LightOutput, but + // since I don't call LightOutput::write_state() from this transformer's + // code, these callbacks must be called from this transformer instead. + light_mode_callback_->call(end_.light_mode); + state_callback_->call(target_values_); + } + + optional apply() override { + // When transitioning between off and night mode, or between night mode + // light colors, then do this immediately. + // The LED driver circuitry is not capable of doing clean color or brightness + // transitions at the low levels as used for the night light. + if ((end_.light_mode == LIGHT_MODE_NIGHT && + (start_.light_mode == LIGHT_MODE_OFF || start_.light_mode == LIGHT_MODE_NIGHT)) || + (end_.light_mode == LIGHT_MODE_OFF && start_.light_mode == LIGHT_MODE_NIGHT)) { + light_->set_state(&end_); + if (end_.light_mode != LIGHT_MODE_OFF) { + light_->turn_on(); + } + force_finish_ = true; + } + // Otherwise perform a standard transformation. + else { + auto smoothed = light::LightTransitionTransformer::smoothed_progress(get_progress_()); + light_->set_rgbw( + std::lerp(start_.red, end_.red, smoothed), + std::lerp(start_.green, end_.green, smoothed), + std::lerp(start_.blue, end_.blue, smoothed), + std::lerp(start_.white, end_.white, smoothed)); + if (end_.light_mode != LIGHT_MODE_OFF) { + light_->turn_on(); + } + } + + if (is_finished()) { + light_->set_light_mode(end_.light_mode); + if (end_.light_mode == LIGHT_MODE_OFF) { + light_->turn_off(); + } + + return target_values_; + } else { + return {}; + } + } + + protected: + LightHAL *light_; + bool force_finish_{false}; + GPIOOutputValues start_{}; + ColorHandlerChain end_{}; + CallbackManager *light_mode_callback_; + CallbackManager *state_callback_; + NightLightCalibration night_light_calibration_; +}; + +} // namespace bslamp2 +} // namespace xiaomi +} // namespace esphome diff --git a/components/xiaomi_bslamp2/light/presets.h b/components/xiaomi_bslamp2/light/presets.h new file mode 100644 index 0000000..f42c3d4 --- /dev/null +++ b/components/xiaomi_bslamp2/light/presets.h @@ -0,0 +1,190 @@ +#pragma once + +#include "../common.h" +#include "esphome/core/optional.h" + +namespace esphome { +namespace xiaomi { +namespace bslamp2 { + +class Preset : public Component { + public: + std::string group_name; + std::string name; + Preset *next_preset = nullptr; + + explicit Preset(light::LightState *light, std::string group_name, std::string name) + : group_name(group_name), name(name), light_state_(light) {} + + void set_transition_length(uint32_t t) { transition_length_ = t; } + void set_brightness(float t) { brightness_ = t; } + void set_red(float t) { red_ = t; } + void set_green(float t) { green_ = t; } + void set_blue(float t) { blue_ = t; } + void set_color_temperature(float t) { color_temperature_ = t; } + void set_effect(const std::string &effect) { effect_ = effect; } + + void apply() { + ESP_LOGI(TAG, "Activating light preset: %s/%s", group_name.c_str(), name.c_str()); + auto call = light_state_->make_call(); + call.set_state(true); + if (transition_length_.has_value()) + call.set_transition_length(*transition_length_); + if (brightness_.has_value()) + call.set_brightness(*brightness_); + if (red_.has_value()) + call.set_red(*red_); + if (green_.has_value()) + call.set_green(*green_); + if (blue_.has_value()) + call.set_blue(*blue_); + if (color_temperature_.has_value()) + call.set_color_temperature(*color_temperature_); + if (effect_.has_value()) + call.set_effect(*effect_); + call.perform(); + } + + protected: + light::LightState *light_state_; + optional transition_length_; + optional brightness_; + optional red_; + optional green_; + optional blue_; + optional color_temperature_; + optional effect_; +}; + +class PresetGroup { + public: + std::string name; + PresetGroup *next_group = nullptr; + Preset *first_preset = nullptr; + Preset *last_preset = nullptr; + Preset *active_preset = nullptr; + + explicit PresetGroup(std::string g_name) : name(g_name) {} + + void add_preset(Preset *p) { + if (first_preset == nullptr) { + first_preset = last_preset = active_preset = p; + } else { + last_preset->next_preset = p; + last_preset = p; + } + } + + Preset *get_preset(std::string p_name) { + for (auto p = first_preset; p != nullptr; p = p->next_preset) + if (p->name == p_name) + return p; + return nullptr; + } +}; + +class PresetsContainer : public Component { + public: + PresetGroup *first_group = nullptr; + PresetGroup *last_group = nullptr; + PresetGroup *active_group = nullptr; + + void dump_config() { + if (first_group != nullptr) { + ESP_LOGCONFIG(TAG, "Light Presets:"); + for (auto g = first_group; g != nullptr; g = g->next_group) { + ESP_LOGCONFIG(TAG, " Preset group: %s", g->name.c_str()); + for (auto p = g->first_preset; p != nullptr; p = p->next_preset) + ESP_LOGCONFIG(TAG, " Preset: %s", p->name.c_str()); + } + } + } + + void add_preset(Preset *preset) { + auto g = make_preset_group_(preset->group_name); + g->add_preset(preset); + } + + PresetGroup *get_group(std::string g_name) { + for (auto g = first_group; g != nullptr; g = g->next_group) + if (g->name == g_name) + return g; + return nullptr; + } + + void activate_next_group() { + if (active_group == nullptr) { + ESP_LOGW(TAG, "activate_next_group(): no preset groups defined"); + return; + } + active_group = active_group->next_group == nullptr ? first_group : active_group->next_group; + if (active_group->active_preset == nullptr) { + ESP_LOGW(TAG, "activate_next_group(): no presets defined for group %s", active_group->name.c_str()); + return; + } + active_group->active_preset->apply(); + } + + void activate_next_preset() { + if (active_group == nullptr) { + ESP_LOGW(TAG, "activate_next_preset(): no preset groups defined"); + return; + } + auto p = active_group->active_preset; + if (p == nullptr) { + ESP_LOGW(TAG, "activate_next_preset(): no presets defined for group %s", active_group->name.c_str()); + return; + } + active_group->active_preset = p->next_preset == nullptr ? active_group->first_preset : p->next_preset; + active_group->active_preset->apply(); + } + + void activate_group(std::string g_name) { + auto g = get_group(g_name); + if (g == nullptr) { + ESP_LOGE(TAG, "activate_group(%s): preset group does not exist", g_name.c_str()); + return; + } + auto p = g->active_preset; + if (p == nullptr) { + ESP_LOGW(TAG, "activate_group(%s): no presets defined for group", g_name.c_str()); + return; + } + p->apply(); + } + + void activate_preset(std::string g_name, std::string p_name) { + auto g = get_group(g_name); + if (g == nullptr) { + ESP_LOGE(TAG, "activate_preset(%s, %s): preset group '%s' does not exist", g_name.c_str(), p_name.c_str(), + g_name.c_str()); + return; + } + auto p = g->get_preset(p_name); + if (p == nullptr) { + ESP_LOGE(TAG, "activate_preset(%s, %s): preset '%s' does not exist in group '%s'", g_name.c_str(), p_name.c_str(), + p_name.c_str(), g->name.c_str()); + return; + } + p->apply(); + } + + protected: + PresetGroup *make_preset_group_(std::string g_name) { + auto g = get_group(g_name); + if (g == nullptr) { + g = new PresetGroup(g_name); + if (first_group == nullptr) { + first_group = last_group = active_group = g; + } else { + last_group->next_group = g; + last_group = g; + } + } + return g; + } +}; + +} // namespace bslamp2 +} // namespace xiaomi +} // namespace esphome diff --git a/components/xiaomi_bslamp2/light_hal.h b/components/xiaomi_bslamp2/light_hal.h new file mode 100644 index 0000000..1d7eb2a --- /dev/null +++ b/components/xiaomi_bslamp2/light_hal.h @@ -0,0 +1,109 @@ +#pragma once + +#include "esphome/components/gpio/output/gpio_binary_output.h" +#include "esphome/components/ledc/ledc_output.h" +#include "esphome/core/component.h" + +namespace esphome { +namespace xiaomi { +namespace bslamp2 { + +static const std::string LIGHT_MODE_UNKNOWN{"unknown"}; +static const std::string LIGHT_MODE_OFF{"off"}; +static const std::string LIGHT_MODE_RGB{"rgb"}; +static const std::string LIGHT_MODE_WHITE{"white"}; +static const std::string LIGHT_MODE_NIGHT{"night"}; + +class GPIOOutputValues { + public: + float red = 0.0f; + float green = 0.0f; + float blue = 0.0f; + float white = 0.0f; + std::string light_mode = LIGHT_MODE_OFF; + + /** + * Copies the current output values to another GPIOOutputValues object. + */ + void copy_to(GPIOOutputValues *other) { + other->red = red; + other->green = green; + other->blue = blue; + other->white = white; + other->light_mode = light_mode; + } + + void log(const char *prefix) { ESP_LOGD(TAG, "%s: RGB=[%f,%f,%f], white=%f", prefix, red, green, blue, white); } +}; + +class LightHAL : public Component, public GPIOOutputValues { + public: + void set_red_pin(ledc::LEDCOutput *pin) { red_pin_ = pin; } + void set_green_pin(ledc::LEDCOutput *pin) { green_pin_ = pin; } + void set_blue_pin(ledc::LEDCOutput *pin) { blue_pin_ = pin; } + void set_white_pin(ledc::LEDCOutput *pin) { white_pin_ = pin; } + void set_master1_pin(gpio::GPIOBinaryOutput *pin) { master1_pin_ = pin; } + void set_master2_pin(gpio::GPIOBinaryOutput *pin) { master2_pin_ = pin; } + + /** + * Turn on the master switch for the LEDs. + */ + void turn_on() { + master1_pin_->turn_on(); + master2_pin_->turn_on(); + is_on_ = true; + } + + /** + * Turn off the master switch for the LEDs. + */ + void turn_off() { + master1_pin_->turn_off(); + master2_pin_->turn_off(); + is_on_ = false; + } + + /** + * Check if the light is turned on. + */ + bool is_on() { + return is_on_; + } + + void set_state(GPIOOutputValues *new_state) { + new_state->copy_to(this); + red_pin_->set_level(this->red); + green_pin_->set_level(this->green); + blue_pin_->set_level(this->blue); + white_pin_->set_level(this->white); + } + + void set_rgbw(float r, float g, float b, float w) { + red_pin_->set_level(r); + green_pin_->set_level(g); + blue_pin_->set_level(b); + white_pin_->set_level(w); + + this->red = r; + this->green = g; + this->blue = b; + this->white = w; + } + + void set_light_mode(std::string light_mode) { + this->light_mode = light_mode; + } + + protected: + bool is_on_{false}; + ledc::LEDCOutput *red_pin_; + ledc::LEDCOutput *green_pin_; + ledc::LEDCOutput *blue_pin_; + ledc::LEDCOutput *white_pin_; + gpio::GPIOBinaryOutput *master1_pin_; + gpio::GPIOBinaryOutput *master2_pin_; +}; + +} // namespace bslamp2 +} // namespace xiaomi +} // namespace esphome diff --git a/components/xiaomi_bslamp2/output/__init__.py b/components/xiaomi_bslamp2/output/__init__.py new file mode 100644 index 0000000..6d4bf2b --- /dev/null +++ b/components/xiaomi_bslamp2/output/__init__.py @@ -0,0 +1,158 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import output +from esphome.const import CONF_ID, CONF_LEVEL +from esphome import automation +from .. import bslamp2_ns, CODEOWNERS, CONF_FRONT_PANEL_HAL_ID, FrontPanelHAL, FRONT_PANEL_LED_OPTIONS + +__all__ = ["CODEOWNERS"] + +CONF_LEDS = "leds" + +DEPENDENCIES = ["xiaomi_bslamp2"] + +XiaomiBslamp2FrontPanelOutput = bslamp2_ns.class_("XiaomiBslamp2FrontPanelOutput", output.FloatOutput, cg.Component) +SetLEDsAction = bslamp2_ns.class_("SetLEDsAction", automation.Action) +SetLevelAction = bslamp2_ns.class_("SetLevelAction", automation.Action) +UpdateLEDsAction = bslamp2_ns.class_("UpdateLEDsAction", automation.Action) + +CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(XiaomiBslamp2FrontPanelOutput), + cv.GenerateID(CONF_FRONT_PANEL_HAL_ID): cv.use_id(FrontPanelHAL), + } +).extend(cv.COMPONENT_SCHEMA) + + +def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + yield cg.register_component(var, config) + yield output.register_output(var, config) + + front_panel_hal_var = yield cg.get_variable(config[CONF_FRONT_PANEL_HAL_ID]) + cg.add(var.set_parent(front_panel_hal_var)) + + +def maybe_simple_level_value(schema): + def validator(value): + if isinstance(value, dict): + return schema(value) + return schema({"level": value}) + + return validator + + +def maybe_simple_leds_value(schema): + def validator(value): + if isinstance(value, dict): + return schema(value) + return schema({"leds": value}) + + return validator + + +FRONT_PANEL_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_ID): cv.use_id(XiaomiBslamp2FrontPanelOutput), + } +) + +FRONT_PANEL_LEVEL_SCHEMA = cv.Schema( + maybe_simple_level_value( + FRONT_PANEL_SCHEMA.extend( + { + cv.Required(CONF_LEVEL): cv.templatable(cv.percentage), + } + ) + ) +) + +FRONT_PANEL_LED_SCHEMA = cv.Schema( + maybe_simple_leds_value( + FRONT_PANEL_SCHEMA.extend( + { + cv.Required(CONF_LEDS): cv.ensure_list(cv.enum(FRONT_PANEL_LED_OPTIONS, upper=True)), + } + ) + ) +) + + +@automation.register_action( + "front_panel.set_level", + SetLevelAction, + FRONT_PANEL_LEVEL_SCHEMA, + synchronous=True, +) +async def set_level_to_code(config, action_id, template_arg, args): + output_var = await cg.get_variable(config[CONF_ID]) + action_var = cg.new_Pvariable(action_id, template_arg, output_var) + template_ = await cg.templatable(config[CONF_LEVEL], args, float) + cg.add(action_var.set_level(template_)) + return action_var + + +@automation.register_action( + "front_panel.set_leds", + SetLEDsAction, + FRONT_PANEL_LED_SCHEMA, + synchronous=True, +) +async def set_leds_to_code(config, action_id, template_arg, args): + output_var = await cg.get_variable(config[CONF_ID]) + action_var = cg.new_Pvariable(action_id, template_arg, output_var) + bits = [FRONT_PANEL_LED_OPTIONS["NONE"]] + [FRONT_PANEL_LED_OPTIONS[led] for led in config[CONF_LEDS]] + value = cg.RawExpression("|".join(map(str, bits))) + mode_template = await cg.templatable(2, args, cg.int_) + cg.add(action_var.set_mode(mode_template)) + leds_template = await cg.templatable(value, args, cg.uint16) + cg.add(action_var.set_leds(leds_template)) + return action_var + + +@automation.register_action( + "front_panel.turn_on_leds", + SetLEDsAction, + FRONT_PANEL_LED_SCHEMA, + synchronous=True, +) +async def turn_on_leds_to_code(config, action_id, template_arg, args): + output_var = await cg.get_variable(config[CONF_ID]) + action_var = cg.new_Pvariable(action_id, template_arg, output_var) + bits = [FRONT_PANEL_LED_OPTIONS["NONE"]] + [FRONT_PANEL_LED_OPTIONS[led] for led in config[CONF_LEDS]] + value = cg.RawExpression("|".join(map(str, bits))) + mode_template = await cg.templatable(1, args, cg.int_) + cg.add(action_var.set_mode(mode_template)) + leds_template = await cg.templatable(value, args, cg.uint16) + cg.add(action_var.set_leds(leds_template)) + return action_var + + +@automation.register_action( + "front_panel.turn_off_leds", + SetLEDsAction, + FRONT_PANEL_LED_SCHEMA, + synchronous=True, +) +async def turn_off_leds_to_code(config, action_id, template_arg, args): + output_var = await cg.get_variable(config[CONF_ID]) + action_var = cg.new_Pvariable(action_id, template_arg, output_var) + bits = [FRONT_PANEL_LED_OPTIONS["NONE"]] + [FRONT_PANEL_LED_OPTIONS[led] for led in config[CONF_LEDS]] + value = cg.RawExpression("|".join(map(str, bits))) + mode_template = await cg.templatable(0, args, cg.int_) + cg.add(action_var.set_mode(mode_template)) + leds_template = await cg.templatable(value, args, cg.uint16) + cg.add(action_var.set_leds(leds_template)) + return action_var + + +@automation.register_action( + "front_panel.update_leds", + UpdateLEDsAction, + FRONT_PANEL_SCHEMA, + synchronous=True, +) +async def update_leds_to_code(config, action_id, template_arg, args): + output_var = await cg.get_variable(config[CONF_ID]) + action_var = cg.new_Pvariable(action_id, template_arg, output_var) + return action_var diff --git a/components/xiaomi_bslamp2/output/automation.h b/components/xiaomi_bslamp2/output/automation.h new file mode 100644 index 0000000..a002bfc --- /dev/null +++ b/components/xiaomi_bslamp2/output/automation.h @@ -0,0 +1,69 @@ +#pragma once + +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "../front_panel_hal.h" +#include "output.h" +#include + +namespace esphome { +namespace xiaomi { +namespace bslamp2 { + +template class SetLEDsAction : public Action { + public: + explicit SetLEDsAction(XiaomiBslamp2FrontPanelOutput *parent) : parent_(parent) {} + + TEMPLATABLE_VALUE(int, mode) + TEMPLATABLE_VALUE(uint16_t, leds) + + void play(const Ts &...x) override { + uint16_t mode = this->mode_.value(x...); + uint16_t value = this->leds_.value(x...); + switch (mode) { + case 0: + parent_->turn_off_leds(value); + break; + case 1: + parent_->turn_on_leds(value); + break; + case 2: + parent_->set_leds(value); + break; + } + } + + protected: + XiaomiBslamp2FrontPanelOutput *parent_; +}; + +template class SetLevelAction : public Action { + public: + explicit SetLevelAction(XiaomiBslamp2FrontPanelOutput *parent) : parent_(parent) {} + + TEMPLATABLE_VALUE(float, level) + + void play(const Ts &...x) override { + parent_->set_level(this->level_.value(x...)); + parent_->update_leds(); + } + + protected: + XiaomiBslamp2FrontPanelOutput *parent_; +}; + +template class UpdateLEDsAction : public Action { + public: + explicit UpdateLEDsAction(XiaomiBslamp2FrontPanelOutput *parent) : parent_(parent) {} + + void play(const Ts &...x) override { + parent_->update_leds(); + } + + protected: + XiaomiBslamp2FrontPanelOutput *parent_; +}; + +} // namespace bslamp2 +} // namespace xiaomi +} // namespace esphome diff --git a/components/xiaomi_bslamp2/output/output.h b/components/xiaomi_bslamp2/output/output.h new file mode 100644 index 0000000..e51f0b1 --- /dev/null +++ b/components/xiaomi_bslamp2/output/output.h @@ -0,0 +1,57 @@ +#pragma once + +#include "../common.h" +#include "../front_panel_hal.h" +#include "esphome/components/output/float_output.h" +#include + +namespace esphome { +namespace xiaomi { +namespace bslamp2 { + +/** + * An output, used for controlling the front panel illumination and + * level indicator on the Xiaomi Mijia Bedside Lamp 2 front panel. + */ +class XiaomiBslamp2FrontPanelOutput : public output::FloatOutput, public Component { + public: + void set_parent(FrontPanelHAL *front_panel) { + front_panel_ = front_panel; + } + + void write_state(float level) { + if (level > 0) { + turn_on_leds(LED_POWER | LED_COLOR); + set_level(level); + } else { + turn_off_leds(LED_ALL); + } + } + + void set_level(float level) { + front_panel_->set_slider_level(level); + } + + void set_leds(uint16_t leds) { + front_panel_->set_leds(leds); + } + + void turn_on_leds(uint16_t leds) { + front_panel_->turn_on_leds(leds); + } + + void turn_off_leds(uint16_t leds) { + front_panel_->turn_off_leds(leds); + } + + void update_leds() { + front_panel_->update_leds(); + } + + protected: + FrontPanelHAL *front_panel_; +}; + +} // namespace bslamp2 +} // namespace xiaomi +} // namespace esphome diff --git a/components/xiaomi_bslamp2/sensor/__init__.py b/components/xiaomi_bslamp2/sensor/__init__.py new file mode 100644 index 0000000..5c50f8f --- /dev/null +++ b/components/xiaomi_bslamp2/sensor/__init__.py @@ -0,0 +1,36 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import sensor +from esphome.const import CONF_ID, CONF_FORCE_UPDATE, CONF_RANGE_FROM, CONF_RANGE_TO +from .. import bslamp2_ns, CODEOWNERS, CONF_FRONT_PANEL_HAL_ID, FrontPanelHAL + +__all__ = ["CODEOWNERS"] + +DEPENDENCIES = ["xiaomi_bslamp2"] + +XiaomiBslamp2SliderSensor = bslamp2_ns.class_("XiaomiBslamp2SliderSensor", sensor.Sensor, cg.Component) + +CONFIG_SCHEMA = ( + sensor.sensor_schema() + .extend( + { + cv.GenerateID(): cv.declare_id(XiaomiBslamp2SliderSensor), + cv.GenerateID(CONF_FRONT_PANEL_HAL_ID): cv.use_id(FrontPanelHAL), + cv.Optional(CONF_FORCE_UPDATE, default=True): cv.boolean, + cv.Optional(CONF_RANGE_FROM, default=0.01): cv.float_, + cv.Optional(CONF_RANGE_TO, default=1.00): cv.float_, + } + ) + .extend(cv.COMPONENT_SCHEMA) +) + + +def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + yield cg.register_component(var, config) + yield sensor.register_sensor(var, config) + + front_panel_hal_var = yield cg.get_variable(config[CONF_FRONT_PANEL_HAL_ID]) + cg.add(var.set_parent(front_panel_hal_var)) + cg.add(var.set_range_from(config[CONF_RANGE_FROM])) + cg.add(var.set_range_to(config[CONF_RANGE_TO])) diff --git a/components/xiaomi_bslamp2/sensor/slider_sensor.h b/components/xiaomi_bslamp2/sensor/slider_sensor.h new file mode 100644 index 0000000..1452b2f --- /dev/null +++ b/components/xiaomi_bslamp2/sensor/slider_sensor.h @@ -0,0 +1,71 @@ +#pragma once + +#include "../common.h" +#include "../front_panel_hal.h" +#include "esphome/components/sensor/sensor.h" +#include +#include + +namespace esphome { +namespace xiaomi { +namespace bslamp2 { + +/** + * A sensor for the touch slider on the front panel of the + * Xiaomi Mijia Bedside Lamp 2. + * + * This sensor publishes the level at which the slider was touched, so it + * can be used to implement automations. Note that it does not represent + * the brightness of the LED lights (this is implemented by the light output + * component), nor the level as displayed by the slider using the front + * panel illumination (this is implemented by the slider output component). + */ +class XiaomiBslamp2SliderSensor : public sensor::Sensor, public Component { + public: + void set_parent(FrontPanelHAL *front_panel) { front_panel_ = front_panel; } + void set_range_from(float from) { range_from_ = from; } + void set_range_to(float to) { range_to_ = to; } + + void setup() { + slope_ = (range_to_ - range_from_) / 19.0f; + + front_panel_->add_on_event_callback([this](EVENT ev) { + if ((ev & FLAG_PART_MASK) == FLAG_PART_SLIDER) { + float level = (ev & FLAG_LEVEL_MASK) >> FLAG_LEVEL_SHIFT; + + // Slider level 1 is really hard to touch. It is between + // the power button and the slider space, so it doesn't + // look like this one was ever meant to be used, or that + // the design was faulty on this. Therefore, level 1 is + // ignored. The resulting range of levels is 0-19. + float corrected_level = std::max(0.0f, level - 2.0f); + + float final_level = range_from_ + (slope_ * corrected_level); + + // Accomodate for rounding errors that might push the result + // value just past the "range to" value. + if (final_level > range_to_) { + final_level = range_to_; + } + + this->publish_state(final_level); + } + }); + } + + void dump_config() { + ESP_LOGCONFIG(TAG, "Front panel slider sensor:"); + ESP_LOGCONFIG(TAG, " Range from: %f", range_from_); + ESP_LOGCONFIG(TAG, " Range to: %f", range_to_); + } + + protected: + FrontPanelHAL *front_panel_; + float range_from_; + float range_to_; + float slope_; +}; + +} // namespace bslamp2 +} // namespace xiaomi +} // namespace esphome diff --git a/components/xiaomi_bslamp2/text_sensor/__init__.py b/components/xiaomi_bslamp2/text_sensor/__init__.py new file mode 100644 index 0000000..2fd9cb3 --- /dev/null +++ b/components/xiaomi_bslamp2/text_sensor/__init__.py @@ -0,0 +1,36 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import text_sensor +from esphome.const import CONF_ID, CONF_OUTPUT_ID +from .. import bslamp2_ns, CODEOWNERS +from ..light import XiaomiBslamp2LightOutput + +__all__ = ["CODEOWNERS"] + +DEPENDENCIES = ["xiaomi_bslamp2"] + +CONF_LIGHT_ID = "light_id" + +XiaomiBslamp2LightModeTextSensor = bslamp2_ns.class_( + "XiaomiBslamp2LightModeTextSensor", text_sensor.TextSensor, cg.Component +) + +CONFIG_SCHEMA = ( + text_sensor.text_sensor_schema() + .extend( + { + cv.GenerateID(): cv.declare_id(XiaomiBslamp2LightModeTextSensor), + cv.GenerateID(CONF_OUTPUT_ID): cv.use_id(XiaomiBslamp2LightOutput), + } + ) + .extend(cv.COMPONENT_SCHEMA) +) + + +def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + yield cg.register_component(var, config) + yield text_sensor.register_text_sensor(var, config) + + parent_var = yield cg.get_variable(config[CONF_OUTPUT_ID]) + cg.add(var.set_parent(parent_var)) diff --git a/components/xiaomi_bslamp2/text_sensor/light_mode_text_sensor.h b/components/xiaomi_bslamp2/text_sensor/light_mode_text_sensor.h new file mode 100644 index 0000000..45e9de5 --- /dev/null +++ b/components/xiaomi_bslamp2/text_sensor/light_mode_text_sensor.h @@ -0,0 +1,35 @@ +#pragma once + +#include "esphome/components/text_sensor/text_sensor.h" + +namespace esphome { +namespace xiaomi { +namespace bslamp2 { + +/** + * A text sensor, used for propagating the active light mode on the + * Xiaomi Mijia Bedside Lamp 2. + * + * The possible light modes are "off", "rgb", "white" and "night". + */ +class XiaomiBslamp2LightModeTextSensor : public text_sensor::TextSensor, public Component { + public: + void set_parent(XiaomiBslamp2LightOutput *light) { light_ = light; } + + void setup() { + light_->add_on_light_mode_callback([this](std::string light_mode) { + if (last_light_mode_ != light_mode) { + publish_state(light_mode); + last_light_mode_ = light_mode; + } + }); + } + + protected: + XiaomiBslamp2LightOutput *light_; + std::string last_light_mode_ = LIGHT_MODE_UNKNOWN; +}; + +} // namespace bslamp2 +} // namespace xiaomi +} // namespace esphome diff --git a/services/lampBehaviour.yaml b/services/lampBehaviour.yaml new file mode 100644 index 0000000..149ee5b --- /dev/null +++ b/services/lampBehaviour.yaml @@ -0,0 +1,172 @@ +# ------------------------------------------------------------------------ +# Behavior: default +# +# * Presets that resemble the original Xiaomi firmware +# (one group of RGB colors, one group of white light colors) +# * Tapping the power button, toggles the light +# * Holding the power button, turns on night light mode +# * Tapping the color button, activates the next preset +# * Holding the color button, activates the next preset group +# * Touching the slider sets the brightness level and turns on the lamp +# * When the light is on, the front panel illumination is on +# * When the night light is on, only the power button is illuminated +# ------------------------------------------------------------------------ + +# A global variable to keep track of the brightness level. This one is +# used by the update_front_panel script to represent the brightness level +# using the front panel illumination. +# +# This variable is used instead of querying the light component directly, +# because that brightness level may vary during a light transition. The +# behavior that I want: when you touch the front panel slider at 75%, then +# the illumination rises directly to that level, while the light might do +# a smooth transition. +globals: + - id: current_brightness + type: float + initial_value: "0.0" + +# This script updates the front panel illumination. +script: + - id: update_front_panel + mode: restart + then: + - front_panel.turn_off_leds: ALL + # In rgb or white light mode, turn on the front panel illumination, + # and show the current brightness level. + - if: + condition: + or: + - text_sensor.state: + id: my_light_mode + state: rgb + - text_sensor.state: + id: my_light_mode + state: white + then: + - front_panel.turn_on_leds: [ POWER, COLOR ] + - front_panel.set_level: !lambda return id(current_brightness); + # In night light mode, turn off the front panel illumination, except + # for the power button. + - if: + condition: + - text_sensor.state: + id: my_light_mode + state: night + then: + - front_panel.turn_on_leds: [ POWER ] + - front_panel.update_leds: + +# This component controls the LED lights of the lamp. +light: + - platform: xiaomi_bslamp2 + id: my_light + name: ${light_name} + default_transition_length: ${default_transition_length} + night_calibration: + red: ${night_light_red} + green: ${night_light_green} + blue: ${night_light_blue} + + # When the brightness changes, update the front panel illumination. + on_brightness: + then: + - globals.set: + id: current_brightness + value: !lambda return x; + - script.execute: update_front_panel + + # You can use any effects that you like. These are just examples. + effects: + - random: + name: "Slow Random" + transition_length: 30s + update_interval: 30s + - random: + name: "Fast Random" + transition_length: 3s + update_interval: 3s + + # You can define one or more groups of presets. These presets can + # be activated using various "preset.activate" action options. + # The presets can for example be used to mimic the behavior of the + # original firmware (tapping the color button = go to next preset, + # holding the color button = switch between RGB and white light mode). + # These bindings have been setup below, using the binary_sensor for + # the color button. + presets: + rgb: + red: { red: 100%, green: 0%, blue: 0% } + green: { red: 0%, green: 100%, blue: 0% } + blue: { red: 0%, green: 0%, blue: 100% } + yellow: { red: 100%, green: 100%, blue: 0% } + purple: { red: 100%, green: 0%, blue: 100% } + randomize: { effect: Fast Random } + white: + cold: { color_temperature: 153 mireds } + chilly: { color_temperature: 275 mireds } + luke: { color_temperature: 400 mireds } + warm: { color_temperature: 588 mireds } + +# Binary sensors can be created for handling front panel touch / release +# events. To specify what part of the front panel to look at, the "for" +# parameter can be set to: "POWER_BUTTON", "COLOR_BUTTON" or "SLIDER". +binary_sensor: + # When tapping the power button, toggle the light. + # When holding the power button, turn on night light mode. + - platform: xiaomi_bslamp2 + id: my_power_button + for: POWER_BUTTON + on_multi_click: + - timing: + - ON for at most 0.8s + then: + - light.toggle: my_light + - timing: + - ON for at least 0.8s + then: + - light.turn_on: + id: my_light + brightness: 1% + + # When tapping the color button, activate the next preset. + # When holding the color button, activate the next preset group. + - platform: xiaomi_bslamp2 + id: my_color_button + for: COLOR_BUTTON + on_multi_click: + - timing: + - ON for at most 0.6s + then: + - preset.activate: + next: preset + - timing: + - ON for at least 0.6s + then: + - preset.activate: + next: group + +# This sensor component publishes touch events for the front panel slider. +# The published value represents the level at which the slider was touched. +# By default, values range from 0.01 to 1.00 (in 20 steps). This range can +# be modified using the "range_from" and "range_to" parameters. +sensor: + # When the slider is touched, update the brightness. + # Brightness 0.01 initiates the light night mode, which has already + # been handled above (by holding the power button). Therefore, brightness + # starts from 0.02 here, to not trigger night mode using the slider. + - platform: xiaomi_bslamp2 + id: my_slider_level + range_from: 0.02 + on_value: + then: + - light.turn_on: + id: my_light + brightness: !lambda return x; + +# This text sensor propagates the currently active light mode. +# The possible light modes are: "off", "rgb", "white" and "night". +text_sensor: + - platform: xiaomi_bslamp2 + name: ${light_mode_text_sensor_name} + id: my_light_mode \ No newline at end of file