diff --git a/components/brmesh/__init__.py b/components/brmesh/__init__.py index ae9493b..eac3d21 100644 --- a/components/brmesh/__init__.py +++ b/components/brmesh/__init__.py @@ -1,5 +1,3 @@ -from .brmesh import CONFIG_SCHEMA, DEPENDENCIES, to_code +#from .brmesh import CONFIG_SCHEMA, DEPENDENCIES, to_code -AUTO_LOAD = [ "light" ] - -__all__ = ["CONFIG_SCHEMA", "DEPENDENCIES", "to_code"] +#__all__ = ["CONFIG_SCHEMA", "DEPENDENCIES", "to_code"] diff --git a/components/brmesh/address.h b/components/brmesh/address.h deleted file mode 100644 index ba3a1b0..0000000 --- a/components/brmesh/address.h +++ /dev/null @@ -1,35 +0,0 @@ -#pragma once -#include -#include -#include - -#include "esphome/core/helpers.h" - -namespace esphome { -namespace brmesh { - -class Address : public std::array { - static constexpr uint8_t reverse(uint8_t x) { - x = ((x & 0xAA) >> 1) | ((x & 0x55) << 1); - x = ((x & 0xCC) >> 2) | ((x & 0x33) << 2); - x = ((x & 0xF0) >> 4) | ((x & 0x0F) << 4); - return x; - } -public: - constexpr Address(const uint8_t a, const uint8_t b, const uint8_t c) - : array{reverse(c), reverse(b), reverse(a)} {} - - template - std::span set(const std::span &dst) const { - return set(std::span(dst)); - } - - std::span set(const std::span &dst) const { - if (dst.size() < size()) - return {}; - std::copy(cbegin(), cend(), &dst[0]); - return dst.subspan(0, size()); - } -}; -} // namespace brmesh -} // namespace esphome diff --git a/components/brmesh/brmesh.py b/components/brmesh/brmesh.py index 0f5e386..0376382 100644 --- a/components/brmesh/brmesh.py +++ b/components/brmesh/brmesh.py @@ -1,88 +1,67 @@ import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_TX_POWER -from esphome.core import HexInt, TimePeriod +from esphome.const import CONF_ID +from esphome.core import HexInt from esphome.components import esp32_ble -DEPENDENCIES = ["esp32_ble"] - -CONF_KEY = "key" -CONF_ADV = "advertise" -CONF_INTERVAL = "interval" -CONF_MIN = "min" -CONF_MAX = "max" - -brmesh_ns = cg.esphome_ns.namespace("brmesh") -Network = brmesh_ns.class_("Network", cg.Component) - -def validate_key_string(value): - value = value.replace(" ", "") - if len(value) != 8: - raise cv.Invalid("Key must be exactly 4 bytes (8 hex characters)") - - try: - return [HexInt(int(value[x:x+2], 16)) for x in range(0, len(value), 2)] - except ValueError as err: - raise cv.Invalid(f"Invalid hex value: {err}") - - -def validate_key(value): - if isinstance(value, str): - value = validate_key_string(value) - - if not isinstance(value, list): - raise cv.Invalid("Key must be a list") - - if len(value) != 4: - raise cv.Invalid("Key must have 4 bytes") - - return [ cv.uint8_t(x) for x in value ] - -INTERVAL_RANGE_SCHEMA = cv.All( - cv.positive_time_period_milliseconds, - cv.Range( - min=TimePeriod(milliseconds=20), - max=TimePeriod(milliseconds=10240) - ), -) - -INTERVAL_SCHEMA = cv.Schema({ - cv.Optional(CONF_MIN, default="20ms"): INTERVAL_RANGE_SCHEMA, - cv.Optional(CONF_MAX, default="40ms"): INTERVAL_RANGE_SCHEMA, -}) - -ADV_SCHEMA = cv.Schema({ - cv.Optional(CONF_INTERVAL, default=INTERVAL_SCHEMA({})): INTERVAL_SCHEMA, - cv.Optional(CONF_TX_POWER, default="3dBm"): cv.All( - cv.decibel, cv.enum(esp32_ble.TX_POWER_LEVELS, int=True) - ), -}) +if False: + DEPENDENCIES = ["esp32_ble"] -CONFIG_SCHEMA = cv.Schema([ - { - cv.GenerateID(): cv.declare_id(Network), + CONF_KEY = "key" + CONF_ADV = "adv" + CONF_INTERVAL = "interval" + CONF_MIN = "min" + CONF_MAX = "max" + CONF_DURATION = "duration" + CONF_GAP = "gap" + + + def validate_key_string(value): + value = value.replace(" ", "") + if len(value) != 8: + raise cv.Invalid("Key must be exactly 4 bytes (8 hex characters)") + + try: + return [HexInt(int(value[x:x+2], 16)) for x in range(0, len(value), 2)] + except ValueError as err: + raise cv.Invalid(f"Invalid hex value: {err}") + + + def validate_key(value): + if isinstance(value, str): + value = validate_key_string(value) + + if not isinstance(value, list): + raise cv.Invalid("Key must be a list") + + if len(value) != 4: + raise cv.Invalid("Key must have 4 bytes") + + return [ cv.uint8_t(x) for x in value ] + + + brmesh_ns = cg.esphome_ns.namespace("brmesh") + Controller = brmesh_ns.class_("Controller", cg.Component, esp32_ble.GAPEventHandler) + + + CONFIG_SCHEMA = cv.COMPONENT_SCHEMA.extend({ + cv.GenerateID(): cv.declare_id(Controller), cv.GenerateID(esp32_ble.CONF_BLE_ID): cv.use_id(esp32_ble.ESP32BLE), cv.Required(CONF_KEY): validate_key, - cv.Optional(CONF_ADV, default=ADV_SCHEMA({})): ADV_SCHEMA, - } -]) + cv.Optional(CONF_ADV): ADV_SCHEMA, + }) -async def to_code(config): - cg.add_define("USE_ESP32_BLE_ADVERTISING") - cg.add_define("USE_ESP32_BLE_UUID") - for config in config: + + async def to_code(config): adv = config[CONF_ADV] interval = adv[CONF_INTERVAL] - + if interval[CONF_MAX] < interval[CONF_MIN]: raise cv.Invalid( f"{CONF_ADV}.{CONF_INTERVAL}.{CONF_MIN} ({interval[CONF_MIN]}) must be <= " f"{CONF_ADV}.{CONF_INTERVLA}.{CONF_MAX} ({interval[CONF_MAX]})" ) ble = await cg.get_variable(config[esp32_ble.CONF_BLE_ID]) - - var = cg.new_Pvariable(config[CONF_ID], config[CONF_KEY], - interval[CONF_MIN], interval[CONF_MAX], - adv[CONF_TX_POWER]); - esp32_ble.register_gap_event_handler(ble, var) + + var = cg.new_Pvariable(config[CONF_ID], ble, config[CONF_KEY], interval[CONF_MIN], interval[CONF_MAX], adv[CONF_DURATION].total_milliseconds, adv[CONF_GAP].total_milliseconds) await cg.register_component(var, config) diff --git a/components/brmesh/controller.cpp b/components/brmesh/controller.cpp new file mode 100644 index 0000000..e62fe98 --- /dev/null +++ b/components/brmesh/controller.cpp @@ -0,0 +1,275 @@ +#if 0 +#include "esphome/core/component_iterator.h" +#include "esphome/core/log.h" +#include "esphome/components/esp32_ble/ble.h" +#include "controller.h" +#include "protocol.h" + +namespace { +const char *const TAG = "brmesh.controller"; + +template +std::enable_if_t<(addr_size == 3 || addr_size == std::dynamic_extent), uint16_t> +crc16(const std::span &addr, const std::span &data, const uint16_t init = 0xFFFF) +{ + constexpr auto step = [](uint16_t result, uint8_t byte)->uint16_t{ + result ^= static_cast(byte) << 8; + for (int j = 0; j < 4; j++) { + uint16_t tmp = result << 1; + if (result & 0x8000) + tmp ^= 0x1021; + result = tmp << 1; + if (tmp & 0x8000) + result ^= 0x1021; + } + return result; + }; + + auto result = init; + + for (const auto &c:addr) + result = step(result, c); + + for (const uint8_t &c:data) + result = step(result, esphome::reverse_bits(c)); + + return ~esphome::reverse_bits(result); +} + +} + + +namespace esphome { +namespace brmesh { +Controller::Controller(esp32_ble::ESP32BLE &ble, + const Key &key, + const uint16_t adv_interval_min, + const uint16_t adv_interval_max, + const uint16_t adv_duration, + const uint16_t adv_gap) + : ble_(ble), key_(key), + adv_interval_min_(adv_interval_min), + adv_interval_max_(adv_interval_max), + adv_duration_(adv_duration), + adv_gap_(adv_duration) +{ +} + +void Controller::setup() +{ + ESP_LOGCONFIG(TAG, "BRmesh Controller:\n" + " key: %02hx:%02hx:%02hx:%02hx\n" + " adv interval: %hd-%hd\n" + " adv duration: %hdms\n" + " adv gap: %hdms", + key_[0], key_[1], key_[2], key_[3], + adv_interval_min_, adv_interval_max_, + adv_duration_, + adv_gap_); + ble_.register_gap_event_handler(this); +} + +void Controller::loop() +{ + auto *light = q_.peek(); + disable_loop(); + if (light == nullptr) + light = q_.peek(); + if (light == nullptr) + return; +} + +void Controller::advertise(Light &light) +{ + esp_ble_adv_params_t adv_params = { + .adv_int_min = adv_interval_min_, + .adv_int_max = adv_interval_max_, + .adv_type = ADV_TYPE_NONCONN_IND, + .own_addr_type = BLE_ADDR_TYPE_PUBLIC, + .peer_addr = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + .peer_addr_type = BLE_ADDR_TYPE_PUBLIC, + .channel_map = ADV_CHNL_ALL, + .adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY, + }; + + std::array adv_data_raw[31]; + uint8_t adv_data_len = 0; + + // Add flags + adv_data_raw[adv_data_len++] = 2; + adv_data_raw[adv_data_len++] = ESP_BLE_AD_TYPE_FLAG; + adv_data_raw[adv_data_len++] = ESP_BLE_ADV_FLAG_BREDR_NOT_SPT | ESP_BLE_ADV_FLAG_GEN_DISC; + + // Add manufacturer data + const auto cmd_data_len = adv_data_len++; + adv_data_raw[adv_data_len++] = ESP_BLE_AD_MANUFACTURER_SPECIFIC_TYPE; + adv_data_raw[adv_data_len++] = MANUFACTURER_DATA_ID & 0xFF; + adv_data_raw[adv_data_len++] = (MANUFACTURER_DATA_ID >> 8) & 0xFF; + + const auto cmd = command(std::span(adv_data_raw).subspan(adv_data_len), light); + adv_data_raw[cmd_data_len] = cmd.size_bytes(); + adv_data_len += cmd.size_bytes(); + + esp_err_t err = esp_ble_gap_config_adv_data_raw(adv_data_raw, adv_data_len); + if (err != ESP_OK) + { + ESP_LOGW(TAG, "Error setting raw advertisement data (err=%d): %s", err, esp_err_to_name(err)); + return; + } + + err = esp_ble_gap_start_advertising(&adv_params); + if (err != ESP_OK) + { + ESP_LOGW(TAG, "Error starting advertisement (err=%d): %s", err, esp_err_to_name(err)); + return; + } + + ESP_LOGD(TAG, "advertise id=%hd", light.id()); +} + +void +Controller::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) +{ + switch (event) { + case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: + ESP_LOD(TAG, "advertise: %hdms\n", adv_duration_); + set_timeout(adv_duration_, esp_ble_gap_stop_advertising); + return; + + case ESP_GAP_BLE_ADV_STOP_COMPLETE_EVT: + ESP_LOGD(TAG, "advertise gap: %hdms\n", adv_gap_); + set_timeout(adv_gap_, [this](){ + q_.dequeue(); + auto *light = q_.peek(); + if (light == nullptr) + advertise(*light); + }); + return; + + default: + return; + } +} + +void +Controller::enqueue(Light &light) +{ + if (!q_.enqueue(light)) + return; + defer([this](){ advertise(*q_.peek()); }); +} + +std::vector Controller::single_control(uint32_t light_id_, const std::vector &light_data) +{ + std::vector result_data(12); + + result_data[0] = 2 | (((0xfffffff & (light_data.size() + 1)) << 4)); + result_data[1] = light_id_; + std::copy(light_data.begin(), light_data.end(), result_data.begin() + 2); + + // Debug output - print payload as hex + auto hex_str = vector_to_hex_string(result_data).data(); + ESP_LOGD(TAG, "Inner Payload (%d bytes): %s", result_data.size(), hex_str); + + return this->generate_command(5, light_id_, result_data, true); +} + +std::vector Controller::generate_command(uint8_t n, uint32_t light_id_, const std::vector &data, bool forward) +{ + uint8_t& sequence = seq_; + + // Create command body with header + std::vector body(data.size() + 4); + uint8_t i2 = (light_id_ / 256); + + // Construct header + body[0] = (i2 & 0b1111) | ((n & 0b111) << 4) | (forward ? 0x80 : 0); + body[1] = sequence++; // Use and increment sequence number + if (sequence >= 255) + sequence = 1; + + body[2] = this->key_[3]; // Safe key + + // Copy data + std::copy(data.begin(), data.end(), body.begin() + 4); + + // Calculate checksum + uint8_t checksum = 0; + for (size_t i = 0; i < body.size(); i++) + { + if (i != 3) + { + checksum = checksum + body[i]; + } + } + body[3] = checksum; + + // Encrypt header and data + for (size_t i = 0; i < 4; i++) + { + body[i] = DEFAULT_ENCRYPT_KEY[i & 3] ^ body[i]; + } + + for (size_t i = 0; i < data.size(); i++) + { + body[4 + i] = this->key_[i & 3] ^ body[4 + i]; + } + + // Prepare the final payload with RF protocol formatting + std::vector addr = {DEFAULT_BLE_ADDRESS.begin(), DEFAULT_BLE_ADDRESS.end()}; + return prepare_payload(addr, body); +} + +std::span Controller::payload(std::span &&span, const Light &light) +{ + const auto header = span.subspan(0, 3); + header[0] = 0x71; + header[1] = 0x0f; + header[2] = 0x55; + + const auto address = span.subspan(3, 3); + std::reverse_copy(address.begin(), address.end(), + DEFAULT_BLE_ADDRESS.cbegin(), + DEFAULT_BLE_ADDRESS.cend()); + + const auto data = command(span.subspan(6, 10), light); + const auto crc = span.subspan(6 + data.size(), 2); + const auto crc16 = ::crc16(address, data); + crc[0] = static_cast(crc16); + crc[1] = static_cast(crc16 >> 8); + + auto result = span.subspan(0, header.size() + address.size() + data.size() + crc.size()); + + // TODO: adjust seed to skip zero byte warmup + Whitening whitening(0x25); + for (size_t i = 0; i< 16; ++i) + (void) whitening.encode(0x00); + + whitening.encode(result); + + return result; +} + +std::span command(std::span &&span, const Light &light) +{ + static constexpr uint8_t n = 5; // TODO + static constexpr bool forward = true; // TODO + + const auto inner = span.subpsna(4, light.command(span.subspan(4, 6))); + + auto header = span.subspan(0, 4); + header[0] = (i2 & 0b1111) | ((n & 0b111) << 4) | (forward ? 0x80 : 0); + header[1] = seq_++; + header[2] = key_[3]; // safe key + header[3] = crc(inner); + + DEFAULT_ENCRYPT_KEY(header); + key_(inner); + + //prepare(DEFAULT_BLE_ADDRESS, span.subspan(4 + inner.size())); + return span.subspan(0, 4 + inner.size()); +} + +} // namespace brmesh +} // namespace esphome +#endif diff --git a/components/brmesh/controller.h b/components/brmesh/controller.h new file mode 100644 index 0000000..159e7b3 --- /dev/null +++ b/components/brmesh/controller.h @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include +#include "esphome/core/component.h" +#include "esphome/components/esp32_ble/ble.h" + +#include "q.h" +#include "key.h" + +namespace esphome { +namespace brmesh { + +class Light; + +class Controller : public Component, public esp32_ble::GAPEventHandler +{ +public: + Controller(esp32_ble::ESP32BLE *ble, const Key &key, uint16_t adv_interval_min, uint16_t adv_interval_max, uint16_t adv_duration, uint16_t adv_gap) + : Controller(*ble, key, adv_interval_min, adv_interval_max, adv_duration, adv_gap) {} + + Controller(esp32_ble::ESP32BLE &ble, const Key &key, uint16_t adv_interval_min, uint16_t adv_interval_max, uint16_t adv_duration, uint16_t adv_gap); + + std::vector single_control(uint32_t addr, const std::vector &light_data); + void enqueue(Light &); + + // interface: Component + void setup() override; + void loop() override; + + // interface: GAPEventHandler + void gap_event_handler(esp_gap_ble_cb_event_t, esp_ble_gap_cb_param_t *); + +protected: + struct Command + { + std::vector data; + uint32_t timestamp; + uint8_t retries{0}; + static constexpr uint8_t MAX_RETRIES = 3; + }; + + enum class AdvertiseState + { + IDLE, + ADVERTISING, + GAP + }; + + AdvertiseState adv_state_{AdvertiseState::IDLE}; + uint32_t state_start_time_{0}; + + // Protocol implementation + std::vector generate_command(uint8_t n, uint32_t light_id_, const std::vector &data, bool forward = true); + + static const uint16_t MANUFACTURER_DATA_ID = 0xfff0; +private: + esp32_ble::ESP32BLE &ble_; + Key key_; + + uint16_t adv_interval_min_; + uint16_t adv_interval_max_; + uint16_t adv_duration_; + uint16_t adv_gap_; + + Q q_ = {}; + uint8_t seq_ = 0; + + void advertise(Light &); + + std::span payload(std::span &span, const Light &light); + + std::span command(std::span &&span, const Light &light); +}; + +} // namespace brmesh +} // namespace esphome diff --git a/components/brmesh/debug.h b/components/brmesh/debug.h new file mode 100644 index 0000000..9e6e4d6 --- /dev/null +++ b/components/brmesh/debug.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome { +namespace brmesh { + +namespace { +template +void hexdump(const char *TAG, const uint8_t *data, size_t size, const char *fmt, types&&...args) { + // TODO: conditional format_hex_pretty + const auto hex = format_hex_pretty(data, size); + ESP_LOGD(TAG, fmt, std::forward(args)..., hex.c_str()); +} + +template +void hexdump(const char *TAG, const void *data, size_t size, const char *fmt, types&&...args) { + hexdump(TAG, static_cast(data), size, fmt, std::forward(args)...); +} + +template +void hexdump(const char *TAG, const std::span &span, const char *fmt, types&&...args) { + hexdump(TAG, static_cast(&span.front()), span.size_bytes(), fmt, std::forward(args)...); +} + +} // namespace +} // namespace brmesh +} // namespace esphome diff --git a/components/brmesh/fastcon/__init__.py b/components/brmesh/fastcon/__init__.py new file mode 100644 index 0000000..9b07c9a --- /dev/null +++ b/components/brmesh/fastcon/__init__.py @@ -0,0 +1,3 @@ +from .fastcon_controller import CONFIG_SCHEMA, DEPENDENCIES, FastconController, to_code + +__all__ = ["CONFIG_SCHEMA", "DEPENDENCIES", "FastconController", "to_code"] diff --git a/components/brmesh/fastcon/fastcon_controller.cpp b/components/brmesh/fastcon/fastcon_controller.cpp new file mode 100644 index 0000000..993c079 --- /dev/null +++ b/components/brmesh/fastcon/fastcon_controller.cpp @@ -0,0 +1,269 @@ +#include "esphome/core/component_iterator.h" +#include "esphome/core/log.h" +#include "esphome/components/light/color_mode.h" +#include "fastcon_controller.h" +#include "protocol.h" + +namespace esphome +{ + namespace fastcon + { + static const char *const TAG = "fastcon.controller"; + + void FastconController::queueCommand(uint32_t light_id_, const std::vector &data) + { + std::lock_guard lock(queue_mutex_); + if (queue_.size() >= max_queue_size_) + { + ESP_LOGW(TAG, "Command queue full (size=%d), dropping command for light %d", + queue_.size(), light_id_); + return; + } + + Command cmd; + cmd.data = data; + cmd.timestamp = millis(); + cmd.retries = 0; + + queue_.push(cmd); + ESP_LOGV(TAG, "Command queued, queue size: %d", queue_.size()); + } + + void FastconController::clear_queue() + { + std::lock_guard lock(queue_mutex_); + std::queue empty; + std::swap(queue_, empty); + } + + void FastconController::setup() + { + ESP_LOGCONFIG(TAG, "Setting up Fastcon BLE Controller..."); + ESP_LOGCONFIG(TAG, " Advertisement interval: %d-%d", this->adv_interval_min_, this->adv_interval_max_); + ESP_LOGCONFIG(TAG, " Advertisement duration: %dms", this->adv_duration_); + ESP_LOGCONFIG(TAG, " Advertisement gap: %dms", this->adv_gap_); + } + + void FastconController::loop() + { + const uint32_t now = millis(); + + switch (adv_state_) + { + case AdvertiseState::IDLE: + { + std::lock_guard lock(queue_mutex_); + if (queue_.empty()) + return; + + Command cmd = queue_.front(); + queue_.pop(); + + esp_ble_adv_params_t adv_params = { + .adv_int_min = adv_interval_min_, + .adv_int_max = adv_interval_max_, + .adv_type = ADV_TYPE_NONCONN_IND, + .own_addr_type = BLE_ADDR_TYPE_PUBLIC, + .peer_addr = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + .peer_addr_type = BLE_ADDR_TYPE_PUBLIC, + .channel_map = ADV_CHNL_ALL, + .adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY, + }; + + uint8_t adv_data_raw[31] = {0}; + uint8_t adv_data_len = 0; + + // Add flags + adv_data_raw[adv_data_len++] = 2; + adv_data_raw[adv_data_len++] = ESP_BLE_AD_TYPE_FLAG; + adv_data_raw[adv_data_len++] = ESP_BLE_ADV_FLAG_BREDR_NOT_SPT | ESP_BLE_ADV_FLAG_GEN_DISC; + + // Add manufacturer data + adv_data_raw[adv_data_len++] = cmd.data.size() + 2; + adv_data_raw[adv_data_len++] = ESP_BLE_AD_MANUFACTURER_SPECIFIC_TYPE; + adv_data_raw[adv_data_len++] = MANUFACTURER_DATA_ID & 0xFF; + adv_data_raw[adv_data_len++] = (MANUFACTURER_DATA_ID >> 8) & 0xFF; + + memcpy(&adv_data_raw[adv_data_len], cmd.data.data(), cmd.data.size()); + adv_data_len += cmd.data.size(); + + esp_err_t err = esp_ble_gap_config_adv_data_raw(adv_data_raw, adv_data_len); + if (err != ESP_OK) + { + ESP_LOGW(TAG, "Error setting raw advertisement data (err=%d): %s", err, esp_err_to_name(err)); + return; + } + + err = esp_ble_gap_start_advertising(&adv_params); + if (err != ESP_OK) + { + ESP_LOGW(TAG, "Error starting advertisement (err=%d): %s", err, esp_err_to_name(err)); + return; + } + + adv_state_ = AdvertiseState::ADVERTISING; + state_start_time_ = now; + ESP_LOGV(TAG, "Started advertising"); + break; + } + + case AdvertiseState::ADVERTISING: + { + if (now - state_start_time_ >= adv_duration_) + { + esp_ble_gap_stop_advertising(); + adv_state_ = AdvertiseState::GAP; + state_start_time_ = now; + ESP_LOGV(TAG, "Stopped advertising, entering gap period"); + } + break; + } + + case AdvertiseState::GAP: + { + if (now - state_start_time_ >= adv_gap_) + { + adv_state_ = AdvertiseState::IDLE; + ESP_LOGV(TAG, "Gap period complete"); + } + break; + } + } + } + + std::vector FastconController::get_light_data(light::LightState *state) + { + std::vector light_data = { + 0, // 0 - On/Off Bit + 7-bit Brightness + 0, // 1 - Blue byte + 0, // 2 - Red byte + 0, // 3 - Green byte + 0, // 4 - Warm byte + 0 // 5 - Cold byte + }; + + // TODO: need to figure out when esphome is changing to white vs setting brightness + + auto values = state->current_values; + + bool is_on = values.is_on(); + if (!is_on) + { + return std::vector({0x00}); + } + + auto color_mode = values.get_color_mode(); + bool has_white = (static_cast(color_mode) & static_cast(light::ColorCapability::WHITE)) != 0; + float brightness = std::min(values.get_brightness() * 127.0f, 127.0f); // clamp the value to at most 127 + light_data[0] = 0x80 + static_cast(brightness); + + if (has_white) + { + return std::vector({static_cast(brightness)}); + // DEBUG: when changing to white mode, this should be the payload: + // ff0000007f7f + } + + bool has_rgb = (static_cast(color_mode) & static_cast(light::ColorCapability::RGB)) != 0; + if (has_rgb) + { + light_data[1] = static_cast(values.get_blue() * 255.0f); + light_data[2] = static_cast(values.get_red() * 255.0f); + light_data[3] = static_cast(values.get_green() * 255.0f); + } + + bool has_cold_warm = (static_cast(color_mode) & static_cast(light::ColorCapability::COLD_WARM_WHITE)) != 0; + if (has_cold_warm) + { + light_data[4] = static_cast(values.get_warm_white() * 255.0f); + light_data[5] = static_cast(values.get_cold_white() * 255.0f); + } + + // TODO figure out if we can use these, and how + bool has_temp = (static_cast(color_mode) & static_cast(light::ColorCapability::COLOR_TEMPERATURE)) != 0; + if (has_temp) + { + float temperature = values.get_color_temperature(); + if (temperature < 153) + { + light_data[4] = 0xff; + light_data[5] = 0x00; + } + else if (temperature > 500) + { + light_data[4] = 0x00; + light_data[5] = 0xff; + } + else + { + // Linear interpolation between (153, 0xff) and (500, 0x00) + light_data[4] = (uint8_t)(((500 - temperature) * 255.0f + (temperature - 153) * 0x00) / (500 - 153)); + light_data[5] = (uint8_t)(((temperature - 153) * 255.0f + (500 - temperature) * 0x00) / (500 - 153)); + } + } + + return light_data; + } + + std::vector FastconController::single_control(uint32_t light_id_, const std::vector &light_data) + { + std::vector result_data(12); + + result_data[0] = 2 | (((0xfffffff & (light_data.size() + 1)) << 4)); + result_data[1] = light_id_; + std::copy(light_data.begin(), light_data.end(), result_data.begin() + 2); + + // Debug output - print payload as hex + auto hex_str = vector_to_hex_string(result_data).data(); + ESP_LOGD(TAG, "Inner Payload (%d bytes): %s", result_data.size(), hex_str); + + return this->generate_command(5, light_id_, result_data, true); + } + + std::vector FastconController::generate_command(uint8_t n, uint32_t light_id_, const std::vector &data, bool forward) + { + static uint8_t sequence = 0; + + // Create command body with header + std::vector body(data.size() + 4); + uint8_t i2 = (light_id_ / 256); + + // Construct header + body[0] = (i2 & 0b1111) | ((n & 0b111) << 4) | (forward ? 0x80 : 0); + body[1] = sequence++; // Use and increment sequence number + if (sequence >= 255) + sequence = 1; + + body[2] = this->mesh_key_[3]; // Safe key + + // Copy data + std::copy(data.begin(), data.end(), body.begin() + 4); + + // Calculate checksum + uint8_t checksum = 0; + for (size_t i = 0; i < body.size(); i++) + { + if (i != 3) + { + checksum = checksum + body[i]; + } + } + body[3] = checksum; + + // Encrypt header and data + for (size_t i = 0; i < 4; i++) + { + body[i] = DEFAULT_ENCRYPT_KEY[i & 3] ^ body[i]; + } + + for (size_t i = 0; i < data.size(); i++) + { + body[4 + i] = this->mesh_key_[i & 3] ^ body[4 + i]; + } + + // Prepare the final payload with RF protocol formatting + std::vector addr = {DEFAULT_BLE_FASTCON_ADDRESS.begin(), DEFAULT_BLE_FASTCON_ADDRESS.end()}; + return prepare_payload(addr, body); + } + } // namespace fastcon +} // namespace esphome diff --git a/components/brmesh/fastcon/fastcon_controller.h b/components/brmesh/fastcon/fastcon_controller.h new file mode 100644 index 0000000..cdc7808 --- /dev/null +++ b/components/brmesh/fastcon/fastcon_controller.h @@ -0,0 +1,90 @@ +#pragma once + +#include +#include +#include +#include "esphome/core/component.h" +#include "esphome/components/esp32_ble_server/ble_server.h" + +namespace esphome +{ + namespace fastcon + { + + class FastconController : public Component + { + public: + FastconController() = default; + + void setup() override; + void loop() override; + + std::vector get_light_data(light::LightState *state); + std::vector single_control(uint32_t addr, const std::vector &light_data); + + void queueCommand(uint32_t light_id_, const std::vector &data); + + void clear_queue(); + bool is_queue_empty() const + { + std::lock_guard lock(queue_mutex_); + return queue_.empty(); + } + size_t get_queue_size() const + { + std::lock_guard lock(queue_mutex_); + return queue_.size(); + } + void set_max_queue_size(size_t size) { max_queue_size_ = size; } + + void set_mesh_key(std::array key) { mesh_key_ = key; } + void set_adv_interval_min(uint16_t val) { adv_interval_min_ = val; } + void set_adv_interval_max(uint16_t val) + { + adv_interval_max_ = val; + if (adv_interval_max_ < adv_interval_min_) + { + adv_interval_max_ = adv_interval_min_; + } + } + void set_adv_duration(uint16_t val) { adv_duration_ = val; } + void set_adv_gap(uint16_t val) { adv_gap_ = val; } + + protected: + struct Command + { + std::vector data; + uint32_t timestamp; + uint8_t retries{0}; + static constexpr uint8_t MAX_RETRIES = 3; + }; + + std::queue queue_; + mutable std::mutex queue_mutex_; + size_t max_queue_size_{100}; + + enum class AdvertiseState + { + IDLE, + ADVERTISING, + GAP + }; + + AdvertiseState adv_state_{AdvertiseState::IDLE}; + uint32_t state_start_time_{0}; + + // Protocol implementation + std::vector generate_command(uint8_t n, uint32_t light_id_, const std::vector &data, bool forward = true); + + std::array mesh_key_{}; + + uint16_t adv_interval_min_{0x20}; + uint16_t adv_interval_max_{0x40}; + uint16_t adv_duration_{50}; + uint16_t adv_gap_{10}; + + static const uint16_t MANUFACTURER_DATA_ID = 0xfff0; + }; + + } // namespace fastcon +} // namespace esphome diff --git a/components/brmesh/fastcon/fastcon_controller.py b/components/brmesh/fastcon/fastcon_controller.py new file mode 100644 index 0000000..a3361e1 --- /dev/null +++ b/components/brmesh/fastcon/fastcon_controller.py @@ -0,0 +1,78 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.core import HexInt + +DEPENDENCIES = ["esp32_ble"] + +CONF_MESH_KEY = "mesh_key" +CONF_ADV_INTERVAL_MIN = "adv_interval_min" +CONF_ADV_INTERVAL_MAX = "adv_interval_max" +CONF_ADV_DURATION = "adv_duration" +CONF_ADV_GAP = "adv_gap" +CONF_MAX_QUEUE_SIZE = "max_queue_size" + +DEFAULT_ADV_INTERVAL_MIN = 0x20 +DEFAULT_ADV_INTERVAL_MAX = 0x40 +DEFAULT_ADV_DURATION = 50 +DEFAULT_ADV_GAP = 10 +DEFAULT_MAX_QUEUE_SIZE = 100 + + +def validate_hex_bytes(value): + if isinstance(value, str): + value = value.replace(" ", "") + if len(value) != 8: + raise cv.Invalid("Mesh key must be exactly 4 bytes (8 hex characters)") + + try: + return HexInt(int(value, 16)) + except ValueError as err: + raise cv.Invalid(f"Invalid hex value: {err}") + raise cv.Invalid("Mesh key must be a string") + + +fastcon_ns = cg.esphome_ns.namespace("fastcon") +FastconController = fastcon_ns.class_("FastconController", cg.Component) + +CONFIG_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ID, default="fastcon_controller"): cv.declare_id( + FastconController + ), + cv.Required(CONF_MESH_KEY): validate_hex_bytes, + cv.Optional( + CONF_ADV_INTERVAL_MIN, default=DEFAULT_ADV_INTERVAL_MIN + ): cv.uint16_t, + cv.Optional( + CONF_ADV_INTERVAL_MAX, default=DEFAULT_ADV_INTERVAL_MAX + ): cv.uint16_t, + cv.Optional(CONF_ADV_DURATION, default=DEFAULT_ADV_DURATION): cv.uint16_t, + cv.Optional(CONF_ADV_GAP, default=DEFAULT_ADV_GAP): cv.uint16_t, + cv.Optional( + CONF_MAX_QUEUE_SIZE, default=DEFAULT_MAX_QUEUE_SIZE + ): cv.positive_int, + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + if CONF_MESH_KEY in config: + mesh_key = config[CONF_MESH_KEY] + key_bytes = [(mesh_key >> (i * 8)) & 0xFF for i in range(3, -1, -1)] + cg.add(var.set_mesh_key(key_bytes)) + + if config[CONF_ADV_INTERVAL_MAX] < config[CONF_ADV_INTERVAL_MIN]: + raise cv.Invalid( + f"adv_interval_max ({config[CONF_ADV_INTERVAL_MAX]}) must be >= " + f"adv_interval_min ({config[CONF_ADV_INTERVAL_MIN]})" + ) + + cg.add(var.set_adv_interval_min(config[CONF_ADV_INTERVAL_MIN])) + cg.add(var.set_adv_interval_max(config[CONF_ADV_INTERVAL_MAX])) + cg.add(var.set_adv_duration(config[CONF_ADV_DURATION])) + cg.add(var.set_adv_gap(config[CONF_ADV_GAP])) + cg.add(var.set_max_queue_size(config[CONF_MAX_QUEUE_SIZE])) diff --git a/components/brmesh/fastcon/fastcon_light.cpp b/components/brmesh/fastcon/fastcon_light.cpp new file mode 100644 index 0000000..af7a983 --- /dev/null +++ b/components/brmesh/fastcon/fastcon_light.cpp @@ -0,0 +1,71 @@ +#include +#include "esphome/core/log.h" +#include "fastcon_light.h" +#include "fastcon_controller.h" +#include "utils.h" + +namespace esphome +{ + namespace fastcon + { + static const char *const TAG = "fastcon.light"; + + void FastconLight::setup() + { + if (this->controller_ == nullptr) + { + ESP_LOGE(TAG, "Controller not set for light %d!", this->light_id_); + this->mark_failed(); + return; + } + ESP_LOGCONFIG(TAG, "Setting up Fastcon BLE light (ID: %d)...", this->light_id_); + } + + void FastconLight::set_controller(FastconController *controller) + { + this->controller_ = controller; + } + + light::LightTraits FastconLight::get_traits() + { + auto traits = light::LightTraits(); + traits.set_supported_color_modes({light::ColorMode::RGB, light::ColorMode::WHITE, light::ColorMode::BRIGHTNESS, light::ColorMode::COLD_WARM_WHITE}); + traits.set_min_mireds(153); + traits.set_max_mireds(500); + return traits; + } + + void FastconLight::write_state(light::LightState *state) + { + // Get the light data bits from the state + auto light_data = this->controller_->get_light_data(state); + + // Debug output - print the light state values + bool is_on = (light_data[0] & 0x80) != 0; + float brightness = ((light_data[0] & 0x7F) / 127.0f) * 100.0f; + if (light_data.size() == 1) + { + ESP_LOGD(TAG, "Writing state: light_id=%d, on=%d, brightness=%.1f%%", light_id_, is_on, brightness); + } + else + { + auto r = light_data[2]; + auto g = light_data[3]; + auto b = light_data[1]; + auto warm = light_data[4]; + auto cold = light_data[5]; + ESP_LOGD(TAG, "Writing state: light_id=%d, on=%d, brightness=%.1f%%, rgb=(%d,%d,%d), warm=%d, cold=%d", light_id_, is_on, brightness, r, g, b, warm, cold); + } + + // Generate the advertisement payload + auto adv_data = this->controller_->single_control(this->light_id_, light_data); + + // Debug output - print payload as hex + auto hex_str = vector_to_hex_string(adv_data).data(); + ESP_LOGD(TAG, "Advertisement Payload (%d bytes): %s", adv_data.size(), hex_str); + + // Send the advertisement + this->controller_->queueCommand(this->light_id_, adv_data); + } + } // namespace fastcon +} // namespace esphome diff --git a/components/brmesh/fastcon/fastcon_light.h b/components/brmesh/fastcon/fastcon_light.h new file mode 100644 index 0000000..bf583e2 --- /dev/null +++ b/components/brmesh/fastcon/fastcon_light.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include "esphome/core/component.h" +#include "esphome/components/light/light_output.h" +#include "fastcon_controller.h" + +namespace esphome +{ + namespace fastcon + { + enum class LightState + { + OFF, + WARM_WHITE, + RGB + }; + + class FastconLight : public Component, public light::LightOutput + { + public: + FastconLight(uint8_t light_id) : light_id_(light_id) {} + + void setup() override; + light::LightTraits get_traits() override; + void write_state(light::LightState *state) override; + void set_controller(FastconController *controller); + + protected: + FastconController *controller_{nullptr}; + uint8_t light_id_; + }; + } // namespace fastcon +} // namespace esphome diff --git a/components/brmesh/fastcon/light.py b/components/brmesh/fastcon/light.py new file mode 100644 index 0000000..3ff7da4 --- /dev/null +++ b/components/brmesh/fastcon/light.py @@ -0,0 +1,37 @@ +"""Light platform for Fastcon BLE lights.""" + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import light +from esphome.const import CONF_LIGHT_ID, CONF_OUTPUT_ID + +from .fastcon_controller import FastconController + +DEPENDENCIES = ["esp32_ble"] +AUTO_LOAD = ["light"] + +CONF_CONTROLLER_ID = "controller_id" + +fastcon_ns = cg.esphome_ns.namespace("fastcon") +FastconLight = fastcon_ns.class_("FastconLight", light.LightOutput, cg.Component) + +CONFIG_SCHEMA = cv.All( + light.BRIGHTNESS_ONLY_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(FastconLight), + cv.Required(CONF_LIGHT_ID): cv.int_range(min=1, max=255), + cv.Optional(CONF_CONTROLLER_ID, default="fastcon_controller"): cv.use_id( + FastconController + ), + } + ).extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_OUTPUT_ID], config[CONF_LIGHT_ID]) + await cg.register_component(var, config) + await light.register_light(var, config) + + controller = await cg.get_variable(config[CONF_CONTROLLER_ID]) + cg.add(var.set_controller(controller)) diff --git a/components/brmesh/fastcon/protocol.cpp b/components/brmesh/fastcon/protocol.cpp new file mode 100644 index 0000000..549b4be --- /dev/null +++ b/components/brmesh/fastcon/protocol.cpp @@ -0,0 +1,60 @@ +#include +#include "protocol.h" + +namespace esphome +{ + namespace fastcon + { + std::vector get_rf_payload(const std::vector &addr, const std::vector &data) + { + const size_t data_offset = 0x12; + const size_t inverse_offset = 0x0f; + const size_t result_data_size = data_offset + addr.size() + data.size(); + + // Create result buffer including space for checksum + std::vector resultbuf(result_data_size + 2, 0); + + // Set hardcoded values + resultbuf[0x0f] = 0x71; + resultbuf[0x10] = 0x0f; + resultbuf[0x11] = 0x55; + + // Copy address in reverse + for (size_t i = 0; i < addr.size(); i++) + { + resultbuf[data_offset + addr.size() - i - 1] = addr[i]; + } + + // Copy data + std::copy(data.begin(), data.end(), resultbuf.begin() + data_offset + addr.size()); + + // Reverse bytes in specified range + for (size_t i = inverse_offset; i < inverse_offset + addr.size() + 3; i++) + { + resultbuf[i] = reverse_8(resultbuf[i]); + } + + // Add CRC + uint16_t crc = crc16(addr, data); + resultbuf[result_data_size] = crc & 0xFF; + resultbuf[result_data_size + 1] = (crc >> 8) & 0xFF; + + return resultbuf; + } + + std::vector prepare_payload(const std::vector &addr, const std::vector &data) + { + auto payload = get_rf_payload(addr, data); + + // Initialize whitening + WhiteningContext context; + whitening_init(0x25, context); + + // Apply whitening to the payload + whitening_encode(payload, context); + + // Return only the portion after 0xf bytes + return std::vector(payload.begin() + 0xf, payload.end()); + } + } // namespace fastcon +} // namespace esphome \ No newline at end of file diff --git a/components/brmesh/fastcon/protocol.h b/components/brmesh/fastcon/protocol.h new file mode 100644 index 0000000..930acab --- /dev/null +++ b/components/brmesh/fastcon/protocol.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include +#include +#include "utils.h" + +namespace esphome +{ + namespace fastcon + { + static const std::array DEFAULT_ENCRYPT_KEY = {0x5e, 0x36, 0x7b, 0xc4}; + static const std::array DEFAULT_BLE_FASTCON_ADDRESS = {0xC1, 0xC2, 0xC3}; + + std::vector get_rf_payload(const std::vector &addr, const std::vector &data); + std::vector prepare_payload(const std::vector &addr, const std::vector &data); + } // namespace fastcon +} // namespace esphome \ No newline at end of file diff --git a/components/brmesh/fastcon/utils.cpp b/components/brmesh/fastcon/utils.cpp new file mode 100644 index 0000000..371ae09 --- /dev/null +++ b/components/brmesh/fastcon/utils.cpp @@ -0,0 +1,126 @@ +#include +#include +#include "esphome/core/log.h" +#include "utils.h" + +namespace esphome +{ + namespace fastcon + { + uint8_t reverse_8(uint8_t d) + { + uint8_t result = 0; + for (int i = 0; i < 8; i++) + { + result |= ((d >> i) & 1) << (7 - i); + } + return result; + } + + uint16_t reverse_16(uint16_t d) + { + uint16_t result = 0; + for (int i = 0; i < 16; i++) + { + result |= ((d >> i) & 1) << (15 - i); + } + return result; + } + + uint16_t crc16(const std::vector &addr, const std::vector &data) + { + uint16_t crc = 0xffff; + + // Process address in reverse + for (auto it = addr.rbegin(); it != addr.rend(); ++it) + { + crc ^= (static_cast(*it) << 8); + for (int j = 0; j < 4; j++) + { + uint16_t tmp = crc << 1; + if (crc & 0x8000) + { + tmp ^= 0x1021; + } + crc = tmp << 1; + if (tmp & 0x8000) + { + crc ^= 0x1021; + } + } + } + + // Process data + for (size_t i = 0; i < data.size(); i++) + { + crc ^= (static_cast(reverse_8(data[i])) << 8); + for (int j = 0; j < 4; j++) + { + uint16_t tmp = crc << 1; + if (crc & 0x8000) + { + tmp ^= 0x1021; + } + crc = tmp << 1; + if (tmp & 0x8000) + { + crc ^= 0x1021; + } + } + } + + crc = ~reverse_16(crc); + return crc; + } + + void whitening_init(uint32_t val, WhiteningContext &ctx) + { + uint32_t v0[] = {(val >> 5), (val >> 4), (val >> 3), (val >> 2)}; + + ctx.f_0x0 = 1; + ctx.f_0x4 = v0[0] & 1; + ctx.f_0x8 = v0[1] & 1; + ctx.f_0xc = v0[2] & 1; + ctx.f_0x10 = v0[3] & 1; + ctx.f_0x14 = (val >> 1) & 1; + ctx.f_0x18 = val & 1; + } + + void whitening_encode(std::vector &data, WhiteningContext &ctx) + { + for (size_t i = 0; i < data.size(); i++) + { + uint32_t varC = ctx.f_0xc; + uint32_t var14 = ctx.f_0x14; + uint32_t var18 = ctx.f_0x18; + uint32_t var10 = ctx.f_0x10; + uint32_t var8 = var14 ^ ctx.f_0x8; + uint32_t var4 = var10 ^ ctx.f_0x4; + uint32_t _var = var18 ^ varC; + uint32_t var0 = _var ^ ctx.f_0x0; + + uint8_t c = data[i]; + data[i] = ((c & 0x80) ^ ((var8 ^ var18) << 7)) + ((c & 0x40) ^ (var0 << 6)) + ((c & 0x20) ^ (var4 << 5)) + ((c & 0x10) ^ (var8 << 4)) + ((c & 0x08) ^ (_var << 3)) + ((c & 0x04) ^ (var10 << 2)) + ((c & 0x02) ^ (var14 << 1)) + ((c & 0x01) ^ (var18 << 0)); + + ctx.f_0x8 = var4; + ctx.f_0xc = var8; + ctx.f_0x10 = var8 ^ varC; + ctx.f_0x14 = var0 ^ var10; + ctx.f_0x18 = var4 ^ var14; + ctx.f_0x0 = var8 ^ var18; + ctx.f_0x4 = var0; + } + } + + std::vector vector_to_hex_string(std::vector &data) + { + std::vector hex_str(data.size() * 2 + 1); // Allocate the vector with the required size + for (size_t i = 0; i < data.size(); i++) + { + sprintf(hex_str.data() + (i * 2), "%02X", data[i]); + } + hex_str[data.size() * 2] = '\0'; // Ensure null termination + return hex_str; + } + } // namespace fastcon +} // namespace esphome \ No newline at end of file diff --git a/components/brmesh/fastcon/utils.h b/components/brmesh/fastcon/utils.h new file mode 100644 index 0000000..c243d9c --- /dev/null +++ b/components/brmesh/fastcon/utils.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +namespace esphome +{ + namespace fastcon + { + // Bit manipulation utilities + uint8_t reverse_8(uint8_t d); + uint16_t reverse_16(uint16_t d); + + // CRC calculation + uint16_t crc16(const std::vector &addr, const std::vector &data); + + // Whitening context and functions + struct WhiteningContext + { + uint32_t f_0x0; + uint32_t f_0x4; + uint32_t f_0x8; + uint32_t f_0xc; + uint32_t f_0x10; + uint32_t f_0x14; + uint32_t f_0x18; + + WhiteningContext() : f_0x0(0), f_0x4(0), f_0x8(0), f_0xc(0), f_0x10(0), f_0x14(0), f_0x18(0) {} + }; + + void whitening_init(uint32_t val, WhiteningContext &ctx); + void whitening_encode(std::vector &data, WhiteningContext &ctx); + + std::vector vector_to_hex_string(std::vector &data); + } // namespace fastcon +} // namespace esphome \ No newline at end of file diff --git a/components/brmesh/light.cpp b/components/brmesh/light.cpp index 0f0bb8a..ad15a1e 100644 --- a/components/brmesh/light.cpp +++ b/components/brmesh/light.cpp @@ -1,12 +1,13 @@ #include "light.h" - -#include "network.h" #include "whitening.h" +#include "protocol.h" +#include "debug.h" #include "esphome/core/log.h" namespace { const char *const TAG = "brmesh.light"; + } // namespace namespace esphome { @@ -23,39 +24,22 @@ uint8_t crc(const std::span &bytes, uint8_t init = 0x00) { uint16_t crc16(uint8_t byte, uint16_t crc) { crc ^= static_cast(byte) << 8; - - auto a = [](uint16_t crc) { - for (int j=0; j<4; ++j) { - uint16_t tmp = crc << 1; - if (crc & 0x8000) - tmp ^= 0x1021; - crc = tmp << 1; - if (tmp & 0x8000) - crc ^= 0x1021; - } - return crc; - }(crc); - auto b = [](uint16_t crc) { - for (int i = 0; i < 8; i++) { - if (crc & 0x8000) - crc = (crc << 1) ^ 0x1021; - else - crc <<= 1; - } - return crc; - }(crc); - - if (a != b) - ESP_LOGE(TAG, "crc16 missmatch: %04hX*%02hhX => %04hX|%04hX", - crc, byte, a, b); - return a; + for (int j=0; j<4; ++j) { + uint16_t tmp = crc << 1; + if (crc & 0x8000) + tmp ^= 0x1021; + crc = tmp << 1; + if (tmp & 0x8000) + crc ^= 0x1021; + } + return crc; } uint16_t crc16(const std::array &addr, const std::span &data, const uint16_t seed = 0xFFFF) { auto crc = seed; - for (auto it = addr.rbegin(); it != addr.rend(); ++it) - crc = crc16(*it, crc); + for (const auto &byte:addr) + crc = crc16(byte, crc); for (const auto &byte:data) crc = crc16(reverse_bits(byte), crc); @@ -63,6 +47,11 @@ uint16_t crc16(const std::array &addr, const std::span &dat return ~reverse_bits(crc); } +template +std::span subspan(const std::span &buffer, size_t offset) { + return std::span(buffer.subspan(offset, size)); +} + std::span single_light_data(const std::span &buffer, const light::LightState &state) { const auto &values = state.current_values; // 0 - On/Off Bit + 7-bit Brightness @@ -137,26 +126,139 @@ std::span single_light_data(const std::span &buffer, const } -std::span -group_light_data(const std::span &buffer, const light::LightState &state) { - ESP_LOGE(TAG, "not implemented: group light data"); - return {}; +std::span command(const std::span &buffer, const Key &key, Light::Id id, uint8_t seq, light::LightState &state) { + static constexpr uint8_t n = 5; // TODO + static constexpr bool forward = true; // TODO + const uint8_t i2 = (id.value() / 256); + + if (id.kind() != 'L') + return {}; + + const auto header = buffer.subspan(0, 4); + const auto body = single_light_data(subspan<6>(buffer, 4), state); + + hexdump(TAG, body, "%c%02hhX: body: %s", id.kind(), id.value()); + + header[0] = (i2 & 0b1111) | ((n & 0b111) << 4) | (forward ? 0x80 : 0); + header[1] = seq; + header[2] = key[3]; + header[3] = crc(body); + + const auto result = buffer.subspan(0, 4 + body.size()); + + hexdump(TAG, result, "%c%02hhX: plain: %s", id.kind(), id.value()); + + DEFAULT_ENCRYPT_KEY(header); + key(body); + + hexdump(TAG, result, "%c%02hhX: cipher: %s", id.kind(), id.value()); + + return result; } -std::span light_data(Light::Single, const std::span &buffer, light::LightState &state) { - return single_light_data(buffer, state); +std::span payload(const std::span &buffer, const Key &key, Light::Id id, uint8_t seq, light::LightState &state) { + // static values + buffer[0] = 0x71; + buffer[1] = 0x0F; + buffer[2] = 0x55; + + const auto address = buffer.subspan(3, 3); + std::reverse_copy(DEFAULT_BLE_ADDRESS.cbegin(), DEFAULT_BLE_ADDRESS.cend(), address.begin()); + + const auto command = brmesh::command(subspan<16>(buffer, 4), key, id, seq, state); + hexdump(TAG, command, "L%hhd: command: %s", id); + + const auto result = buffer.subspan(0, 4 + command.size() + 2); + + uint16_t crc_value = crc16(DEFAULT_BLE_ADDRESS, command); + const auto crc = buffer.subspan(4+command.size(), 2); + crc[0] = crc_value; + crc[1] = crc_value >> 8; + + for (auto &byte:buffer.subspan(0, 6)) + byte = reverse_bits(byte); + + hexdump(TAG, result, "L%hhd: raw: %s", id); + + // TODO: shift seed to drop warmup + Whitening whitening(0x25); + for (size_t i=0; i<0x10; ++i) + whitening.encode(0); + + return whitening.encode(result); } -std::span light_data(Light::Group, const std::span &buffer, light::LightState &state) { - return group_light_data(buffer, state); +std::span adv_data(const std::span &buffer, const Key &key, Light::Id id, uint8_t seq, light::LightState &state) { + const auto flags = buffer.subspan(0, 3); + const auto length = buffer.subspan(3, 1); + const auto manufacturer = buffer.subspan(4, 3); + const auto payload = brmesh::payload(std::span(buffer.subspan(7)), key, id, seq, state); + + hexdump(TAG, payload, "L%hhd: payload: %s", id); + + flags[0] = 2; + flags[1] = ESP_BLE_AD_TYPE_FLAG; + flags[2] = ESP_BLE_ADV_FLAG_BREDR_NOT_SPT | ESP_BLE_ADV_FLAG_GEN_DISC; + + length[0] = payload.size(); + + manufacturer[0] = ESP_BLE_AD_MANUFACTURER_SPECIFIC_TYPE; + manufacturer[1] = 0xF0; + manufacturer[2] = 0xFF; + + return buffer.subspan(0, 7 + payload.size()); } } // namespace +void +Light::adv_event_(esp_ble_gap_cb_param_t::ble_adv_data_raw_cmpl_evt_param &) { + esp_ble_adv_params_t adv_params = { + .adv_int_min = adv_interval_min_, + .adv_int_max = adv_interval_max_, + .adv_type = ADV_TYPE_NONCONN_IND, + .own_addr_type = BLE_ADDR_TYPE_PUBLIC, + .peer_addr = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + .peer_addr_type = BLE_ADDR_TYPE_PUBLIC, + .channel_map = ADV_CHNL_ALL, + .adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY, + }; + + const auto err = esp_ble_gap_start_advertising(&adv_params); + if (err != ESP_OK) + ESP_LOGE(TAG, "%c%02hhX: start advertising: %s", id_.kind(), id_.value(), esp_err_to_name(err)); + else + ESP_LOGD(TAG, "%c%02hhX: start advertising %hhd", id_.kind(), id_.value(), count_); +} + +void +Light::adv_event_(esp_ble_gap_cb_param_t::ble_adv_start_cmpl_evt_param ¶m) { + if (param.status != ESP_BT_STATUS_SUCCESS) + // TODO: is param.status a valid esp_err_to_name parameter? + ESP_LOGE(TAG, "adv start: %s", esp_err_to_name(param.status)); + else + ESP_LOGD(TAG, "%c%02hhX: advertise started", id_.kind(), id_.value()); +} + +void +Light::adv_event_(esp_ble_gap_cb_param_t::ble_adv_stop_cmpl_evt_param ¶m) { + if (param.status != ESP_BT_STATUS_SUCCESS) + // TODO: is param.status a valid esp_err_to_name parameter? + ESP_LOGE(TAG, "%c%02hhX: adv stop: %s", id_.kind(), id_.value(), esp_err_to_name(param.status)); + else + ESP_LOGD(TAG, "%c%02hhX: advertise stop", id_.kind(), id_.value()); +} + // interface: esphome::Component void Light::dump_config() { - ESP_LOGCONFIG(TAG, "light %c%hhd", id_.kind(), id_.value()); + auto *ble = esphome::esp32_ble::global_ble; + ESP_LOGCONFIG(TAG, + "light %hhd\n" + " key: %02hhx %02hhx %02hhx %02hhx\n" + " seq: %02hhx\n" + " ble: %p", + id_, key_[0], key_[1], key_[2], key_[3], seq_, ble); } void @@ -169,62 +271,55 @@ Light::setup() { } ble->advertising_register_raw_advertisement_callback([this](bool advertise) { - this->advertise(advertise); + const bool advertising = advertising_; + ESP_LOGD(TAG, "%c%02hhX: adv callback %s", id_.kind(), id_.value(), advertise ? "on" : "off"); + + if (advertise == advertising) + return; + + advertising_ = advertise; + if (!advertise) + return; + + enable_loop(); }); } -void -Light::advertise(bool advertise) { - const bool advertising = advertising_; - ESP_LOGV(TAG, "%c%02hhX: adv callback %s", id_.kind(), id_.value(), advertise ? "on" : "off"); - - if (advertise == advertising) - return; - - advertising_ = advertise; - if (advertise) - enable_loop(); - else - network_.advertise(id_, {}); -} - -std::span -Light::command(const std::span &output) const -{ - const auto hdr = output.subspan(0, 2); - const auto dst = output.subspan(hdr.size()); - const auto src = std::span(payload_.cbegin(), payload_length_); - - if (output.size() < hdr.size() + src.size()) - return {}; - - hdr[0] = 2 | (((0xfffffff & (src.size() + 1)) << 4)); - hdr[1] = id_.value(); - std::copy(src.begin(), src.end(), dst.begin()); - std::ranges::fill(dst.subspan(src.size()), 0); - - return output; -} - void Light::loop() { disable_loop(); if (!advertising_) { - ESP_LOGD(TAG, "%c%02hhX: not advertising", id_.kind(), id_.value()); + ESP_LOGE(TAG, "%c%02hhX: loop while not advertising", id_.kind(), id_.value()); return; } - if (payload_length_ == 0 || count_ >= 5) { - ESP_LOGD(TAG, "%c%02hhX: no update", id_.kind(), id_.value()); + if (state_ == nullptr) { + ESP_LOGD(TAG, "%c%02hhX: sleep", id_.kind(), id_.value()); return; } ++count_; - auto payload = std::span(&payload_[0], payload_length_); - ESP_LOGV(TAG, "%c%02hhX: advertise: %s", id_.kind(), id_.value(), - format_hex_pretty(&payload[0], payload.size()).c_str()); - network_.advertise(id_, payload); + auto *const state = state_; + + const auto seq = seq_++; + std::array buffer; + + auto data = std::visit([buffer=std::span(buffer), this, seq, state](auto &&id) { + return adv_data(buffer, key_, id, seq, *state); + }, id_); + hexdump(TAG, data, "%c%02hhX: advertise: %s", id_.kind(), id_.value()); + + if (data.size() == 0) + return; + + auto err = esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_ADV, tx_power_); + if (err != ESP_OK) + ESP_LOGW(TAG, "%c%02hhX: tx power set: %s", id_.kind(), id_.value(), esp_err_to_name(err)); + + err = esp_ble_gap_config_adv_data_raw(&data.front(), data.size()); + if (err != ESP_OK) + ESP_LOGE(TAG, "%c%02hhX: config adv: %s", id_.kind(), id_.value(), esp_err_to_name(err)); } // interface: esphome::light::LightOutput @@ -244,29 +339,211 @@ Light::get_traits() { void Light::setup_state(light::LightState *state) { - // nothing to do - (void) state; } void Light::update_state(light::LightState *state) { - // nothing to do - (void) state; } +#include "./fastcon/fastcon_controller.h" + void Light::write_state(light::LightState *state) { - //ESP_LOGV(TAG, "%c%02hhX: state %02hx", id_.kind(), id_.value(), state->current_values.get_color_mode()); + ESP_LOGV(TAG, "%c%02hhX: state %02hx", id_.kind(), id_.value(), state->current_values.get_color_mode()); - const auto payload = std::visit([this, state](auto && id) { - return light_data(id, std::span(payload_), *state); - }, id_); - payload_length_ = payload.size(); + payload_length_ = single_light_data(std::span(payload_), *state).size(); + hexdump(TAG, &payload_.front(), payload_length_, "%c%02hhX: state: %s", id_.kind(), id_.value()); + state_ = state; count_ = 0; - //ESP_LOGD(TAG, "%c%02hhX: state: %s", id_.kind(), id_.value(), - // format_hex_pretty(&payload[0], payload.size()).c_str()); - //enable_loop(); + +#if 0 + switch (state->current_values.get_color_mode()) + // handled in update_state + float red; + float green; + float blue; + float temperatur; + float brightness; + state->current_values_as_rgbct(&red, &green, &blue, &temperatur, &brightness); +#else + static fastcon::FastconController controller = [this]() { + fastcon::FastconController controller; + controller.set_mesh_key(key_); + }(); + + const auto fastcon_data = controller.get_light_data(state); + hexdump(TAG, &fastcon_data.front(), fastcon_data.size(), "%c%02hhX: fastcon: %s", id_.kind(), id_.value()); + + +#endif +} + +// interface: esphome::esp32_ble::GAPEventHandler +void +Light::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { + if (!advertising_) + return; + + esp_err_t err; + switch (event) { + case ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT: + adv_event_(param->adv_data_raw_cmpl); + return; + + case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: + adv_event_(param->adv_start_cmpl); + return; + + case ESP_GAP_BLE_ADV_STOP_COMPLETE_EVT: + adv_event_(param->adv_stop_cmpl); + return; + } } } // namespace brmesh } // namespace esphome + +#if 0 +#include +#include "esphome/core/log.h" +#include "esphome/components/light/light_color_values.h" +#include "light.h" +#include "controller.h" +#include "utils.h" + +namespace { +const char *const TAG = "fastcon.light"; + +const esphome::light::ColorModeMask color_modes[] = { + esphome::light::ColorMode::RGB, + esphome::light::ColorMode::WHITE, + esphome::light::ColorMode::BRIGHTNESS, + esphome::light::ColorMode::COLD_WARM_WHITE, +}; + +} // namespace + +namespace esphome { +namespace brmesh { + +void Light::setup() +{ + ESP_LOGCONFIG(TAG, "id=%hhd", light_id_); +} + +light::LightTraits Light::get_traits() +{ + auto traits = light::LightTraits(); + traits.set_supported_color_modes(color_modes); + traits.set_min_mireds(153); + traits.set_max_mireds(500); + return traits; +} + +void Light::setup_state(light::LightState *state) +{ + state_ = state; +} + +void Light::update_state(light::LightState *state) +{ + state_ = state; + controller_.enqueue(*this); +} + +void Light::write_state(light::LightState *state) +{ + // already queued from update_state +} + +size_t Light::command(std::span &&data) const +{ + if (light_state_ == nullptr) + return 0; + + const auto &values = light_state_->current_values; + // 0 - On/Off Bit + 7-bit Brightness + // 1 - Blue byte + // 2 - Red byte + // 3 - Green byte + // 4 - Warm byte + // 5 - Cold byte + + bool is_on = values.is_on(); + if (!is_on) + { + data[0] = 0x00; + return 1; + } + + auto color_mode = [mode=light::ColorModeMask(values.get_color_mode())](light::ColorCapability cap){ + return light::has_capability(mode, cap); + }; + float brightness = std::min(values.get_brightness() * 127.0f, 127.0f); // clamp the value to at most 127 + + if (color_mode(light::ColorCapability::WHITE)) + { + // TODO: need to figure out when esphome is changing to white vs setting brightness + // when changing to white mode, this should be the payload: + // ff0000007f7f + + data[0] = brightness; + return 1; + } + + data[0] = 0x80 + static_cast(brightness); + if (color_mode(light::ColorCapability::RGB)) { + data[1] = static_cast(values.get_blue() * 255.0f); + data[2] = static_cast(values.get_red() * 255.0f); + data[3] = static_cast(values.get_green() * 255.0f); + } else { + data[1] = 0; + data[2] = 0; + data[3] = 0; + } + + if (color_mode(light::ColorCapability::COLD_WARM_WHITE)) { + data[4] = static_cast(values.get_warm_white() * 255.0f); + data[5] = static_cast(values.get_cold_white() * 255.0f); + } else if (color_mode(light::ColorCapability::COLOR_TEMPERATURE)) { + // TODO figure out if we can use these, and how + float temperature = values.get_color_temperature(); + if (temperature < 153) + { + data[4] = 0xff; + data[5] = 0x00; + } + else if (temperature > 500) + { + data[4] = 0x00; + data[5] = 0xff; + } + else + { + // Linear interpolation between (153, 0xff) and (500, 0x00) + data[4] = (uint8_t)(((500 - temperature) * 255.0f + (temperature - 153) * 0x00) / (500 - 153)); + data[5] = (uint8_t)(((temperature - 153) * 255.0f + (500 - temperature) * 0x00) / (500 - 153)); + } + } else { + data[4] = 0; + data[5] = 0; + } + + return 6; +} +#if 0 + // Generate the advertisement payload + auto adv_data = controller_.single_control(this->light_id_, light_data); + + // Debug output - print payload as hex + auto hex_str = vector_to_hex_string(adv_data).data(); + ESP_LOGD(TAG, "Advertisement Payload (%d bytes): %s", adv_data.size(), hex_str); + + // Send the advertisement + controller_.queueCommand(this->light_id_, adv_data); +} +#endif + +} // namespace brmesh +} // namespace esphome +#endif diff --git a/components/brmesh/light.h b/components/brmesh/light.h index dda261d..abed4db 100644 --- a/components/brmesh/light.h +++ b/components/brmesh/light.h @@ -13,9 +13,7 @@ namespace esphome { namespace brmesh { -class Network; - -class Light : public Component, public light::LightOutput +class Light : public Component, public light::LightOutput, public esp32_ble::GAPEventHandler { public: enum class Single : uint8_t {}; @@ -23,23 +21,27 @@ public: class Id : public std::variant { public: using variant::variant; - constexpr Id(uint8_t value) : variant(Single{value}) {} static constexpr char kind(Single) { return 'L'; } static constexpr char kind(Group) { return 'G'; } - constexpr char kind() const { + constexpr char kind() { return std::visit([](auto &&value) { return Id::kind(value); }, *this); } static constexpr uint8_t value(Single value) { return static_cast(value); } static constexpr uint8_t value(Group value) { return static_cast(value); } - constexpr uint8_t value() const { + constexpr uint8_t value() { return std::visit([](auto &&value) { return Id::value(value); }, *this); } }; - Light(Id id, Network *network) : id_(id), network_(*network) {} - Light(Single id, Network *network) : Light(Id{id}, network) {} + + Light(Id id, const Key &key, + uint16_t adv_interval_min, uint16_t adv_interval_max, + uint16_t adv_duration, uint16_t adv_gap) + : id_(id), key_(key), + adv_interval_min_(adv_interval_min), adv_interval_max_(adv_interval_max), + adv_duration_(adv_duration), adv_gap_(adv_gap) {} // interface: Component void dump_config() override; @@ -55,18 +57,31 @@ public: void update_state(light::LightState *state) override; void write_state(light::LightState *state) override; - // callback - void advertise(bool advertise); + // interface: GAPEventHandler + void gap_event_handler(esp_gap_ble_cb_event_t, esp_ble_gap_cb_param_t *) override; - std::span command(const std::span &output) const; private: + light::LightState *state_ = nullptr; Id id_; - Network &network_; + const Key key_; + const uint16_t adv_interval_min_; + const uint16_t adv_interval_max_; + const uint16_t adv_duration_; + const uint16_t adv_gap_; + static constexpr esp_power_level_t tx_power_{}; + uint8_t seq_ = 0; uint8_t count_ = 0; bool advertising_ = false; std::array payload_ = {}; size_t payload_length_ = 0; + + void adv_event_(esp_ble_gap_cb_param_t::ble_adv_data_raw_cmpl_evt_param &); + void adv_event_(esp_ble_gap_cb_param_t::ble_adv_start_cmpl_evt_param &); + void adv_event_(esp_ble_gap_cb_param_t::ble_adv_stop_cmpl_evt_param &); + + static std::span adv_data_(const std::span &, uint8_t seq, light::LightState &); + }; } // namespace brmesh diff --git a/components/brmesh/light.py b/components/brmesh/light.py index 03f9a38..4c7d79f 100644 --- a/components/brmesh/light.py +++ b/components/brmesh/light.py @@ -2,40 +2,91 @@ import esphome.codegen as cg import esphome.config_validation as cv -from esphome.cpp_generator import LambdaExpression -from esphome.const import CONF_LIGHT_ID, CONF_OUTPUT_ID, CONF_REPEAT +from esphome.const import CONF_LIGHT_ID, CONF_OUTPUT_ID from esphome.core import HexInt from esphome.components import light, esp32_ble -from .brmesh import Network, brmesh_ns +#from .brmesh import Controller DEPENDENCIES = ["esp32_ble"] AUTO_LOAD = ["light"] -CONF_NETWORK_ID = "network" +CONF_KEY = "key" +CONF_ADV = "adv" +CONF_INTERVAL = "interval" +CONF_MIN = "min" +CONF_MAX = "max" +CONF_DURATION = "duration" +CONF_GAP = "gap" + +def validate_key_string(value): + value = value.replace(" ", "") + if len(value) != 8: + raise cv.Invalid("Key must be exactly 4 bytes (8 hex characters)") + + try: + return [HexInt(int(value[x:x+2], 16)) for x in range(0, len(value), 2)] + except ValueError as err: + raise cv.Invalid(f"Invalid hex value: {err}") + + +def validate_key(value): + if isinstance(value, str): + value = validate_key_string(value) + + if not isinstance(value, list): + raise cv.Invalid("Key must be a list") + + if len(value) != 4: + raise cv.Invalid("Key must have 4 bytes") + + return [ cv.uint8_t(x) for x in value ] + + +brmesh_ns = cg.esphome_ns.namespace("brmesh") Light = brmesh_ns.class_("Light", light.LightOutput, cg.Component) -Single = Light.enum('Single', is_class=True) +Single = Light.enum("Single", True) +Group = Light.enum("Group", True); + +ADV_INTERVAL_SCHEMA = cv.Schema({ + cv.Optional(CONF_MIN, default=0x20): cv.uint16_t, + cv.Optional(CONF_MAX, default=0x80): cv.uint16_t, +}) + +ADV_SCHEMA = cv.Schema({ + cv.Optional(CONF_INTERVAL, default=ADV_INTERVAL_SCHEMA({})): ADV_INTERVAL_SCHEMA, + cv.Optional(CONF_DURATION, default="50ms"): cv.positive_time_period, + cv.Optional(CONF_GAP, default="10ms"): cv.positive_time_period, +}) CONFIG_SCHEMA = light.light_schema(Light, light.LightType.RGB).extend({ - cv.Required(CONF_NETWORK_ID): cv.use_id(Network), - cv.Required(CONF_LIGHT_ID): cv.int_range(min=1, max=255), - # TODO: use CONF_BLE_ID from CONF_NETWORK_ID... cv.GenerateID(esp32_ble.CONF_BLE_ID): cv.use_id(esp32_ble.ESP32BLE), - cv.Optional(CONF_REPEAT, default=5): cv.positive_not_null_int, + cv.Required(CONF_KEY): validate_key, + cv.Required(CONF_LIGHT_ID): cv.int_range(min=1, max=255), + cv.Optional(CONF_ADV, default=ADV_SCHEMA({})): ADV_SCHEMA, }) async def to_code(config): - network = await cg.get_variable(config[CONF_NETWORK_ID]) + adv = config[CONF_ADV] + interval = adv[CONF_INTERVAL] + + if interval[CONF_MAX] < interval[CONF_MIN]: + raise cv.Invalid( + f"{CONF_ADV}.{CONF_INTERVAL}.{CONF_MIN} ({interval[CONF_MIN]}) must be <= " + f"{CONF_ADV}.{CONF_INTERVLA}.{CONF_MAX} ({interval[CONF_MAX]})" + ) ble = await cg.get_variable(config[esp32_ble.CONF_BLE_ID]) target = Single(config[CONF_LIGHT_ID]) - var = cg.new_Pvariable(config[CONF_OUTPUT_ID], target, network) - #config[CONF_KEY], - # interval[CONF_MIN], interval[CONF_MAX]) - #esp32_ble.register_gap_event_handler(ble, var) - callback = LambdaExpression(f"{var}->advertise(advertise);", [ (bool, "advertise") ], "") - cg.add(ble.advertising_register_raw_advertisement_callback(callback)) + var = cg.new_Pvariable(config[CONF_OUTPUT_ID], target, config[CONF_KEY], + interval[CONF_MIN], interval[CONF_MAX], + adv[CONF_DURATION].total_milliseconds, + adv[CONF_GAP].total_milliseconds) + esp32_ble.register_gap_event_handler(ble, var) await cg.register_component(var, config) await light.register_light(var, config) + + cg.add_define("USE_ESP32_BLE_ADVERTISING") + cg.add_define("USE_ESP32_BLE_UUID") diff --git a/components/brmesh/network.cpp b/components/brmesh/network.cpp deleted file mode 100644 index a67c981..0000000 --- a/components/brmesh/network.cpp +++ /dev/null @@ -1,300 +0,0 @@ -#include "network.h" - -#include "address.h" -#include "light.h" -#include "whitening.h" - -#include - -namespace { -const char *const TAG = "brmesh.network"; -} // namespace - -namespace esphome { -namespace brmesh { - -namespace { - -template -std::span subspan(const std::span &input, size_t offset, ssize_t size) { - if (input.size() < offset) - return {}; - - const auto remain = input.size() - offset; - if (size < 0) { - size += remain; - if (size < 0) - return {}; - } - if (remain < size) - return {}; - - return input.subspan(offset, size); -} - -template -std::span subspan(const std::span &input, size_t offset) { - if (input.size() < offset) - return {}; - return subspan(input, offset, input.size() - offset); -} - -void -adv_start(uint16_t adv_int_min, uint16_t adv_int_max) { - esp_ble_adv_params_t adv_params = { - .adv_int_min = adv_int_min, - .adv_int_max = adv_int_max, - .adv_type = ADV_TYPE_NONCONN_IND, - .own_addr_type = BLE_ADDR_TYPE_PUBLIC, - .peer_addr = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, - .peer_addr_type = BLE_ADDR_TYPE_PUBLIC, - .channel_map = ADV_CHNL_ALL, - .adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY, - }; - - const auto err = esp_ble_gap_start_advertising(&adv_params); - if (err != ESP_OK) - ESP_LOGE(TAG, "start advertising: %s", esp_err_to_name(err)); - else - ESP_LOGD(TAG, "start advertising"); -} - -template -uint8_t crc(const std::span &bytes, uint8_t init = 0x00) { - for (const auto& byte:bytes) - init += byte; - return init; -} - -std::span inner(const std::span &buffer, Light::Id id, const std::span &light_data) { - if (id.kind() != 'L') - return {}; - - const auto src = light_data; - const auto hdr = subspan(buffer, 0, 2); - const auto dst = subspan(buffer, hdr.size()); - - if (hdr.size() == 0) - return {}; - - if (dst.size() < src.size()) - return {}; - - hdr[0] = 2 | (((0xfffffff & (light_data.size() + 1)) << 4)); - hdr[1] = id.value(); - std::copy(src.begin(), src.end(), dst.begin()); - - std::ranges::fill(subspan(dst, src.size()), 0); - - //ESP_LOGV(TAG, "%c%02hhX: inner: %s", id.kind(), id.value(), - // format_hex_pretty(&buffer[0], buffer.size()).c_str()); - - return buffer; -} - -std::span command(const std::span &buffer, const Key &key, Light::Id id, uint8_t seq, const std::span &light_data) { - if (buffer.empty()) - return {}; - - static constexpr Key DEFAULT_ENCRYPT_KEY = { 0x5e, 0x36, 0x7b, 0xc4 }; - - static constexpr uint8_t n = 5; // TODO - static constexpr bool forward = true; // TODO - - const auto header = subspan(buffer, 0, 4); - if (header.empty()) - return {}; - - const auto body = inner(subspan(buffer, header.size()), id, light_data); - if (body.empty()) - return {}; - - const auto result = subspan(buffer, 0, header.size() + body.size()); - if (result.empty()) - return {}; - - const uint8_t i2 = (id.value() / 256); - header[0] = (i2 & 0b1111) | ((n & 0b111) << 4) | (forward ? 0x80 : 0); - header[1] = seq; - header[2] = key[3]; - header[3] = header[0] + header[1] + header[2] + crc(body); - - //ESP_LOGV(TAG, "%c%02hhX: plain: %s", id.kind(), id.value(), - // format_hex_pretty(&result[0], result.size()).c_str()); - - DEFAULT_ENCRYPT_KEY(header); - key(body); - - //ESP_LOGV(TAG, "%c%02hhX: crypt: %s", id.kind(), id.value(), - // format_hex_pretty(&result[0], result.size()).c_str()); - - - return result; -} - -std::span payload(const std::span &buffer, const Key &key, Light::Id id, uint8_t seq, const std::span &light_data) { - constexpr Address ADDRESS = { 0xC1, 0xC2, 0xC3 }; - //constexpr std::array PREFIX = { 0x71, 0x0F, 0x55 }; - constexpr std::array PREFIX = { 0x8E, 0xF0, 0xAA }; - - const auto prefix = subspan(buffer, 0, PREFIX.size()); - if (prefix.empty()) - return {}; - - const auto address = subspan(buffer, prefix.size(), ADDRESS.size()); - if (address.empty()) - return {}; - - const auto command = brmesh::command(subspan(buffer, prefix.size() + address.size(), -2), key, id, seq, light_data); - if (command.empty()) - return {}; - - const auto crc = subspan(buffer, prefix.size() + address.size() + command.size(), 2); - if (crc.empty()) - return {}; - - //ESP_LOGD(TAG, "%c%02hhX: command: %s", id.kind(), id.value(), - // format_hex_pretty(&command[0], command.size()).c_str()); - - const auto result = subspan(buffer, 0, prefix.size() + address.size() + command.size() + crc.size()); - if (result.empty()) - return {}; - - const auto crc_guarded = subspan(buffer, prefix.size(), address.size() + command.size()); - if (crc_guarded.empty()) - return {}; - - // static values - std::copy(PREFIX.cbegin(), PREFIX.cend(), prefix.begin()); - - // copy - std::copy(ADDRESS.cbegin(), ADDRESS.cend(), address.begin()); - - uint16_t crc_value = ~crc16(&crc_guarded[0], crc_guarded.size(), 0xFFFF, true, true); - crc[0] = crc_value; - crc[1] = crc_value >> 8; - - //ESP_LOGV(TAG, "%c%02hhX: raw: %s", id.kind(), id.value(), - // format_hex_pretty(&result[0], result.size()).c_str()); - - //for (auto &byte:prefix) - // byte = reverse_bits(byte); - - //ESP_LOGV(TAG, "%c%02hhX: reverse: %s", id.kind(), id.value(), - // format_hex_pretty(&result[0], result.size()).c_str()); - - auto whitening = Whitening::from_val(0x7B); - whitening.encode(result); - - return result; -} - -std::span adv_data(const std::span &buffer, const Key &key, Light::Id id, uint8_t seq, const std::span &light_data) { - const auto flags = subspan(buffer, 0, 3); - if (flags.empty()) - return {}; - const auto length = subspan(buffer, flags.size(), 1); - if (length.empty()) - return {}; - const auto manufacturer = subspan(buffer, flags.size() + length.size(), 3); - - const auto payload = brmesh::payload(subspan(buffer, flags.size() + length.size() + manufacturer.size()), key, id, seq, light_data); - - if (payload.empty()) - return {}; - const auto result = subspan(buffer, 0, flags.size() + length.size() + manufacturer.size() + payload.size()); - - ESP_LOGD(TAG, "%c%02hhX: payload: %s", id.kind(), id.value(), - format_hex_pretty(&payload[0], payload.size()).c_str()); - - flags[0] = 2; - flags[1] = ESP_BLE_AD_TYPE_FLAG; - flags[2] = ESP_BLE_ADV_FLAG_BREDR_NOT_SPT | ESP_BLE_ADV_FLAG_GEN_DISC; - - length[0] = payload.size(); - - manufacturer[0] = ESP_BLE_AD_MANUFACTURER_SPECIFIC_TYPE; - manufacturer[1] = 0xF0; - manufacturer[2] = 0xFF; - - return result; -} - -} // namespace - -// interface: esphome::Component -void -Network::dump_config() { - ESP_LOGCONFIG(TAG, "%02hhx %02hhx %02hhx %02hhx", - key_[0], key_[1], key_[2], key_[3]); -} - -void -Network::setup() { - disable_loop(); -} - -void -Network::loop() { -} - -void -Network::advertise(Light::Id id, const std::span &payload) { - if (payload.size() == 0) { - advertise_= false; - // TODO: disable ble advertise - return; - } - - std::array buffer; - const auto data = adv_data(std::span(buffer), key_, id, seq_++, payload); - - ESP_LOGD(TAG, "%c%02hhX: advertise: %s", id.kind(), id.value(), - format_hex_pretty(&data[0], data.size()).c_str()); - - if (data.size() == 0) - return; - - auto err = ESP_OK; - err = esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_ADV, tx_power_); - if (err != ESP_OK) - ESP_LOGW(TAG, "%c%02hhX: tx power set: %s", id.kind(), id.value(), esp_err_to_name(err)); - - advertise_ = true; - err = esp_ble_gap_config_adv_data_raw(&data.front(), data.size()); - if (err != ESP_OK) - ESP_LOGE(TAG, "%c%02hhX: config adv: %s", id.kind(), id.value(), esp_err_to_name(err)); - else - ESP_LOGD(TAG, "%c%02hhX: config adv", id.kind(), id.value()); -} - -// interface: esphome::esp32_ble::GAPEventHandler -void -Network::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { - if (!advertise_) - return; - - esp_err_t err; - switch (event) { - case ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT: - ESP_LOGD(TAG, "adv data raw set"); - adv_start(adv_interval_min_, adv_interval_max_); - break; - - case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: - err = param->adv_start_cmpl.status; - if (err != ESP_BT_STATUS_SUCCESS) - // TODO: is param.status a valid esp_err_to_name parameter? - ESP_LOGE(TAG, "adv start: %s", esp_err_to_name(err)); - else - ESP_LOGD(TAG, "adv started"); - break; - - case ESP_GAP_BLE_ADV_STOP_COMPLETE_EVT: - ESP_LOGD(TAG, "adv stop"); - break; - } -} - -} // namespace brmesh -} // namespace esphome diff --git a/components/brmesh/network.h b/components/brmesh/network.h deleted file mode 100644 index 6d01831..0000000 --- a/components/brmesh/network.h +++ /dev/null @@ -1,43 +0,0 @@ -#pragma once -#include "key.h" -#include "light.h" - -#include "esphome/core/component.h" -#include "esphome/components/esp32_ble/ble.h" - -#include -#include - -namespace esphome { -namespace brmesh { - -class Network : public Component, public esp32_ble::GAPEventHandler { -public: - constexpr Network(const Key &key, uint16_t adv_interval_min, uint16_t adv_interval_max, esp_power_level_t tx_power) - : key_(key), adv_interval_min_(adv_interval_min / 0.625f), adv_interval_max_(adv_interval_max / 0.625f), tx_power_(tx_power) {} - - void advertise(Light::Id id, const std::span &payload); - - // interface: Component - void dump_config() override; - float get_setup_priority() const override { - return setup_priority::AFTER_BLUETOOTH; - } - void setup() override; - void loop() override; - - // interface: GAPEventHandler - void gap_event_handler(esp_gap_ble_cb_event_t, esp_ble_gap_cb_param_t *) override; - -private: - const Key key_; - const uint16_t adv_interval_min_; - const uint16_t adv_interval_max_; - const esp_power_level_t tx_power_{}; - uint8_t seq_ = 0; - - bool advertise_ = false; -}; - -} // namespace brmesh -} // namespace esphome diff --git a/components/brmesh/output.h b/components/brmesh/output.h deleted file mode 100644 index 51c8e31..0000000 --- a/components/brmesh/output.h +++ /dev/null @@ -1,35 +0,0 @@ -#pragma once - -namespace esphome { -namespace brmesh { - -class Output : public Component, public light::LightOutput -{ -public: - virtual const char *kind() const = 0; - virtual uint8_t id() const = 0; - - virtual std::span command(const std::span &output) const = 0; - - virtual void advertise(bool advertise) const; - - // interface: Component - void dump_config() override; - float get_setup_priority() const override { - return setup_priority::AFTER_BLUETOOTH; - } - void setup() override; - void loop() override; - - // interface: LightOutput - light::LightTraits get_traits() override; - void setup_state(light::LightState *state) override; - void update_state(light::LightState *state) override; - void write_state(light::LightState *state) override; - -private: - Network &network_; -}; - -} // namespace brmesh -} // namespace esphome diff --git a/components/brmesh/protocol.h b/components/brmesh/protocol.h new file mode 100644 index 0000000..aaee4c0 --- /dev/null +++ b/components/brmesh/protocol.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include +#include +#include "key.h" + +namespace esphome { +namespace brmesh { + +static constexpr Key DEFAULT_ENCRYPT_KEY = { 0x5e, 0x36, 0x7b, 0xc4 }; + +static const std::array DEFAULT_BLE_ADDRESS = {0xC1, 0xC2, 0xC3}; + +} // namespace brmesh +} // namespace esphome diff --git a/components/brmesh/q.h b/components/brmesh/q.h new file mode 100644 index 0000000..aedc24f --- /dev/null +++ b/components/brmesh/q.h @@ -0,0 +1,72 @@ +#pragma once + +namespace esphome { +namespace brmesh { + +template +class Q { +public: + class Node; + + constexpr Q() = default; + + bool enqueue(T &); + T *dequeue(); + T *peek(); +private: + Node *head_ = nullptr; + Node **tail_ = &head_; +}; + +template +class Q::Node { +public: + constexpr Node() = default; + + bool enqueue(Node *&head, Node **&tail); + static Node *dequeue(Node *&head, Node **&tail); +private: + Node *next_ = nullptr; +}; + +template +bool Q::enqueue(T &node) { + return static_cast(node).enqueue(head_, tail_); +} + +template +T *Q::dequeue() { + auto *node = Node::dequeue(head_, tail_); + return dynamic_cast(node); +} + +template +T *Q::peek() { + auto *node = head_; + return dynamic_cast(node); +} + + +template +bool Q::Node::enqueue(Node *&head, Node **&tail) { + const bool first = tail == &head; + *tail = this; + tail = &next_; + return first; +} + +template +Q::Node *Q::Node::dequeue(Node *&head, Node **&tail) { + auto *node = head; + if (node == nullptr) + return nullptr; + + head = node->next_; + if (head == nullptr) + tail = &head; + + return node; +} + +} // namespace brmesh +} // namespace esphome diff --git a/components/brmesh/whitening.cpp b/components/brmesh/whitening.cpp index 3658638..f174fd4 100644 --- a/components/brmesh/whitening.cpp +++ b/components/brmesh/whitening.cpp @@ -1,4 +1,5 @@ #include "whitening.h" +#include "debug.h" #include "esphome/core/log.h" #include "esphome/core/helpers.h" @@ -13,26 +14,15 @@ Whitening::State Whitening::use(bool encode, const LUT &lut, State state, std::span bytes) { const bool log = (bytes.size() > 1 || bytes[0] != 0x00); - if (log) - ESP_LOGW(TAG, "%s: input: (%02hhX) %s", encode ? "encode" : "decode", state, - format_hex_pretty(&bytes[0], bytes.size()).c_str()); - else - ESP_LOGVV(TAG, "%s: input: %s", encode ? "encode" : "decode", - format_hex_pretty(&bytes[0], bytes.size()).c_str()); - + hexdump(TAG, bytes, "%s: input: %s", encode ? "encode" : "decode"); for (auto &byte:bytes) { auto &next = lut[state]; byte = next.lut[byte]; state = next.state; } - if (log) - ESP_LOGV(TAG, "%s: output: (%02hhX) %s", encode ? "encode" : "decode", state, - format_hex_pretty(&bytes[0], bytes.size()).c_str()); - else - ESP_LOGVV(TAG, "%s: output: %s", encode ? "encode" : "decode", - format_hex_pretty(&bytes[0], bytes.size()).c_str()); + hexdump(TAG, bytes, "%s: output: %s", encode ? "encode" : "decode"); return state; } diff --git a/components/fastcon/__init__.py b/components/fastcon/__init__.py new file mode 100644 index 0000000..9b07c9a --- /dev/null +++ b/components/fastcon/__init__.py @@ -0,0 +1,3 @@ +from .fastcon_controller import CONFIG_SCHEMA, DEPENDENCIES, FastconController, to_code + +__all__ = ["CONFIG_SCHEMA", "DEPENDENCIES", "FastconController", "to_code"] diff --git a/components/fastcon/fastcon_controller.cpp b/components/fastcon/fastcon_controller.cpp new file mode 100644 index 0000000..4a0180e --- /dev/null +++ b/components/fastcon/fastcon_controller.cpp @@ -0,0 +1,275 @@ +#include "esphome/core/component_iterator.h" +#include "esphome/core/log.h" +#include "esphome/components/light/color_mode.h" +#include "fastcon_controller.h" +#include "protocol.h" + + +namespace esphome +{ + namespace fastcon + { + static const char *const TAG = "fastcon.controller"; + + void FastconController::queueCommand(uint32_t light_id_, const std::vector &data) + { + std::lock_guard lock(queue_mutex_); + if (queue_.size() >= max_queue_size_) + { + ESP_LOGW(TAG, "Command queue full (size=%d), dropping command for light %d", + queue_.size(), light_id_); + return; + } + + Command cmd; + cmd.data = data; + cmd.timestamp = millis(); + cmd.retries = 0; + + queue_.push(cmd); + ESP_LOGV(TAG, "Command queued, queue size: %d", queue_.size()); + } + + void FastconController::clear_queue() + { + std::lock_guard lock(queue_mutex_); + std::queue empty; + std::swap(queue_, empty); + } + + void FastconController::setup() + { + ESP_LOGCONFIG(TAG, "Setting up Fastcon BLE Controller..."); + ESP_LOGCONFIG(TAG, " Advertisement interval: %d-%d", this->adv_interval_min_, this->adv_interval_max_); + ESP_LOGCONFIG(TAG, " Advertisement duration: %dms", this->adv_duration_); + ESP_LOGCONFIG(TAG, " Advertisement gap: %dms", this->adv_gap_); + } + + void FastconController::loop() + { + const uint32_t now = millis(); + + switch (adv_state_) + { + case AdvertiseState::IDLE: + { + std::lock_guard lock(queue_mutex_); + if (queue_.empty()) + return; + + Command cmd = queue_.front(); + queue_.pop(); + + esp_ble_adv_params_t adv_params = { + .adv_int_min = adv_interval_min_, + .adv_int_max = adv_interval_max_, + .adv_type = ADV_TYPE_NONCONN_IND, + .own_addr_type = BLE_ADDR_TYPE_PUBLIC, + .peer_addr = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + .peer_addr_type = BLE_ADDR_TYPE_PUBLIC, + .channel_map = ADV_CHNL_ALL, + .adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY, + }; + + uint8_t adv_data_raw[31] = {0}; + uint8_t adv_data_len = 0; + + // Add flags + adv_data_raw[adv_data_len++] = 2; + adv_data_raw[adv_data_len++] = ESP_BLE_AD_TYPE_FLAG; + adv_data_raw[adv_data_len++] = ESP_BLE_ADV_FLAG_BREDR_NOT_SPT | ESP_BLE_ADV_FLAG_GEN_DISC; + + // Add manufacturer data + adv_data_raw[adv_data_len++] = cmd.data.size() + 2; + adv_data_raw[adv_data_len++] = ESP_BLE_AD_MANUFACTURER_SPECIFIC_TYPE; + adv_data_raw[adv_data_len++] = MANUFACTURER_DATA_ID & 0xFF; + adv_data_raw[adv_data_len++] = (MANUFACTURER_DATA_ID >> 8) & 0xFF; + + const auto str = format_hex_pretty(cmd.data); + ESP_LOGI(TAG, "cmd: %s", str.c_str()); + + memcpy(&adv_data_raw[adv_data_len], cmd.data.data(), cmd.data.size()); + adv_data_len += cmd.data.size(); + + const auto adv = format_hex_pretty(adv_data_raw); + ESP_LOGI(TAG, "adv: %s", adv.c_str()); + esp_err_t err = esp_ble_gap_config_adv_data_raw(adv_data_raw, adv_data_len); + if (err != ESP_OK) + { + ESP_LOGW(TAG, "Error setting raw advertisement data (err=%d): %s", err, esp_err_to_name(err)); + return; + } + + err = esp_ble_gap_start_advertising(&adv_params); + if (err != ESP_OK) + { + ESP_LOGW(TAG, "Error starting advertisement (err=%d): %s", err, esp_err_to_name(err)); + return; + } + + adv_state_ = AdvertiseState::ADVERTISING; + state_start_time_ = now; + ESP_LOGV(TAG, "Started advertising"); + break; + } + + case AdvertiseState::ADVERTISING: + { + if (now - state_start_time_ >= adv_duration_) + { + esp_ble_gap_stop_advertising(); + adv_state_ = AdvertiseState::GAP; + state_start_time_ = now; + ESP_LOGV(TAG, "Stopped advertising, entering gap period"); + } + break; + } + + case AdvertiseState::GAP: + { + if (now - state_start_time_ >= adv_gap_) + { + adv_state_ = AdvertiseState::IDLE; + ESP_LOGV(TAG, "Gap period complete"); + } + break; + } + } + } + + std::vector FastconController::get_light_data(light::LightState *state) + { + std::vector light_data = { + 0, // 0 - On/Off Bit + 7-bit Brightness + 0, // 1 - Blue byte + 0, // 2 - Red byte + 0, // 3 - Green byte + 0, // 4 - Warm byte + 0 // 5 - Cold byte + }; + + // TODO: need to figure out when esphome is changing to white vs setting brightness + + auto values = state->current_values; + + bool is_on = values.is_on(); + if (!is_on) + { + return std::vector({0x00}); + } + + auto color_mode = values.get_color_mode(); + bool has_white = (static_cast(color_mode) & static_cast(light::ColorCapability::WHITE)) != 0; + float brightness = std::min(values.get_brightness() * 127.0f, 127.0f); // clamp the value to at most 127 + light_data[0] = 0x80 + static_cast(brightness); + + if (has_white) + { + return std::vector({static_cast(brightness)}); + // DEBUG: when changing to white mode, this should be the payload: + // ff0000007f7f + } + + bool has_rgb = (static_cast(color_mode) & static_cast(light::ColorCapability::RGB)) != 0; + if (has_rgb) + { + light_data[1] = static_cast(values.get_blue() * 255.0f); + light_data[2] = static_cast(values.get_red() * 255.0f); + light_data[3] = static_cast(values.get_green() * 255.0f); + } + + bool has_cold_warm = (static_cast(color_mode) & static_cast(light::ColorCapability::COLD_WARM_WHITE)) != 0; + if (has_cold_warm) + { + light_data[4] = static_cast(values.get_warm_white() * 255.0f); + light_data[5] = static_cast(values.get_cold_white() * 255.0f); + } + + // TODO figure out if we can use these, and how + bool has_temp = (static_cast(color_mode) & static_cast(light::ColorCapability::COLOR_TEMPERATURE)) != 0; + if (has_temp) + { + float temperature = values.get_color_temperature(); + if (temperature < 153) + { + light_data[4] = 0xff; + light_data[5] = 0x00; + } + else if (temperature > 500) + { + light_data[4] = 0x00; + light_data[5] = 0xff; + } + else + { + // Linear interpolation between (153, 0xff) and (500, 0x00) + light_data[4] = (uint8_t)(((500 - temperature) * 255.0f + (temperature - 153) * 0x00) / (500 - 153)); + light_data[5] = (uint8_t)(((temperature - 153) * 255.0f + (500 - temperature) * 0x00) / (500 - 153)); + } + } + + return light_data; + } + + std::vector FastconController::single_control(uint32_t light_id_, const std::vector &light_data) + { + std::vector result_data(12); + + result_data[0] = 2 | (((0xfffffff & (light_data.size() + 1)) << 4)); + result_data[1] = light_id_; + std::copy(light_data.begin(), light_data.end(), result_data.begin() + 2); + + // Debug output - print payload as hex + auto hex_str = vector_to_hex_string(result_data).data(); + ESP_LOGD(TAG, "Inner Payload (%d bytes): %s", result_data.size(), hex_str); + + return this->generate_command(5, light_id_, result_data, true); + } + + std::vector FastconController::generate_command(uint8_t n, uint32_t light_id_, const std::vector &data, bool forward) + { + static uint8_t sequence = 0; + + // Create command body with header + std::vector body(data.size() + 4); + uint8_t i2 = (light_id_ / 256); + + // Construct header + body[0] = (i2 & 0b1111) | ((n & 0b111) << 4) | (forward ? 0x80 : 0); + body[1] = sequence++; // Use and increment sequence number + if (sequence >= 255) + sequence = 1; + + body[2] = this->mesh_key_[3]; // Safe key + + // Copy data + std::copy(data.begin(), data.end(), body.begin() + 4); + + // Calculate checksum + uint8_t checksum = 0; + for (size_t i = 0; i < body.size(); i++) + { + if (i != 3) + { + checksum = checksum + body[i]; + } + } + body[3] = checksum; + + // Encrypt header and data + for (size_t i = 0; i < 4; i++) + { + body[i] = DEFAULT_ENCRYPT_KEY[i & 3] ^ body[i]; + } + + for (size_t i = 0; i < data.size(); i++) + { + body[4 + i] = this->mesh_key_[i & 3] ^ body[4 + i]; + } + + // Prepare the final payload with RF protocol formatting + std::vector addr = {DEFAULT_BLE_FASTCON_ADDRESS.begin(), DEFAULT_BLE_FASTCON_ADDRESS.end()}; + return prepare_payload(addr, body); + } + } // namespace fastcon +} // namespace esphome diff --git a/components/fastcon/fastcon_controller.h b/components/fastcon/fastcon_controller.h new file mode 100644 index 0000000..cf78107 --- /dev/null +++ b/components/fastcon/fastcon_controller.h @@ -0,0 +1,90 @@ +#pragma once + +#include +#include +#include +#include "esphome/core/component.h" +#include "esphome/components/esp32_ble/ble.h" + +namespace esphome +{ + namespace fastcon + { + + class FastconController : public Component, public esp32_ble::GAPEventHandler + { + public: + FastconController() = default; + + void setup() override; + void loop() override; + + std::vector get_light_data(light::LightState *state); + std::vector single_control(uint32_t addr, const std::vector &light_data); + + void queueCommand(uint32_t light_id_, const std::vector &data); + + void clear_queue(); + bool is_queue_empty() const + { + std::lock_guard lock(queue_mutex_); + return queue_.empty(); + } + size_t get_queue_size() const + { + std::lock_guard lock(queue_mutex_); + return queue_.size(); + } + void set_max_queue_size(size_t size) { max_queue_size_ = size; } + + void set_mesh_key(std::array key) { mesh_key_ = key; } + void set_adv_interval_min(uint16_t val) { adv_interval_min_ = val; } + void set_adv_interval_max(uint16_t val) + { + adv_interval_max_ = val; + if (adv_interval_max_ < adv_interval_min_) + { + adv_interval_max_ = adv_interval_min_; + } + } + void set_adv_duration(uint16_t val) { adv_duration_ = val; } + void set_adv_gap(uint16_t val) { adv_gap_ = val; } + + protected: + struct Command + { + std::vector data; + uint32_t timestamp; + uint8_t retries{0}; + static constexpr uint8_t MAX_RETRIES = 3; + }; + + std::queue queue_; + mutable std::mutex queue_mutex_; + size_t max_queue_size_{100}; + + enum class AdvertiseState + { + IDLE, + ADVERTISING, + GAP + }; + + AdvertiseState adv_state_{AdvertiseState::IDLE}; + uint32_t state_start_time_{0}; + + // Protocol implementation + std::vector generate_command(uint8_t n, uint32_t light_id_, const std::vector &data, bool forward = true); + + std::array mesh_key_{}; + + uint16_t adv_interval_min_{0x20}; + uint16_t adv_interval_max_{0x40}; + uint16_t adv_duration_{50}; + uint16_t adv_gap_{10}; + + static const uint16_t MANUFACTURER_DATA_ID = 0xfff0; + }; + + } // namespace fastcon +} // namespace esphome diff --git a/components/fastcon/fastcon_controller.py b/components/fastcon/fastcon_controller.py new file mode 100644 index 0000000..a3361e1 --- /dev/null +++ b/components/fastcon/fastcon_controller.py @@ -0,0 +1,78 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.core import HexInt + +DEPENDENCIES = ["esp32_ble"] + +CONF_MESH_KEY = "mesh_key" +CONF_ADV_INTERVAL_MIN = "adv_interval_min" +CONF_ADV_INTERVAL_MAX = "adv_interval_max" +CONF_ADV_DURATION = "adv_duration" +CONF_ADV_GAP = "adv_gap" +CONF_MAX_QUEUE_SIZE = "max_queue_size" + +DEFAULT_ADV_INTERVAL_MIN = 0x20 +DEFAULT_ADV_INTERVAL_MAX = 0x40 +DEFAULT_ADV_DURATION = 50 +DEFAULT_ADV_GAP = 10 +DEFAULT_MAX_QUEUE_SIZE = 100 + + +def validate_hex_bytes(value): + if isinstance(value, str): + value = value.replace(" ", "") + if len(value) != 8: + raise cv.Invalid("Mesh key must be exactly 4 bytes (8 hex characters)") + + try: + return HexInt(int(value, 16)) + except ValueError as err: + raise cv.Invalid(f"Invalid hex value: {err}") + raise cv.Invalid("Mesh key must be a string") + + +fastcon_ns = cg.esphome_ns.namespace("fastcon") +FastconController = fastcon_ns.class_("FastconController", cg.Component) + +CONFIG_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ID, default="fastcon_controller"): cv.declare_id( + FastconController + ), + cv.Required(CONF_MESH_KEY): validate_hex_bytes, + cv.Optional( + CONF_ADV_INTERVAL_MIN, default=DEFAULT_ADV_INTERVAL_MIN + ): cv.uint16_t, + cv.Optional( + CONF_ADV_INTERVAL_MAX, default=DEFAULT_ADV_INTERVAL_MAX + ): cv.uint16_t, + cv.Optional(CONF_ADV_DURATION, default=DEFAULT_ADV_DURATION): cv.uint16_t, + cv.Optional(CONF_ADV_GAP, default=DEFAULT_ADV_GAP): cv.uint16_t, + cv.Optional( + CONF_MAX_QUEUE_SIZE, default=DEFAULT_MAX_QUEUE_SIZE + ): cv.positive_int, + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + if CONF_MESH_KEY in config: + mesh_key = config[CONF_MESH_KEY] + key_bytes = [(mesh_key >> (i * 8)) & 0xFF for i in range(3, -1, -1)] + cg.add(var.set_mesh_key(key_bytes)) + + if config[CONF_ADV_INTERVAL_MAX] < config[CONF_ADV_INTERVAL_MIN]: + raise cv.Invalid( + f"adv_interval_max ({config[CONF_ADV_INTERVAL_MAX]}) must be >= " + f"adv_interval_min ({config[CONF_ADV_INTERVAL_MIN]})" + ) + + cg.add(var.set_adv_interval_min(config[CONF_ADV_INTERVAL_MIN])) + cg.add(var.set_adv_interval_max(config[CONF_ADV_INTERVAL_MAX])) + cg.add(var.set_adv_duration(config[CONF_ADV_DURATION])) + cg.add(var.set_adv_gap(config[CONF_ADV_GAP])) + cg.add(var.set_max_queue_size(config[CONF_MAX_QUEUE_SIZE])) diff --git a/components/fastcon/fastcon_light.cpp b/components/fastcon/fastcon_light.cpp new file mode 100644 index 0000000..af7a983 --- /dev/null +++ b/components/fastcon/fastcon_light.cpp @@ -0,0 +1,71 @@ +#include +#include "esphome/core/log.h" +#include "fastcon_light.h" +#include "fastcon_controller.h" +#include "utils.h" + +namespace esphome +{ + namespace fastcon + { + static const char *const TAG = "fastcon.light"; + + void FastconLight::setup() + { + if (this->controller_ == nullptr) + { + ESP_LOGE(TAG, "Controller not set for light %d!", this->light_id_); + this->mark_failed(); + return; + } + ESP_LOGCONFIG(TAG, "Setting up Fastcon BLE light (ID: %d)...", this->light_id_); + } + + void FastconLight::set_controller(FastconController *controller) + { + this->controller_ = controller; + } + + light::LightTraits FastconLight::get_traits() + { + auto traits = light::LightTraits(); + traits.set_supported_color_modes({light::ColorMode::RGB, light::ColorMode::WHITE, light::ColorMode::BRIGHTNESS, light::ColorMode::COLD_WARM_WHITE}); + traits.set_min_mireds(153); + traits.set_max_mireds(500); + return traits; + } + + void FastconLight::write_state(light::LightState *state) + { + // Get the light data bits from the state + auto light_data = this->controller_->get_light_data(state); + + // Debug output - print the light state values + bool is_on = (light_data[0] & 0x80) != 0; + float brightness = ((light_data[0] & 0x7F) / 127.0f) * 100.0f; + if (light_data.size() == 1) + { + ESP_LOGD(TAG, "Writing state: light_id=%d, on=%d, brightness=%.1f%%", light_id_, is_on, brightness); + } + else + { + auto r = light_data[2]; + auto g = light_data[3]; + auto b = light_data[1]; + auto warm = light_data[4]; + auto cold = light_data[5]; + ESP_LOGD(TAG, "Writing state: light_id=%d, on=%d, brightness=%.1f%%, rgb=(%d,%d,%d), warm=%d, cold=%d", light_id_, is_on, brightness, r, g, b, warm, cold); + } + + // Generate the advertisement payload + auto adv_data = this->controller_->single_control(this->light_id_, light_data); + + // Debug output - print payload as hex + auto hex_str = vector_to_hex_string(adv_data).data(); + ESP_LOGD(TAG, "Advertisement Payload (%d bytes): %s", adv_data.size(), hex_str); + + // Send the advertisement + this->controller_->queueCommand(this->light_id_, adv_data); + } + } // namespace fastcon +} // namespace esphome diff --git a/components/fastcon/fastcon_light.h b/components/fastcon/fastcon_light.h new file mode 100644 index 0000000..bf583e2 --- /dev/null +++ b/components/fastcon/fastcon_light.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include "esphome/core/component.h" +#include "esphome/components/light/light_output.h" +#include "fastcon_controller.h" + +namespace esphome +{ + namespace fastcon + { + enum class LightState + { + OFF, + WARM_WHITE, + RGB + }; + + class FastconLight : public Component, public light::LightOutput + { + public: + FastconLight(uint8_t light_id) : light_id_(light_id) {} + + void setup() override; + light::LightTraits get_traits() override; + void write_state(light::LightState *state) override; + void set_controller(FastconController *controller); + + protected: + FastconController *controller_{nullptr}; + uint8_t light_id_; + }; + } // namespace fastcon +} // namespace esphome diff --git a/components/fastcon/light.py b/components/fastcon/light.py new file mode 100644 index 0000000..3ff7da4 --- /dev/null +++ b/components/fastcon/light.py @@ -0,0 +1,37 @@ +"""Light platform for Fastcon BLE lights.""" + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import light +from esphome.const import CONF_LIGHT_ID, CONF_OUTPUT_ID + +from .fastcon_controller import FastconController + +DEPENDENCIES = ["esp32_ble"] +AUTO_LOAD = ["light"] + +CONF_CONTROLLER_ID = "controller_id" + +fastcon_ns = cg.esphome_ns.namespace("fastcon") +FastconLight = fastcon_ns.class_("FastconLight", light.LightOutput, cg.Component) + +CONFIG_SCHEMA = cv.All( + light.BRIGHTNESS_ONLY_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(FastconLight), + cv.Required(CONF_LIGHT_ID): cv.int_range(min=1, max=255), + cv.Optional(CONF_CONTROLLER_ID, default="fastcon_controller"): cv.use_id( + FastconController + ), + } + ).extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_OUTPUT_ID], config[CONF_LIGHT_ID]) + await cg.register_component(var, config) + await light.register_light(var, config) + + controller = await cg.get_variable(config[CONF_CONTROLLER_ID]) + cg.add(var.set_controller(controller)) diff --git a/components/fastcon/protocol.cpp b/components/fastcon/protocol.cpp new file mode 100644 index 0000000..549b4be --- /dev/null +++ b/components/fastcon/protocol.cpp @@ -0,0 +1,60 @@ +#include +#include "protocol.h" + +namespace esphome +{ + namespace fastcon + { + std::vector get_rf_payload(const std::vector &addr, const std::vector &data) + { + const size_t data_offset = 0x12; + const size_t inverse_offset = 0x0f; + const size_t result_data_size = data_offset + addr.size() + data.size(); + + // Create result buffer including space for checksum + std::vector resultbuf(result_data_size + 2, 0); + + // Set hardcoded values + resultbuf[0x0f] = 0x71; + resultbuf[0x10] = 0x0f; + resultbuf[0x11] = 0x55; + + // Copy address in reverse + for (size_t i = 0; i < addr.size(); i++) + { + resultbuf[data_offset + addr.size() - i - 1] = addr[i]; + } + + // Copy data + std::copy(data.begin(), data.end(), resultbuf.begin() + data_offset + addr.size()); + + // Reverse bytes in specified range + for (size_t i = inverse_offset; i < inverse_offset + addr.size() + 3; i++) + { + resultbuf[i] = reverse_8(resultbuf[i]); + } + + // Add CRC + uint16_t crc = crc16(addr, data); + resultbuf[result_data_size] = crc & 0xFF; + resultbuf[result_data_size + 1] = (crc >> 8) & 0xFF; + + return resultbuf; + } + + std::vector prepare_payload(const std::vector &addr, const std::vector &data) + { + auto payload = get_rf_payload(addr, data); + + // Initialize whitening + WhiteningContext context; + whitening_init(0x25, context); + + // Apply whitening to the payload + whitening_encode(payload, context); + + // Return only the portion after 0xf bytes + return std::vector(payload.begin() + 0xf, payload.end()); + } + } // namespace fastcon +} // namespace esphome \ No newline at end of file diff --git a/components/fastcon/protocol.h b/components/fastcon/protocol.h new file mode 100644 index 0000000..930acab --- /dev/null +++ b/components/fastcon/protocol.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include +#include +#include "utils.h" + +namespace esphome +{ + namespace fastcon + { + static const std::array DEFAULT_ENCRYPT_KEY = {0x5e, 0x36, 0x7b, 0xc4}; + static const std::array DEFAULT_BLE_FASTCON_ADDRESS = {0xC1, 0xC2, 0xC3}; + + std::vector get_rf_payload(const std::vector &addr, const std::vector &data); + std::vector prepare_payload(const std::vector &addr, const std::vector &data); + } // namespace fastcon +} // namespace esphome \ No newline at end of file diff --git a/components/fastcon/utils.cpp b/components/fastcon/utils.cpp new file mode 100644 index 0000000..371ae09 --- /dev/null +++ b/components/fastcon/utils.cpp @@ -0,0 +1,126 @@ +#include +#include +#include "esphome/core/log.h" +#include "utils.h" + +namespace esphome +{ + namespace fastcon + { + uint8_t reverse_8(uint8_t d) + { + uint8_t result = 0; + for (int i = 0; i < 8; i++) + { + result |= ((d >> i) & 1) << (7 - i); + } + return result; + } + + uint16_t reverse_16(uint16_t d) + { + uint16_t result = 0; + for (int i = 0; i < 16; i++) + { + result |= ((d >> i) & 1) << (15 - i); + } + return result; + } + + uint16_t crc16(const std::vector &addr, const std::vector &data) + { + uint16_t crc = 0xffff; + + // Process address in reverse + for (auto it = addr.rbegin(); it != addr.rend(); ++it) + { + crc ^= (static_cast(*it) << 8); + for (int j = 0; j < 4; j++) + { + uint16_t tmp = crc << 1; + if (crc & 0x8000) + { + tmp ^= 0x1021; + } + crc = tmp << 1; + if (tmp & 0x8000) + { + crc ^= 0x1021; + } + } + } + + // Process data + for (size_t i = 0; i < data.size(); i++) + { + crc ^= (static_cast(reverse_8(data[i])) << 8); + for (int j = 0; j < 4; j++) + { + uint16_t tmp = crc << 1; + if (crc & 0x8000) + { + tmp ^= 0x1021; + } + crc = tmp << 1; + if (tmp & 0x8000) + { + crc ^= 0x1021; + } + } + } + + crc = ~reverse_16(crc); + return crc; + } + + void whitening_init(uint32_t val, WhiteningContext &ctx) + { + uint32_t v0[] = {(val >> 5), (val >> 4), (val >> 3), (val >> 2)}; + + ctx.f_0x0 = 1; + ctx.f_0x4 = v0[0] & 1; + ctx.f_0x8 = v0[1] & 1; + ctx.f_0xc = v0[2] & 1; + ctx.f_0x10 = v0[3] & 1; + ctx.f_0x14 = (val >> 1) & 1; + ctx.f_0x18 = val & 1; + } + + void whitening_encode(std::vector &data, WhiteningContext &ctx) + { + for (size_t i = 0; i < data.size(); i++) + { + uint32_t varC = ctx.f_0xc; + uint32_t var14 = ctx.f_0x14; + uint32_t var18 = ctx.f_0x18; + uint32_t var10 = ctx.f_0x10; + uint32_t var8 = var14 ^ ctx.f_0x8; + uint32_t var4 = var10 ^ ctx.f_0x4; + uint32_t _var = var18 ^ varC; + uint32_t var0 = _var ^ ctx.f_0x0; + + uint8_t c = data[i]; + data[i] = ((c & 0x80) ^ ((var8 ^ var18) << 7)) + ((c & 0x40) ^ (var0 << 6)) + ((c & 0x20) ^ (var4 << 5)) + ((c & 0x10) ^ (var8 << 4)) + ((c & 0x08) ^ (_var << 3)) + ((c & 0x04) ^ (var10 << 2)) + ((c & 0x02) ^ (var14 << 1)) + ((c & 0x01) ^ (var18 << 0)); + + ctx.f_0x8 = var4; + ctx.f_0xc = var8; + ctx.f_0x10 = var8 ^ varC; + ctx.f_0x14 = var0 ^ var10; + ctx.f_0x18 = var4 ^ var14; + ctx.f_0x0 = var8 ^ var18; + ctx.f_0x4 = var0; + } + } + + std::vector vector_to_hex_string(std::vector &data) + { + std::vector hex_str(data.size() * 2 + 1); // Allocate the vector with the required size + for (size_t i = 0; i < data.size(); i++) + { + sprintf(hex_str.data() + (i * 2), "%02X", data[i]); + } + hex_str[data.size() * 2] = '\0'; // Ensure null termination + return hex_str; + } + } // namespace fastcon +} // namespace esphome \ No newline at end of file diff --git a/components/fastcon/utils.h b/components/fastcon/utils.h new file mode 100644 index 0000000..c243d9c --- /dev/null +++ b/components/fastcon/utils.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +namespace esphome +{ + namespace fastcon + { + // Bit manipulation utilities + uint8_t reverse_8(uint8_t d); + uint16_t reverse_16(uint16_t d); + + // CRC calculation + uint16_t crc16(const std::vector &addr, const std::vector &data); + + // Whitening context and functions + struct WhiteningContext + { + uint32_t f_0x0; + uint32_t f_0x4; + uint32_t f_0x8; + uint32_t f_0xc; + uint32_t f_0x10; + uint32_t f_0x14; + uint32_t f_0x18; + + WhiteningContext() : f_0x0(0), f_0x4(0), f_0x8(0), f_0xc(0), f_0x10(0), f_0x14(0), f_0x18(0) {} + }; + + void whitening_init(uint32_t val, WhiteningContext &ctx); + void whitening_encode(std::vector &data, WhiteningContext &ctx); + + std::vector vector_to_hex_string(std::vector &data); + } // namespace fastcon +} // namespace esphome \ No newline at end of file