initial commit

This commit is contained in:
Dennis George 2025-02-07 21:37:53 -06:00
commit 4417c77875
18 changed files with 3522 additions and 0 deletions

177
.gitignore vendored Normal file
View file

@ -0,0 +1,177 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# PyPI configuration file
.pypirc
.esphome
.pio
.vscode/c_cpp_properties.json
.vscode/launch.json
secrets.yaml

11
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,11 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"charliermarsh.ruff",
"platformio.platformio-ide"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack"
]
}

71
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,71 @@
{
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
}
},
"ruff.nativeServer": true,
"files.associations": {
"*.css": "tailwindcss",
"*.pgsql": "postgres",
"array": "cpp",
"bitset": "cpp",
"string_view": "cpp",
"initializer_list": "cpp",
"utility": "cpp",
"string": "cpp",
"atomic": "cpp",
"*.tcc": "cpp",
"cctype": "cpp",
"chrono": "cpp",
"cinttypes": "cpp",
"clocale": "cpp",
"cmath": "cpp",
"condition_variable": "cpp",
"cstdarg": "cpp",
"cstddef": "cpp",
"cstdint": "cpp",
"cstdio": "cpp",
"cstdlib": "cpp",
"cstring": "cpp",
"ctime": "cpp",
"cwchar": "cpp",
"cwctype": "cpp",
"deque": "cpp",
"unordered_map": "cpp",
"unordered_set": "cpp",
"vector": "cpp",
"exception": "cpp",
"algorithm": "cpp",
"functional": "cpp",
"iterator": "cpp",
"map": "cpp",
"memory": "cpp",
"memory_resource": "cpp",
"numeric": "cpp",
"optional": "cpp",
"random": "cpp",
"ratio": "cpp",
"regex": "cpp",
"set": "cpp",
"system_error": "cpp",
"tuple": "cpp",
"type_traits": "cpp",
"fstream": "cpp",
"iomanip": "cpp",
"iosfwd": "cpp",
"iostream": "cpp",
"istream": "cpp",
"limits": "cpp",
"mutex": "cpp",
"new": "cpp",
"ostream": "cpp",
"sstream": "cpp",
"stdexcept": "cpp",
"streambuf": "cpp",
"thread": "cpp",
"typeinfo": "cpp"
}
}

107
README.md Normal file
View file

@ -0,0 +1,107 @@
# ESPHome Fastcon BLE Light Component
This is a custom component for ESPHome that allows you to control Broadlink Fastcon BLE lights, also known as brMesh. It should work with any light that can be controlled by brMesh or Broadlink BLE mobile apps.
Be warned - there is also a brLight app, which might look like brMesh, but the protocol is different.
## Requirements
- ESP32 board
- ESPHome 2023.12.0 or newer
## Supported Features
- On/Off control
- Brightness control
- RGB color control
- White mode
## Configuration
Add the following to your ESPHome configuration:
```yaml
# ESP32 is required
esp32:
board: esp32-s3-devkitc-1
framework:
type: arduino
esp32_ble_tracker:
esp32_ble_server:
# Source configuration
external_components:
- source: github://dennispg/esphome-fastcon@main
# Controller configuration
fastcon:
mesh_key: "12345678" # Your mesh key in hex format
# Optional parameters to control the advertisdement protocol with their defaults:
adv_interval_min: 0x20 # Minimum advertisement interval
adv_interval_max: 0x40 # Maximum advertisement interval
adv_duration: 50 # Advertisement duration in milliseconds
adv_gap: 10 # Gap between advertisements in milliseconds
max_queue_size: 100 # Maximum number of queued commands
# Light configuration (add an entry for each light)
light:
- platform: fastcon
id: living_room_light
name: "Living Room Light"
light_id: 1 # ID of the light (1-255)
```
### Configuration Variables
#### Fastcon Controller
- **mesh_key** (*Required*, string): The mesh key for your Fastcon lights in hexadecimal format (8 characters/4 bytes)
- **id** (*Optional*, ID): The ID to use for this controller component. Defaults to "fastcon_controller"
- **adv_interval_min** (*Optional*, int): Minimum advertisement interval. Defaults to 0x20
- **adv_interval_max** (*Optional*, int): Maximum advertisement interval. Defaults to 0x40
- **adv_duration** (*Optional*, int): Duration of each advertisement in milliseconds. Defaults to 50
- **adv_gap** (*Optional*, int): Gap between advertisements in milliseconds. Defaults to 10
- **max_queue_size** (*Optional*, int): Maximum number of commands that can be queued. Defaults to 100
#### Fastcon Light
- **light_id** (*Required*, int): The ID of the light (1-255)
- **name** (*Required*, string): The name for the light entity
- **id** (*Optional*, ID): The ID to use for this light component
- **controller_id** (*Optional*, ID): The ID of the controller to use. Defaults to "fastcon_controller"
## Finding Your Mesh Key
The mesh key is crucial for controlling your Fastcon BLE lights. To find your light's mesh key, you first need to setup your devices using an Android device. The app generates a unique mesh key that will be used with all lights that are set up in the app.
Once the lights are setup, you can use ADB to connect to your phone and you may use the following command to extract the mesh key.
```bash
adb logcat | { grep -m 1 -o 'jyq_helper: .* payload:.\{24\},[[:space:]]*key:[[:space:]]*.\{8\}' | awk '{print $NF}'; kill -2 $(pgrep -P $$ adb); }
```
While running the above, open the app and toggle a light on and off. The command should then output your mesh key.
## Acknowledgments
This component builds upon the reverse engineering and hard work of several others who must be acknowledged and thanked:
### Protocol Reverse Engineering
The foundational protocol reverse engineering work was done by [Mooody](https://mooody.me/posts/2023-04/reverse-the-fastcon-ble-protocol/), who provided detailed analysis of the Fastcon BLE protocol, including packet structure and encryption methods. https://mooody.me/posts/2023-04/reverse-the-fastcon-ble-protocol/
### Implementation References
- [ArcadeMachinist's brMeshMQTT](https://github.com/ArcadeMachinist/brMeshMQTT) - This work was crucial in helping me understand the practical implementation details of the protocol. https://github.com/ArcadeMachinist/brMeshMQTT
### Community Resources
- [Home Assistant Community Thread](https://community.home-assistant.io/t/brmesh-app-bluetooth-lights/473486/102)
This ESPHome component adapts and/or takes heavy inspiration from all of these works to run directly on ESP32 devices, allowing for native integration with Home Assistant without requiring additional bridges or MQTT brokers. A huge thank you to all those who contributed to my understanding of the Fastcon BLE protocol.
## License
This project is licensed under the MIT License - see the LICENSE file for details.

View file

@ -0,0 +1,3 @@
from .fastcon_controller import CONFIG_SCHEMA, DEPENDENCIES, FastconController, to_code
__all__ = ["CONFIG_SCHEMA", "DEPENDENCIES", "FastconController", "to_code"]

View file

@ -0,0 +1,228 @@
#include "esphome/core/component_iterator.h"
#include "esphome/core/log.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<uint8_t> &data)
{
std::lock_guard<std::mutex> 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<std::mutex> lock(queue_mutex_);
std::queue<Command> 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<std::mutex> 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<uint8_t> FastconController::get_advertisement(uint32_t light_id_, bool is_on, float brightness, float red, float green, float blue)
{
std::vector<uint8_t> light_data;
// Convert brightness to 0-127 range
uint8_t bright = static_cast<uint8_t>(std::min(brightness * 127.0f, 127.0f));
if (!is_on)
{
// Off state
light_data = {static_cast<uint8_t>(0)}; // Just the off command
}
else if (red == 0 && green == 0 && blue == 0)
{
// Warm white mode
light_data = std::vector<uint8_t>{
static_cast<uint8_t>(128 + bright), // On bit (128) + brightness
0, 0, 0, // RGB values
127, 127 // Warm/cold values
};
}
else
{
// RGB mode
uint8_t r = static_cast<uint8_t>(red * 255.0f);
uint8_t g = static_cast<uint8_t>(green * 255.0f);
uint8_t b = static_cast<uint8_t>(blue * 255.0f);
light_data = std::vector<uint8_t>{
static_cast<uint8_t>(128 + bright), // On bit (128) + brightness
b, r, g, // RGB values (in BRG order per protocol)
0, 0 // No warm/cold values in RGB mode
};
}
return this->single_control(light_id_, light_data);
}
std::vector<uint8_t> FastconController::single_control(uint32_t light_id_, const std::vector<uint8_t> &data)
{
std::vector<uint8_t> result_data(12);
result_data[0] = 2 | (((0xfffffff & (data.size() + 1)) << 4));
result_data[1] = light_id_;
std::copy(data.begin(), data.end(), result_data.begin() + 2);
return this->generate_command(5, light_id_, result_data, true);
}
std::vector<uint8_t> FastconController::generate_command(uint8_t n, uint32_t light_id_, const std::vector<uint8_t> &data, bool forward)
{
static uint8_t sequence = 0;
// Create command body with header
std::vector<uint8_t> 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<uint8_t> addr = {DEFAULT_BLE_FASTCON_ADDRESS.begin(), DEFAULT_BLE_FASTCON_ADDRESS.end()};
return prepare_payload(addr, body);
}
} // namespace fastcon
} // namespace esphome

View file

@ -0,0 +1,89 @@
#pragma once
#include <queue>
#include <mutex>
#include <vector>
#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;
void queueCommand(uint32_t light_id_, const std::vector<uint8_t> &data);
std::vector<uint8_t> get_advertisement(uint32_t light_id_, bool is_on, float brightness, float red, float green, float blue);
void clear_queue();
bool is_queue_empty() const
{
std::lock_guard<std::mutex> lock(queue_mutex_);
return queue_.empty();
}
size_t get_queue_size() const
{
std::lock_guard<std::mutex> lock(queue_mutex_);
return queue_.size();
}
void set_max_queue_size(size_t size) { max_queue_size_ = size; }
void set_mesh_key(std::array<uint8_t, 4> 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<uint8_t> data;
uint32_t timestamp;
uint8_t retries{0};
static constexpr uint8_t MAX_RETRIES = 3;
};
std::queue<Command> 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<uint8_t> generate_command(uint8_t n, uint32_t light_id_, const std::vector<uint8_t> &data, bool forward = true);
std::vector<uint8_t> single_control(uint32_t addr, const std::vector<uint8_t> &data);
std::array<uint8_t, 4> 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

View file

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

View file

@ -0,0 +1,79 @@
#include <algorithm>
#include "esphome/core/log.h"
#include "fastcon_light.h"
#include "fastcon_controller.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});
traits.set_min_mireds(153);
traits.set_max_mireds(500);
return traits;
}
void FastconLight::write_state(light::LightState *state)
{
float red = 0.0f, green = 0.0f, blue = 0.0f;
// Get the light state values
float brightness = state->current_values.get_brightness() * 127.0f; // Scale to 0-127
bool is_on = state->current_values.is_on();
auto color_mode = state->current_values.get_color_mode();
if (!is_on)
{
brightness = 0.0f;
}
if (color_mode == light::ColorMode::RGB)
{
state->current_values_as_rgb(&red, &green, &blue);
}
// Convert to protocol values
auto r = static_cast<uint8_t>(red * 255.0f);
auto g = static_cast<uint8_t>(green * 255.0f);
auto b = static_cast<uint8_t>(blue * 255.0f);
ESP_LOGD(TAG, "Writing state: light_id=%d, on=%d, brightness=%.1f%%, rgb=(%d,%d,%d)", light_id_, is_on, (brightness / 127.0f) * 100.0f, r, g, b);
// Get the advertisement data
auto adv_data = this->controller_->get_advertisement(this->light_id_, is_on, brightness, red, green, blue);
// Debug output - print payload as hex
char hex_str[adv_data.size() * 2 + 1]; // Each byte needs 2 chars + null terminator
for (size_t i = 0; i < adv_data.size(); i++)
{
sprintf(hex_str + (i * 2), "%02X", adv_data[i]);
}
hex_str[adv_data.size() * 2] = '\0'; // Ensure null termination
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

View file

@ -0,0 +1,35 @@
#pragma once
#include <array>
#include <vector>
#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

View file

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

View file

@ -0,0 +1,60 @@
#include <algorithm>
#include "protocol.h"
namespace esphome
{
namespace fastcon
{
std::vector<uint8_t> get_rf_payload(const std::vector<uint8_t> &addr, const std::vector<uint8_t> &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<uint8_t> 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<uint8_t> prepare_payload(const std::vector<uint8_t> &addr, const std::vector<uint8_t> &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<uint8_t>(payload.begin() + 0xf, payload.end());
}
} // namespace fastcon
} // namespace esphome

View file

@ -0,0 +1,18 @@
#pragma once
#include <vector>
#include <array>
#include <cstdint>
#include "utils.h"
namespace esphome
{
namespace fastcon
{
static const std::array<uint8_t, 4> DEFAULT_ENCRYPT_KEY = {0x5e, 0x36, 0x7b, 0xc4};
static const std::array<uint8_t, 3> DEFAULT_BLE_FASTCON_ADDRESS = {0xC1, 0xC2, 0xC3};
std::vector<uint8_t> get_rf_payload(const std::vector<uint8_t> &addr, const std::vector<uint8_t> &data);
std::vector<uint8_t> prepare_payload(const std::vector<uint8_t> &addr, const std::vector<uint8_t> &data);
} // namespace fastcon
} // namespace esphome

View file

@ -0,0 +1,113 @@
#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<uint8_t> &addr, const std::vector<uint8_t> &data)
{
uint16_t crc = 0xffff;
// Process address in reverse
for (auto it = addr.rbegin(); it != addr.rend(); ++it)
{
crc ^= (static_cast<uint16_t>(*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<uint16_t>(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<uint8_t> &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;
}
}
} // namespace fastcon
} // namespace esphome

View file

@ -0,0 +1,34 @@
#pragma once
#include <cstdint>
#include <vector>
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<uint8_t> &addr, const std::vector<uint8_t> &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<uint8_t> &data, WhiteningContext &ctx);
} // namespace fastcon
} // namespace esphome

71
platformio.ini Normal file
View file

@ -0,0 +1,71 @@
; PlatformIO Project Configuration File
;
; Build options: build flags, source filter
; Upload options: custom upload port, speed and extra flags
; Library options: dependencies, extra library storages
; Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html
[platformio]
default_envs = esp32-arduino
include_dir = components/**
[flags:runtime]
build_flags =
-Wno-unused-but-set-variable
-Wno-sign-compare
-I.venv/lib/python3.13/site-packages
[common]
build_flags =
-DESPHOME_LOG_LEVEL=ESPHOME_LOG_LEVEL_VERY_VERBOSE
[common:arduino]
extends = common
build_flags =
${common.build_flags}
-DUSE_ARDUINO
lib_deps = bblanchon/ArduinoJson@^7.3.0
[common:esp8266-arduino]
extends = common:arduino
platform = platformio/espressif8266@4.2.1
platform_packages =
platformio/framework-arduinoespressif8266@~3.30102.0
framework = arduino
build_flags =
${common:arduino.build_flags}
-Wno-nonnull-compare
-DUSE_ESP8266
-DUSE_ESP8266_FRAMEWORK_ARDUINO
[common:esp32-arduino]
extends = common:arduino
platform = platformio/espressif32@5.4.0
platform_packages =
platformio/framework-arduinoespressif32@~3.20005.0
framework = arduino
build_flags =
${common:arduino.build_flags}
-DUSE_ESP32
-DUSE_ESP32_FRAMEWORK_ARDUINO
-DAUDIO_NO_SD_FS
[env:esp8266-arduino]
extends = common:esp8266-arduino
board = nodemcuv2
build_flags =
${common:esp8266-arduino.build_flags}
${flags:runtime.build_flags}
[env:esp32-arduino]
extends = common:esp32-arduino
board = esp32dev
board_build.partitions = huge_app.csv
build_flags =
${common:esp32-arduino.build_flags}
${flags:runtime.build_flags}
-DUSE_ESP32_VARIANT_ESP32

44
pyproject.toml Normal file
View file

@ -0,0 +1,44 @@
[project]
name = "esphome-fastcon"
version = "1.0.0"
description = ""
license = { text = "MIT" }
authors = [{ name = "Dennis George" }]
keywords = ["esphome", "homeassistant", "home", "automation"]
requires-python = ">=3.9.0"
dependencies = ["esphome>=2024.12.2"]
[tool.uv]
default-groups = ["lint"]
[dependency-groups]
lint = ["ruff>=0.7.0"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build]
include = ["components/**/*.py", "components/**/*.h", "components/**/*.cpp"]
[tool.ruff]
required-version = ">=0.5.0"
[tool.ruff.lint]
select = [
"E", # pycodestyle
"F", # pyflakes/autoflake
"I", # isort
"PL", # pylint
"UP", # pyupgrade
]
ignore = [
"E501", # line too long
"PLR0911", # Too many return statements ({returns} > {max_returns})
"PLR0912", # Too many branches ({branches} > {max_branches})
"PLR0913", # Too many arguments to function call ({c_args} > {max_args})
"PLR0915", # Too many statements ({statements} > {max_statements})
"PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable
"PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target
]

2267
uv.lock generated Normal file

File diff suppressed because it is too large Load diff