From 02491155f0a4aecd3e42a04da5e6a7b7f99f0fae Mon Sep 17 00:00:00 2001 From: eigger Date: Sat, 18 Jul 2026 12:44:10 +0900 Subject: [PATCH 1/3] feat(ws_bridge): add WebSocket bridge component to Home Assistant (no MQTT) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connects an ESP32 (ESP-IDF) directly to Home Assistant's /api/websocket and speaks the hass-ws-bridge protocol (github.com/eigger/hass-ws-bridge) to declare entities, push state, and receive commands — removing the need for an MQTT broker. Combined with a secure way to reach HA from outside the LAN (Nabu Casa remote UI, a reverse proxy with a valid cert), it can also remove the need for a VPN for remote devices. - ws_bridge.h/.cpp: hub Component. Uses ESP-IDF's esp_websocket_client (pulled in via esp32.add_idf_component, no framing/TLS hand-rolled) for wss://, a small state machine for the HA auth handshake (auth_required -> auth -> auth_ok -> ws_bridge/connect), and a lock-free SPSC queue (EventPool + LockFreeQueue) to hand transport events from the esp_websocket_client task to the main loop safely. - ws_protocol.h/.cpp: JSON message build/parse using ESPHome's existing json::build_json/parse_json helpers (ArduinoJson v7 API). - ws_bridge_device.h: shared entity mixin; the hub re-declares every registered entity (and its current state) on every (re)connect, per the protocol's reconnection guidance. - sensor/binary_sensor/switch/number/select/button: one hub + per-platform subfolder structure mirroring this repo's uartex component. Read-only platforms push via add_on_state_callback; control platforms route incoming HA commands to turn_on/turn_off/make_call().set_value()/ set_option()/press(). - Exposes keep_last_state_on_disconnect (sent in ws_bridge/connect) so a gateway's entities can keep their last state instead of going unavailable on disconnect, matching the hass-ws-bridge PR adding that option. Verified compiling for esp32dev (esp-idf) with ESPHome 2026.7, hub + all six platforms, zero errors/warnings. Co-Authored-By: Claude Opus 4.8 --- README.md | 7 + components/ws_bridge/README.md | 137 +++++++++++++ components/ws_bridge/__init__.py | 106 ++++++++++ components/ws_bridge/automation.h | 23 +++ .../ws_bridge/binary_sensor/__init__.py | 22 +++ .../binary_sensor/ws_bridge_binary_sensor.cpp | 24 +++ .../binary_sensor/ws_bridge_binary_sensor.h | 17 ++ components/ws_bridge/button/__init__.py | 20 ++ .../ws_bridge/button/ws_bridge_button.cpp | 23 +++ .../ws_bridge/button/ws_bridge_button.h | 21 ++ components/ws_bridge/const.py | 16 ++ components/ws_bridge/number/__init__.py | 41 ++++ .../ws_bridge/number/ws_bridge_number.cpp | 37 ++++ .../ws_bridge/number/ws_bridge_number.h | 21 ++ components/ws_bridge/select/__init__.py | 26 +++ .../ws_bridge/select/ws_bridge_select.cpp | 38 ++++ .../ws_bridge/select/ws_bridge_select.h | 21 ++ components/ws_bridge/sensor/__init__.py | 20 ++ .../ws_bridge/sensor/ws_bridge_sensor.cpp | 30 +++ .../ws_bridge/sensor/ws_bridge_sensor.h | 17 ++ components/ws_bridge/switch/__init__.py | 20 ++ .../ws_bridge/switch/ws_bridge_switch.cpp | 34 ++++ .../ws_bridge/switch/ws_bridge_switch.h | 21 ++ components/ws_bridge/ws_bridge.cpp | 185 ++++++++++++++++++ components/ws_bridge/ws_bridge.h | 102 ++++++++++ components/ws_bridge/ws_bridge_device.h | 41 ++++ components/ws_bridge/ws_bridge_entity_json.h | 36 ++++ components/ws_bridge/ws_protocol.cpp | 111 +++++++++++ components/ws_bridge/ws_protocol.h | 50 +++++ 29 files changed, 1267 insertions(+) create mode 100644 components/ws_bridge/README.md create mode 100644 components/ws_bridge/__init__.py create mode 100644 components/ws_bridge/automation.h create mode 100644 components/ws_bridge/binary_sensor/__init__.py create mode 100644 components/ws_bridge/binary_sensor/ws_bridge_binary_sensor.cpp create mode 100644 components/ws_bridge/binary_sensor/ws_bridge_binary_sensor.h create mode 100644 components/ws_bridge/button/__init__.py create mode 100644 components/ws_bridge/button/ws_bridge_button.cpp create mode 100644 components/ws_bridge/button/ws_bridge_button.h create mode 100644 components/ws_bridge/const.py create mode 100644 components/ws_bridge/number/__init__.py create mode 100644 components/ws_bridge/number/ws_bridge_number.cpp create mode 100644 components/ws_bridge/number/ws_bridge_number.h create mode 100644 components/ws_bridge/select/__init__.py create mode 100644 components/ws_bridge/select/ws_bridge_select.cpp create mode 100644 components/ws_bridge/select/ws_bridge_select.h create mode 100644 components/ws_bridge/sensor/__init__.py create mode 100644 components/ws_bridge/sensor/ws_bridge_sensor.cpp create mode 100644 components/ws_bridge/sensor/ws_bridge_sensor.h create mode 100644 components/ws_bridge/switch/__init__.py create mode 100644 components/ws_bridge/switch/ws_bridge_switch.cpp create mode 100644 components/ws_bridge/switch/ws_bridge_switch.h create mode 100644 components/ws_bridge/ws_bridge.cpp create mode 100644 components/ws_bridge/ws_bridge.h create mode 100644 components/ws_bridge/ws_bridge_device.h create mode 100644 components/ws_bridge/ws_bridge_entity_json.h create mode 100644 components/ws_bridge/ws_protocol.cpp create mode 100644 components/ws_bridge/ws_protocol.h diff --git a/README.md b/README.md index 2f423473..a36dfb27 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,13 @@ --- +## [ws_bridge](/components/ws_bridge) +**WebSocket Bridge to Home Assistant (no MQTT needed)** +- Connects directly to Home Assistant's `/api/websocket` and speaks the [`hass-ws-bridge`](https://github.com/eigger/hass-ws-bridge) protocol to declare `sensor`/`binary_sensor`/`switch`/`number`/`select`/`button` entities, push state, and receive commands — no MQTT broker required. +- ESP32 (ESP-IDF) only; uses ESP-IDF's `esp_websocket_client` for a secure `wss://` connection. + +--- + ## [jaalee_jht](/components/jaalee_jht) **Jaalee JHT Bluetooth Temperature and Humidity Sensor Component** - Supports Jaalee JHT Bluetooth Temperature and Humidity Sensor. diff --git a/components/ws_bridge/README.md b/components/ws_bridge/README.md new file mode 100644 index 00000000..8cc416fe --- /dev/null +++ b/components/ws_bridge/README.md @@ -0,0 +1,137 @@ +# ws_bridge + +An ESPHome external component for ESP32 (ESP-IDF only). It connects over a +secure WebSocket directly to Home Assistant's standard `/api/websocket` +endpoint and speaks the protocol of the +[`hass-ws-bridge`](https://github.com/eigger/hass-ws-bridge) custom +integration — declaring entities, pushing state, and receiving commands — +**without needing an MQTT broker**. Combined with a way to reach Home +Assistant securely from outside your LAN (e.g. Nabu Casa remote UI, or your +own reverse proxy with a valid certificate), it can also remove the need for +a VPN just to get a remote device's data into Home Assistant. + +> Requires the `ws_bridge` custom component installed on the Home Assistant +> side: https://github.com/eigger/hass-ws-bridge (see its +> [PROTOCOL.md](https://github.com/eigger/hass-ws-bridge/blob/main/docs/PROTOCOL.md) +> for the full wire protocol). + +## Installation + +```yaml +external_components: + - source: github://eigger/espcomponents@latest + components: [ ws_bridge ] + refresh: always +``` + +`ws_bridge` requires the **ESP-IDF** framework (it pulls in ESP-IDF's +`esp_websocket_client` managed component): + +```yaml +esp32: + board: esp32dev + framework: + type: esp-idf +``` + +## Configuration + +```yaml +ws_bridge: + host: 192.168.0.10 # or your Nabu Casa / reverse-proxy hostname + port: 8123 + ssl: true # wss:// (default). Set false only for LAN testing. + token: !secret ha_token # Home Assistant long-lived access token + gateway_id: my_esp # (default: this device's name) + name: "My ESP" # (default: this device's friendly_name) + keep_last_state_on_disconnect: false + + on_connected: + - logger.log: "ws_bridge connected" + on_disconnected: + - logger.log: "ws_bridge disconnected" + +sensor: + - platform: ws_bridge + unique_id: temp1 + name: "Temperature" + device_class: temperature + unit_of_measurement: "°C" + state_class: measurement + +binary_sensor: + - platform: ws_bridge + unique_id: motion1 + name: "Motion" + device_class: motion + +switch: + - platform: ws_bridge + unique_id: relay1 + name: "Relay" + +number: + - platform: ws_bridge + unique_id: setpoint1 + name: "Setpoint" + min_value: 0 + max_value: 100 + step: 0.5 + +select: + - platform: ws_bridge + unique_id: mode1 + name: "Mode" + options: + - "Auto" + - "Manual" + +button: + - platform: ws_bridge + unique_id: restart1 + name: "Restart" +``` + +### `ws_bridge` (hub) options + +| Option | Required | Default | Description | +|--------|:--------:|---------|-------------| +| `host` | ✓ | - | Home Assistant address (IP, `.local` hostname, or a remote hostname such as Nabu Casa's) | +| `port` | | 8123 | Port | +| `ssl` | | `true` | Use `wss://`. Only disable for LAN-only testing — the access token is sent in plain text over `ws://` | +| `token` | ✓ | - | Home Assistant long-lived access token | +| `gateway_id` | | device name | Unique client identifier (becomes the HA gateway device) | +| `name` | | device friendly name | Display name for the gateway device | +| `keep_last_state_on_disconnect` | | `false` | If `true`, this gateway's entities keep their last state in HA instead of going `unavailable` when the connection drops (including an ungraceful disconnect) | + +### Platform options (all of `sensor`/`binary_sensor`/`switch`/`number`/`select`/`button`) + +| Option | Required | Description | +|--------|:--------:|-------------| +| `unique_id` | ✓ | Identifier for this entity, unique within the gateway | +| `ws_device_id` | | Groups this entity under a sub-device in HA (e.g. multiple sensors on one physical board) | +| `ws_device_name` | | Display name for that sub-device | + +Plus each platform's normal ESPHome options (`name`, `device_class`, `icon`, +`entity_category`, `unit_of_measurement`/`state_class` for `sensor`, +`min_value`/`max_value`/`step` for `number`, `options` for `select`). + +## Triggers + +- `on_connected` — the WebSocket connected and Home Assistant accepted the connection +- `on_disconnected` — the connection was lost + +## Behavior / Limitations + +- **Read-only platforms** (`sensor`, `binary_sensor`) push their state to Home + Assistant automatically whenever it changes. +- **Controllable platforms** (`switch`, `number`, `select`, `button`) receive + commands from Home Assistant and update their own state optimistically + (`publish_state`/`this->state`) — hook `on_turn_on`/`lambda:`/etc. in your + own YAML if you need to drive real hardware from the state. +- On every (re)connect, all declared entities and their current state are + re-sent, per the protocol's reconnection guidance. +- TLS uses ESP-IDF's built-in public CA bundle — this works out of the box + with Nabu Casa or any certificate from a public CA. A custom CA + certificate (for self-signed setups) is not supported yet. +- Only one gateway connection per device is supported. diff --git a/components/ws_bridge/__init__.py b/components/ws_bridge/__init__.py new file mode 100644 index 00000000..24fab919 --- /dev/null +++ b/components/ws_bridge/__init__.py @@ -0,0 +1,106 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome import automation +from esphome.components import esp32 +from esphome.const import CONF_ID, CONF_PORT, CONF_NAME, CONF_TRIGGER_ID +from esphome.core import CORE + +from .const import ( + CONF_WS_BRIDGE_ID, + CONF_HOST, + CONF_SSL, + CONF_TOKEN, + CONF_GATEWAY_ID, + CONF_KEEP_LAST_STATE_ON_DISCONNECT, + CONF_UNIQUE_ID, + CONF_WS_DEVICE_ID, + CONF_WS_DEVICE_NAME, + CONF_ON_CONNECTED, + CONF_ON_DISCONNECTED, +) + +CODEOWNERS = ["@eigger"] +DEPENDENCIES = ["network"] +AUTO_LOAD = ["json"] + +ESP_WEBSOCKET_CLIENT_VERSION = "1.7.0" + +ws_bridge_ns = cg.esphome_ns.namespace("ws_bridge") +WsBridgeComponent = ws_bridge_ns.class_("WsBridgeComponent", cg.Component) +WsBridgeDevice = ws_bridge_ns.class_("WsBridgeDevice") + +ConnectedTrigger = ws_bridge_ns.class_("ConnectedTrigger", automation.Trigger.template()) +DisconnectedTrigger = ws_bridge_ns.class_("DisconnectedTrigger", automation.Trigger.template()) + + +def _validate_esp_idf(config): + if CORE.target_framework != "esp-idf": + raise cv.Invalid("ws_bridge requires the ESP-IDF framework (esp32: framework: type: esp-idf)") + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(WsBridgeComponent), + cv.Required(CONF_HOST): cv.string_strict, + cv.Optional(CONF_PORT, default=8123): cv.port, + cv.Optional(CONF_SSL, default=True): cv.boolean, + cv.Required(CONF_TOKEN): cv.string_strict, + cv.Optional(CONF_GATEWAY_ID, default=lambda: CORE.name): cv.string_strict, + cv.Optional(CONF_NAME, default=lambda: CORE.friendly_name or CORE.name): cv.string_strict, + cv.Optional(CONF_KEEP_LAST_STATE_ON_DISCONNECT, default=False): cv.boolean, + cv.Optional(CONF_ON_CONNECTED): automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ConnectedTrigger)} + ), + cv.Optional(CONF_ON_DISCONNECTED): automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(DisconnectedTrigger)} + ), + } + ).extend(cv.COMPONENT_SCHEMA), + _validate_esp_idf, +) + +# Shared schema every ws_bridge platform (sensor/binary_sensor/switch/number/ +# select/button) must extend, mirroring uartex's UARTEX_DEVICE_SCHEMA pattern. +WS_BRIDGE_DEVICE_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_WS_BRIDGE_ID): cv.use_id(WsBridgeComponent), + cv.Required(CONF_UNIQUE_ID): cv.string_strict, + cv.Optional(CONF_WS_DEVICE_ID): cv.string_strict, + cv.Optional(CONF_WS_DEVICE_NAME): cv.string_strict, + } +) + + +async def register_ws_bridge_device(var, config): + parent = await cg.get_variable(config[CONF_WS_BRIDGE_ID]) + cg.add(var.set_ws_bridge_parent(parent)) + cg.add(var.set_unique_id(config[CONF_UNIQUE_ID])) + if CONF_WS_DEVICE_ID in config: + cg.add(var.set_device_id(config[CONF_WS_DEVICE_ID])) + if CONF_WS_DEVICE_NAME in config: + cg.add(var.set_device_name(config[CONF_WS_DEVICE_NAME])) + cg.add(parent.register_device(var)) + + +async def to_code(config): + esp32.add_idf_component(name="espressif/esp_websocket_client", ref=ESP_WEBSOCKET_CLIENT_VERSION) + + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + cg.add(var.set_host(config[CONF_HOST])) + cg.add(var.set_port(config[CONF_PORT])) + cg.add(var.set_ssl(config[CONF_SSL])) + cg.add(var.set_token(config[CONF_TOKEN])) + cg.add(var.set_gateway_id(config[CONF_GATEWAY_ID])) + cg.add(var.set_gateway_name(config[CONF_NAME])) + cg.add(var.set_keep_last_state_on_disconnect(config[CONF_KEEP_LAST_STATE_ON_DISCONNECT])) + + for conf in config.get(CONF_ON_CONNECTED, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [], conf) + for conf in config.get(CONF_ON_DISCONNECTED, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [], conf) diff --git a/components/ws_bridge/automation.h b/components/ws_bridge/automation.h new file mode 100644 index 00000000..677d5540 --- /dev/null +++ b/components/ws_bridge/automation.h @@ -0,0 +1,23 @@ +#pragma once +#include "esphome/core/automation.h" +#include "ws_bridge.h" + +namespace esphome { +namespace ws_bridge { + +class ConnectedTrigger : public Trigger<> { + public: + explicit ConnectedTrigger(WsBridgeComponent *parent) { + parent->add_on_connected_callback([this]() { this->trigger(); }); + } +}; + +class DisconnectedTrigger : public Trigger<> { + public: + explicit DisconnectedTrigger(WsBridgeComponent *parent) { + parent->add_on_disconnected_callback([this]() { this->trigger(); }); + } +}; + +} // namespace ws_bridge +} // namespace esphome diff --git a/components/ws_bridge/binary_sensor/__init__.py b/components/ws_bridge/binary_sensor/__init__.py new file mode 100644 index 00000000..89807b06 --- /dev/null +++ b/components/ws_bridge/binary_sensor/__init__.py @@ -0,0 +1,22 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import binary_sensor, ws_bridge + +from .. import ws_bridge_ns + +DEPENDENCIES = ["ws_bridge"] +WsBridgeBinarySensor = ws_bridge_ns.class_( + "WsBridgeBinarySensor", binary_sensor.BinarySensor, cg.Component, ws_bridge.WsBridgeDevice +) + +CONFIG_SCHEMA = ( + binary_sensor.binary_sensor_schema(WsBridgeBinarySensor) + .extend(ws_bridge.WS_BRIDGE_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config): + var = await binary_sensor.new_binary_sensor(config) + await cg.register_component(var, config) + await ws_bridge.register_ws_bridge_device(var, config) diff --git a/components/ws_bridge/binary_sensor/ws_bridge_binary_sensor.cpp b/components/ws_bridge/binary_sensor/ws_bridge_binary_sensor.cpp new file mode 100644 index 00000000..276b3bd3 --- /dev/null +++ b/components/ws_bridge/binary_sensor/ws_bridge_binary_sensor.cpp @@ -0,0 +1,24 @@ +#include "ws_bridge_binary_sensor.h" +#include "../ws_bridge.h" +#include "../ws_bridge_entity_json.h" + +namespace esphome { +namespace ws_bridge { + +static const char *const TAG = "ws_bridge.binary_sensor"; + +void WsBridgeBinarySensor::setup() { + this->add_on_state_callback([this](bool state) { this->parent_->send_state_bool(this->unique_id_, state); }); +} + +void WsBridgeBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "WS Bridge Binary Sensor", this); } + +void WsBridgeBinarySensor::ws_bridge_declare() { + this->parent_->send_entity_declare(this->unique_id_, "binary_sensor", this->get_name().str(), this->device_id_, + this->device_name_, + [this](JsonObject root) { add_common_entity_fields(root, *this); }); + if (this->has_state()) this->parent_->send_state_bool(this->unique_id_, this->state); +} + +} // namespace ws_bridge +} // namespace esphome diff --git a/components/ws_bridge/binary_sensor/ws_bridge_binary_sensor.h b/components/ws_bridge/binary_sensor/ws_bridge_binary_sensor.h new file mode 100644 index 00000000..a5cac7d9 --- /dev/null +++ b/components/ws_bridge/binary_sensor/ws_bridge_binary_sensor.h @@ -0,0 +1,17 @@ +#pragma once +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/core/component.h" +#include "../ws_bridge_device.h" + +namespace esphome { +namespace ws_bridge { + +class WsBridgeBinarySensor : public binary_sensor::BinarySensor, public Component, public WsBridgeDevice { + public: + void setup() override; + void dump_config() override; + void ws_bridge_declare() override; +}; + +} // namespace ws_bridge +} // namespace esphome diff --git a/components/ws_bridge/button/__init__.py b/components/ws_bridge/button/__init__.py new file mode 100644 index 00000000..dee87938 --- /dev/null +++ b/components/ws_bridge/button/__init__.py @@ -0,0 +1,20 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import button, ws_bridge + +from .. import ws_bridge_ns + +DEPENDENCIES = ["ws_bridge"] +WsBridgeButton = ws_bridge_ns.class_("WsBridgeButton", button.Button, cg.Component, ws_bridge.WsBridgeDevice) + +CONFIG_SCHEMA = ( + button.button_schema(WsBridgeButton) + .extend(ws_bridge.WS_BRIDGE_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config): + var = await button.new_button(config) + await cg.register_component(var, config) + await ws_bridge.register_ws_bridge_device(var, config) diff --git a/components/ws_bridge/button/ws_bridge_button.cpp b/components/ws_bridge/button/ws_bridge_button.cpp new file mode 100644 index 00000000..6d6bd3bc --- /dev/null +++ b/components/ws_bridge/button/ws_bridge_button.cpp @@ -0,0 +1,23 @@ +#include "ws_bridge_button.h" +#include "../ws_bridge.h" +#include "../ws_bridge_entity_json.h" + +namespace esphome { +namespace ws_bridge { + +static const char *const TAG = "ws_bridge.button"; + +void WsBridgeButton::dump_config() { LOG_BUTTON("", "WS Bridge Button", this); } + +void WsBridgeButton::ws_bridge_handle_command(const WsCommand &command) { + if (command.action == "press") this->press(); +} + +void WsBridgeButton::ws_bridge_declare() { + this->parent_->send_entity_declare(this->unique_id_, "button", this->get_name().str(), this->device_id_, + this->device_name_, + [this](JsonObject root) { add_common_entity_fields(root, *this); }); +} + +} // namespace ws_bridge +} // namespace esphome diff --git a/components/ws_bridge/button/ws_bridge_button.h b/components/ws_bridge/button/ws_bridge_button.h new file mode 100644 index 00000000..bd7f9cfd --- /dev/null +++ b/components/ws_bridge/button/ws_bridge_button.h @@ -0,0 +1,21 @@ +#pragma once +#include "esphome/components/button/button.h" +#include "esphome/core/component.h" +#include "../ws_bridge_device.h" + +namespace esphome { +namespace ws_bridge { + +class WsBridgeButton : public button::Button, public Component, public WsBridgeDevice { + public: + void setup() override {} + void dump_config() override; + void ws_bridge_declare() override; + void ws_bridge_handle_command(const WsCommand &command) override; + + protected: + void press_action() override {} +}; + +} // namespace ws_bridge +} // namespace esphome diff --git a/components/ws_bridge/const.py b/components/ws_bridge/const.py new file mode 100644 index 00000000..50d26487 --- /dev/null +++ b/components/ws_bridge/const.py @@ -0,0 +1,16 @@ +CONF_WS_BRIDGE_ID = "ws_bridge_id" + +CONF_HOST = "host" +CONF_SSL = "ssl" +CONF_TOKEN = "token" +CONF_GATEWAY_ID = "gateway_id" +CONF_KEEP_LAST_STATE_ON_DISCONNECT = "keep_last_state_on_disconnect" + +CONF_UNIQUE_ID = "unique_id" +# Prefixed with ws_ to avoid colliding with ESPHome's own reserved +# device_id/device (native multi-device grouping) entity schema keys. +CONF_WS_DEVICE_ID = "ws_device_id" +CONF_WS_DEVICE_NAME = "ws_device_name" + +CONF_ON_CONNECTED = "on_connected" +CONF_ON_DISCONNECTED = "on_disconnected" diff --git a/components/ws_bridge/number/__init__.py b/components/ws_bridge/number/__init__.py new file mode 100644 index 00000000..1ea20288 --- /dev/null +++ b/components/ws_bridge/number/__init__.py @@ -0,0 +1,41 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import number, ws_bridge +from esphome.const import CONF_MAX_VALUE, CONF_MIN_VALUE, CONF_STEP + +from .. import ws_bridge_ns + +DEPENDENCIES = ["ws_bridge"] +WsBridgeNumber = ws_bridge_ns.class_("WsBridgeNumber", number.Number, cg.Component, ws_bridge.WsBridgeDevice) + + +def validate_min_max(config): + if config[CONF_MAX_VALUE] <= config[CONF_MIN_VALUE]: + raise cv.Invalid("max_value must be greater than min_value") + return config + + +CONFIG_SCHEMA = cv.All( + number.number_schema(WsBridgeNumber) + .extend( + { + cv.Required(CONF_MIN_VALUE): cv.float_, + cv.Required(CONF_MAX_VALUE): cv.float_, + cv.Optional(CONF_STEP, default=1.0): cv.positive_float, + } + ) + .extend(ws_bridge.WS_BRIDGE_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), + validate_min_max, +) + + +async def to_code(config): + var = await number.new_number( + config, + min_value=config[CONF_MIN_VALUE], + max_value=config[CONF_MAX_VALUE], + step=config[CONF_STEP], + ) + await cg.register_component(var, config) + await ws_bridge.register_ws_bridge_device(var, config) diff --git a/components/ws_bridge/number/ws_bridge_number.cpp b/components/ws_bridge/number/ws_bridge_number.cpp new file mode 100644 index 00000000..53045ce8 --- /dev/null +++ b/components/ws_bridge/number/ws_bridge_number.cpp @@ -0,0 +1,37 @@ +#include "ws_bridge_number.h" +#include "../ws_bridge.h" +#include "../ws_bridge_entity_json.h" + +namespace esphome { +namespace ws_bridge { + +static const char *const TAG = "ws_bridge.number"; + +void WsBridgeNumber::setup() { + this->add_on_state_callback([this](float state) { this->parent_->send_state_float(this->unique_id_, state); }); +} + +void WsBridgeNumber::dump_config() { LOG_NUMBER("", "WS Bridge Number", this); } + +void WsBridgeNumber::control(float value) { this->publish_state(value); } + +void WsBridgeNumber::ws_bridge_handle_command(const WsCommand &command) { + if (command.action == "set_value" && command.has_value) { + this->make_call().set_value(command.value_float).perform(); + } +} + +void WsBridgeNumber::ws_bridge_declare() { + this->parent_->send_entity_declare( + this->unique_id_, "number", this->get_name().str(), this->device_id_, this->device_name_, + [this](JsonObject root) { + add_common_entity_fields(root, *this); + root["min"] = this->traits.get_min_value(); + root["max"] = this->traits.get_max_value(); + root["step"] = this->traits.get_step(); + }); + if (this->has_state()) this->parent_->send_state_float(this->unique_id_, this->state); +} + +} // namespace ws_bridge +} // namespace esphome diff --git a/components/ws_bridge/number/ws_bridge_number.h b/components/ws_bridge/number/ws_bridge_number.h new file mode 100644 index 00000000..3e00aafd --- /dev/null +++ b/components/ws_bridge/number/ws_bridge_number.h @@ -0,0 +1,21 @@ +#pragma once +#include "esphome/components/number/number.h" +#include "esphome/core/component.h" +#include "../ws_bridge_device.h" + +namespace esphome { +namespace ws_bridge { + +class WsBridgeNumber : public number::Number, public Component, public WsBridgeDevice { + public: + void setup() override; + void dump_config() override; + void ws_bridge_declare() override; + void ws_bridge_handle_command(const WsCommand &command) override; + + protected: + void control(float value) override; +}; + +} // namespace ws_bridge +} // namespace esphome diff --git a/components/ws_bridge/select/__init__.py b/components/ws_bridge/select/__init__.py new file mode 100644 index 00000000..12aac233 --- /dev/null +++ b/components/ws_bridge/select/__init__.py @@ -0,0 +1,26 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import select, ws_bridge +from esphome.const import CONF_OPTIONS + +from .. import ws_bridge_ns + +DEPENDENCIES = ["ws_bridge"] +WsBridgeSelect = ws_bridge_ns.class_("WsBridgeSelect", select.Select, cg.Component, ws_bridge.WsBridgeDevice) + +CONFIG_SCHEMA = ( + select.select_schema(WsBridgeSelect) + .extend( + { + cv.Required(CONF_OPTIONS): cv.All(cv.ensure_list(cv.string_strict), cv.Length(min=1)), + } + ) + .extend(ws_bridge.WS_BRIDGE_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config): + var = await select.new_select(config, options=config[CONF_OPTIONS]) + await cg.register_component(var, config) + await ws_bridge.register_ws_bridge_device(var, config) diff --git a/components/ws_bridge/select/ws_bridge_select.cpp b/components/ws_bridge/select/ws_bridge_select.cpp new file mode 100644 index 00000000..96a293b7 --- /dev/null +++ b/components/ws_bridge/select/ws_bridge_select.cpp @@ -0,0 +1,38 @@ +#include "ws_bridge_select.h" +#include "../ws_bridge.h" +#include "../ws_bridge_entity_json.h" + +namespace esphome { +namespace ws_bridge { + +static const char *const TAG = "ws_bridge.select"; + +void WsBridgeSelect::setup() { + this->add_on_state_callback([this](size_t index) { + this->parent_->send_state_string(this->unique_id_, this->option_at(index)); + }); +} + +void WsBridgeSelect::dump_config() { LOG_SELECT("", "WS Bridge Select", this); } + +void WsBridgeSelect::control(const std::string &value) { this->publish_state(value); } + +void WsBridgeSelect::ws_bridge_handle_command(const WsCommand &command) { + if (command.action == "select_option" && command.has_value) { + this->make_call().set_option(command.value_string).perform(); + } +} + +void WsBridgeSelect::ws_bridge_declare() { + this->parent_->send_entity_declare( + this->unique_id_, "select", this->get_name().str(), this->device_id_, this->device_name_, + [this](JsonObject root) { + add_common_entity_fields(root, *this); + JsonArray options = root["options"].to(); + for (const char *option : this->traits.get_options()) options.add(option); + }); + if (this->has_state()) this->parent_->send_state_string(this->unique_id_, this->current_option().str()); +} + +} // namespace ws_bridge +} // namespace esphome diff --git a/components/ws_bridge/select/ws_bridge_select.h b/components/ws_bridge/select/ws_bridge_select.h new file mode 100644 index 00000000..588362ff --- /dev/null +++ b/components/ws_bridge/select/ws_bridge_select.h @@ -0,0 +1,21 @@ +#pragma once +#include "esphome/components/select/select.h" +#include "esphome/core/component.h" +#include "../ws_bridge_device.h" + +namespace esphome { +namespace ws_bridge { + +class WsBridgeSelect : public select::Select, public Component, public WsBridgeDevice { + public: + void setup() override; + void dump_config() override; + void ws_bridge_declare() override; + void ws_bridge_handle_command(const WsCommand &command) override; + + protected: + void control(const std::string &value) override; +}; + +} // namespace ws_bridge +} // namespace esphome diff --git a/components/ws_bridge/sensor/__init__.py b/components/ws_bridge/sensor/__init__.py new file mode 100644 index 00000000..e50bacf6 --- /dev/null +++ b/components/ws_bridge/sensor/__init__.py @@ -0,0 +1,20 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import sensor, ws_bridge + +from .. import ws_bridge_ns + +DEPENDENCIES = ["ws_bridge"] +WsBridgeSensor = ws_bridge_ns.class_("WsBridgeSensor", sensor.Sensor, cg.Component, ws_bridge.WsBridgeDevice) + +CONFIG_SCHEMA = ( + sensor.sensor_schema(WsBridgeSensor) + .extend(ws_bridge.WS_BRIDGE_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config): + var = await sensor.new_sensor(config) + await cg.register_component(var, config) + await ws_bridge.register_ws_bridge_device(var, config) diff --git a/components/ws_bridge/sensor/ws_bridge_sensor.cpp b/components/ws_bridge/sensor/ws_bridge_sensor.cpp new file mode 100644 index 00000000..6d5f3e50 --- /dev/null +++ b/components/ws_bridge/sensor/ws_bridge_sensor.cpp @@ -0,0 +1,30 @@ +#include "ws_bridge_sensor.h" +#include "../ws_bridge.h" +#include "../ws_bridge_entity_json.h" + +namespace esphome { +namespace ws_bridge { + +static const char *const TAG = "ws_bridge.sensor"; + +void WsBridgeSensor::setup() { + this->add_on_state_callback([this](float state) { this->parent_->send_state_float(this->unique_id_, state); }); +} + +void WsBridgeSensor::dump_config() { LOG_SENSOR("", "WS Bridge Sensor", this); } + +void WsBridgeSensor::ws_bridge_declare() { + this->parent_->send_entity_declare( + this->unique_id_, "sensor", this->get_name().str(), this->device_id_, this->device_name_, + [this](JsonObject root) { + add_common_entity_fields(root, *this); + StringRef uom = this->get_unit_of_measurement_ref(); + if (!uom.empty()) root["unit_of_measurement"] = uom.str(); + sensor::StateClass sc = this->get_state_class(); + if (sc != sensor::STATE_CLASS_NONE) root["state_class"] = LOG_STR_ARG(sensor::state_class_to_string(sc)); + }); + if (this->has_state()) this->parent_->send_state_float(this->unique_id_, this->state); +} + +} // namespace ws_bridge +} // namespace esphome diff --git a/components/ws_bridge/sensor/ws_bridge_sensor.h b/components/ws_bridge/sensor/ws_bridge_sensor.h new file mode 100644 index 00000000..e66a165c --- /dev/null +++ b/components/ws_bridge/sensor/ws_bridge_sensor.h @@ -0,0 +1,17 @@ +#pragma once +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" +#include "../ws_bridge_device.h" + +namespace esphome { +namespace ws_bridge { + +class WsBridgeSensor : public sensor::Sensor, public Component, public WsBridgeDevice { + public: + void setup() override; + void dump_config() override; + void ws_bridge_declare() override; +}; + +} // namespace ws_bridge +} // namespace esphome diff --git a/components/ws_bridge/switch/__init__.py b/components/ws_bridge/switch/__init__.py new file mode 100644 index 00000000..65f9d1d3 --- /dev/null +++ b/components/ws_bridge/switch/__init__.py @@ -0,0 +1,20 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import switch, ws_bridge + +from .. import ws_bridge_ns + +DEPENDENCIES = ["ws_bridge"] +WsBridgeSwitch = ws_bridge_ns.class_("WsBridgeSwitch", switch.Switch, cg.Component, ws_bridge.WsBridgeDevice) + +CONFIG_SCHEMA = ( + switch.switch_schema(WsBridgeSwitch) + .extend(ws_bridge.WS_BRIDGE_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config): + var = await switch.new_switch(config) + await cg.register_component(var, config) + await ws_bridge.register_ws_bridge_device(var, config) diff --git a/components/ws_bridge/switch/ws_bridge_switch.cpp b/components/ws_bridge/switch/ws_bridge_switch.cpp new file mode 100644 index 00000000..b0ec8395 --- /dev/null +++ b/components/ws_bridge/switch/ws_bridge_switch.cpp @@ -0,0 +1,34 @@ +#include "ws_bridge_switch.h" +#include "../ws_bridge.h" +#include "../ws_bridge_entity_json.h" + +namespace esphome { +namespace ws_bridge { + +static const char *const TAG = "ws_bridge.switch"; + +void WsBridgeSwitch::setup() { + this->add_on_state_callback([this](bool state) { this->parent_->send_state_bool(this->unique_id_, state); }); +} + +void WsBridgeSwitch::dump_config() { LOG_SWITCH("", "WS Bridge Switch", this); } + +void WsBridgeSwitch::write_state(bool state) { this->publish_state(state); } + +void WsBridgeSwitch::ws_bridge_handle_command(const WsCommand &command) { + if (command.action == "turn_on") { + this->turn_on(); + } else if (command.action == "turn_off") { + this->turn_off(); + } +} + +void WsBridgeSwitch::ws_bridge_declare() { + this->parent_->send_entity_declare(this->unique_id_, "switch", this->get_name().str(), this->device_id_, + this->device_name_, + [this](JsonObject root) { add_common_entity_fields(root, *this); }); + if (this->has_state()) this->parent_->send_state_bool(this->unique_id_, this->state); +} + +} // namespace ws_bridge +} // namespace esphome diff --git a/components/ws_bridge/switch/ws_bridge_switch.h b/components/ws_bridge/switch/ws_bridge_switch.h new file mode 100644 index 00000000..0c88cf06 --- /dev/null +++ b/components/ws_bridge/switch/ws_bridge_switch.h @@ -0,0 +1,21 @@ +#pragma once +#include "esphome/components/switch/switch.h" +#include "esphome/core/component.h" +#include "../ws_bridge_device.h" + +namespace esphome { +namespace ws_bridge { + +class WsBridgeSwitch : public switch_::Switch, public Component, public WsBridgeDevice { + public: + void setup() override; + void dump_config() override; + void ws_bridge_declare() override; + void ws_bridge_handle_command(const WsCommand &command) override; + + protected: + void write_state(bool state) override; +}; + +} // namespace ws_bridge +} // namespace esphome diff --git a/components/ws_bridge/ws_bridge.cpp b/components/ws_bridge/ws_bridge.cpp new file mode 100644 index 00000000..6c14376b --- /dev/null +++ b/components/ws_bridge/ws_bridge.cpp @@ -0,0 +1,185 @@ +#include "ws_bridge.h" +#include "esp_crt_bundle.h" +#include "esp_transport_ws.h" +#include "esphome/components/network/util.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace ws_bridge { + +static const char *const TAG = "ws_bridge"; + +void WsBridgeComponent::setup() { + esp_websocket_client_config_t config = {}; + config.host = this->host_.c_str(); + config.port = this->port_; + config.path = "/api/websocket"; + config.transport = this->ssl_ ? WEBSOCKET_TRANSPORT_OVER_SSL : WEBSOCKET_TRANSPORT_OVER_TCP; + if (this->ssl_) { + config.crt_bundle_attach = esp_crt_bundle_attach; + } + config.disable_auto_reconnect = false; + config.reconnect_timeout_ms = 10000; + config.network_timeout_ms = 10000; + + this->client_ = esp_websocket_client_init(&config); + if (this->client_ == nullptr) { + ESP_LOGE(TAG, "Failed to init WebSocket client"); + this->mark_failed(); + return; + } + esp_websocket_register_events(this->client_, WEBSOCKET_EVENT_ANY, WsBridgeComponent::ws_event_handler_, this); +} + +void WsBridgeComponent::loop() { + if (!this->started_) { + if (!network::is_connected()) return; + esp_err_t err = esp_websocket_client_start(this->client_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_websocket_client_start failed: %d", err); + return; + } + this->started_ = true; + } + + WsEvent *event; + while ((event = this->event_queue_.pop()) != nullptr) { + this->handle_event_(*event); + this->event_pool_.release(event); + } +} + +void WsBridgeComponent::dump_config() { + ESP_LOGCONFIG(TAG, "WS Bridge:"); + ESP_LOGCONFIG(TAG, " Server: %s://%s:%u/api/websocket", this->ssl_ ? "wss" : "ws", this->host_.c_str(), + this->port_); + ESP_LOGCONFIG(TAG, " Gateway ID: %s", this->gateway_id_.c_str()); + ESP_LOGCONFIG(TAG, " Keep last state on disconnect: %s", YESNO(this->keep_last_state_on_disconnect_)); +} + +void WsBridgeComponent::set_state_(WsBridgeState s) { + if (this->state_ != s) { + ESP_LOGD(TAG, "state %d -> %d", this->state_, s); + this->state_ = s; + } +} + +// Runs on the esp_websocket_client task, NOT the ESPHome main loop task. +// Must stay fast and non-blocking: reassemble fragmented text frames into +// rx_accum_ (producer-only state) and hand off complete events through the +// lock-free queue for loop() to process. +void WsBridgeComponent::ws_event_handler_(void *handler_args, esp_event_base_t base, int32_t event_id, + void *event_data) { + auto *self = static_cast(handler_args); + auto *data = static_cast(event_data); + auto ws_event_id = static_cast(event_id); + + if (ws_event_id == WEBSOCKET_EVENT_DATA) { + if (data->op_code != WS_TRANSPORT_OPCODES_TEXT && data->op_code != WS_TRANSPORT_OPCODES_CONT) { + return; // ignore ping/pong/close/binary frames + } + if (data->data_len > 0) self->rx_accum_.append(data->data_ptr, data->data_len); + bool complete = data->payload_len == 0 || (data->payload_offset + data->data_len >= data->payload_len); + if (!complete) return; // wait for more fragments + } + + WsEvent *event = self->event_pool_.allocate(); + if (event == nullptr) return; // queue full, drop this event + event->event_id = ws_event_id; + if (ws_event_id == WEBSOCKET_EVENT_DATA) { + event->data = std::move(self->rx_accum_); + self->rx_accum_.clear(); + } + if (!self->event_queue_.push(event)) self->event_pool_.release(event); +} + +void WsBridgeComponent::handle_event_(const WsEvent &event) { + switch (event.event_id) { + case WEBSOCKET_EVENT_CONNECTED: + ESP_LOGI(TAG, "WebSocket transport connected"); + this->set_state_(WS_BRIDGE_WAIT_AUTH_REQUIRED); + break; + case WEBSOCKET_EVENT_DISCONNECTED: + case WEBSOCKET_EVENT_ERROR: + case WEBSOCKET_EVENT_CLOSED: { + bool was_connected = this->is_connected(); + this->set_state_(WS_BRIDGE_DISCONNECTED); + if (was_connected) { + ESP_LOGW(TAG, "WebSocket disconnected"); + this->disconnected_cb_.call(); + } + break; + } + case WEBSOCKET_EVENT_DATA: + this->handle_message_(event.data); + break; + default: + break; + } +} + +void WsBridgeComponent::handle_message_(const std::string &raw) { + ParsedMessage msg = parse_message(raw); + + if (msg.type == "auth_required") { + this->send_raw_(build_auth(this->token_)); + this->set_state_(WS_BRIDGE_WAIT_AUTH_OK); + } else if (msg.type == "auth_ok") { + this->send_raw_( + build_connect(this->next_id_(), this->gateway_id_, this->gateway_name_, this->keep_last_state_on_disconnect_)); + this->set_state_(WS_BRIDGE_CONNECTED); + this->declare_all_entities_(); + this->connected_cb_.call(); + } else if (msg.type == "auth_invalid") { + ESP_LOGE(TAG, "Home Assistant rejected the access token"); + } else if (msg.type == "event") { + if (!msg.command.unique_id.empty()) this->route_command_(msg.command); + } + // "result": nothing to do, we don't block on ws_bridge/* results. +} + +void WsBridgeComponent::route_command_(const WsCommand &command) { + for (auto *device : this->devices_) { + if (device->get_ws_bridge_unique_id() == command.unique_id) { + device->ws_bridge_handle_command(command); + return; + } + } + ESP_LOGW(TAG, "Command for unknown unique_id '%s'", command.unique_id.c_str()); +} + +void WsBridgeComponent::declare_all_entities_() { + for (auto *device : this->devices_) device->ws_bridge_declare(); +} + +void WsBridgeComponent::send_raw_(const std::string &msg) { + if (this->client_ == nullptr || !esp_websocket_client_is_connected(this->client_)) return; + esp_websocket_client_send_text(this->client_, msg.c_str(), msg.size(), pdMS_TO_TICKS(1000)); +} + +void WsBridgeComponent::send_entity_declare(const std::string &unique_id, const std::string &platform, + const std::string &name, const std::string &device_id, + const std::string &device_name, + const std::function &extra) { + if (!this->is_connected()) return; + this->send_raw_(build_entity_declare(this->next_id_(), unique_id, platform, name, device_id, device_name, extra)); +} + +void WsBridgeComponent::send_state_float(const std::string &unique_id, float value) { + if (!this->is_connected()) return; + this->send_raw_(build_state_float(this->next_id_(), unique_id, value)); +} + +void WsBridgeComponent::send_state_bool(const std::string &unique_id, bool value) { + if (!this->is_connected()) return; + this->send_raw_(build_state_bool(this->next_id_(), unique_id, value)); +} + +void WsBridgeComponent::send_state_string(const std::string &unique_id, const std::string &value) { + if (!this->is_connected()) return; + this->send_raw_(build_state_string(this->next_id_(), unique_id, value)); +} + +} // namespace ws_bridge +} // namespace esphome diff --git a/components/ws_bridge/ws_bridge.h b/components/ws_bridge/ws_bridge.h new file mode 100644 index 00000000..8574d008 --- /dev/null +++ b/components/ws_bridge/ws_bridge.h @@ -0,0 +1,102 @@ +#pragma once +#include +#include +#include +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/event_pool.h" +#include "esphome/core/lock_free_queue.h" +#include "esp_websocket_client.h" +#include "ws_bridge_device.h" +#include "ws_protocol.h" + +namespace esphome { +namespace ws_bridge { + +enum WsBridgeState : uint8_t { + WS_BRIDGE_DISCONNECTED = 0, + WS_BRIDGE_WAIT_AUTH_REQUIRED, + WS_BRIDGE_WAIT_AUTH_OK, + WS_BRIDGE_CONNECTED, +}; + +// One transport-level event handed from the esp_websocket_client task to the +// main loop via a lock-free SPSC queue. Text payloads (WEBSOCKET_EVENT_DATA) +// are fully reassembled (across WS fragments) before being queued, so `data` +// is always either empty or one complete JSON message. +struct WsEvent { + esp_websocket_event_id_t event_id{WEBSOCKET_EVENT_ERROR}; + std::string data; + void release() { data.clear(); } +}; + +class WsBridgeComponent : public Component { + public: + void set_host(const std::string &host) { this->host_ = host; } + void set_port(uint16_t port) { this->port_ = port; } + void set_ssl(bool ssl) { this->ssl_ = ssl; } + void set_token(const std::string &token) { this->token_ = token; } + void set_gateway_id(const std::string &id) { this->gateway_id_ = id; } + void set_gateway_name(const std::string &name) { this->gateway_name_ = name; } + void set_keep_last_state_on_disconnect(bool v) { this->keep_last_state_on_disconnect_ = v; } + + void add_on_connected_callback(std::function &&cb) { this->connected_cb_.add(std::move(cb)); } + void add_on_disconnected_callback(std::function &&cb) { this->disconnected_cb_.add(std::move(cb)); } + + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + + void register_device(WsBridgeDevice *device) { this->devices_.push_back(device); } + bool is_connected() const { return this->state_ == WS_BRIDGE_CONNECTED; } + + // Called by platform entities (via WsBridgeDevice helpers) to push state + // and declarations. No-ops while not connected; the next (re)connect will + // re-declare and re-push through ws_bridge_declare(). + void send_state_float(const std::string &unique_id, float value); + void send_state_bool(const std::string &unique_id, bool value); + void send_state_string(const std::string &unique_id, const std::string &value); + void send_entity_declare(const std::string &unique_id, const std::string &platform, const std::string &name, + const std::string &device_id, const std::string &device_name, + const std::function &extra); + + protected: + static void ws_event_handler_(void *handler_args, esp_event_base_t base, int32_t event_id, void *event_data); + void handle_event_(const WsEvent &event); + void handle_message_(const std::string &raw); + void route_command_(const WsCommand &command); + void declare_all_entities_(); + void send_raw_(const std::string &msg); + uint32_t next_id_() { return ++this->msg_id_; } + void set_state_(WsBridgeState s); + + esp_websocket_client_handle_t client_{nullptr}; + bool started_{false}; + + std::string host_; + uint16_t port_{8123}; + bool ssl_{true}; + std::string token_; + std::string gateway_id_; + std::string gateway_name_; + bool keep_last_state_on_disconnect_{false}; + + WsBridgeState state_{WS_BRIDGE_DISCONNECTED}; + uint32_t msg_id_{0}; + std::vector devices_{}; + + // Producer-side (WS client task) fragment reassembly buffer. Only ever + // touched from ws_event_handler_(), never from loop() — no locking needed. + std::string rx_accum_; + + static constexpr uint8_t EVENT_QUEUE_SIZE = 8; + EventPool event_pool_; + LockFreeQueue event_queue_; + + CallbackManager connected_cb_{}; + CallbackManager disconnected_cb_{}; +}; + +} // namespace ws_bridge +} // namespace esphome diff --git a/components/ws_bridge/ws_bridge_device.h b/components/ws_bridge/ws_bridge_device.h new file mode 100644 index 00000000..64442c7f --- /dev/null +++ b/components/ws_bridge/ws_bridge_device.h @@ -0,0 +1,41 @@ +#pragma once +#include +#include "ws_protocol.h" + +namespace esphome { +namespace ws_bridge { + +class WsBridgeComponent; + +// Shared base for every ws_bridge platform entity (sensor/binary_sensor/switch/ +// number/select/button). Holds the protocol-facing identity fields and gives +// the hub a uniform way to ask any registered entity to (re)send its +// ws_bridge/entity declaration (used on first connect and on every reconnect). +class WsBridgeDevice { + public: + void set_ws_bridge_parent(WsBridgeComponent *parent) { this->parent_ = parent; } + void set_unique_id(const std::string &id) { this->unique_id_ = id; } + void set_device_id(const std::string &id) { this->device_id_ = id; } + void set_device_name(const std::string &name) { this->device_name_ = name; } + + const std::string &get_ws_bridge_unique_id() const { return this->unique_id_; } + + // Ask this entity to send its ws_bridge/entity declaration (and, if it has + // one, its current state) over the parent connection. Called by the hub + // once per entity on (re)connect. + virtual void ws_bridge_declare() = 0; + + // Called by the hub when a command arrives for this entity's unique_id. + // Read-only platforms (sensor/binary_sensor) never receive commands and + // keep the no-op default. + virtual void ws_bridge_handle_command(const WsCommand &command) {} + + protected: + WsBridgeComponent *parent_{nullptr}; + std::string unique_id_; + std::string device_id_; + std::string device_name_; +}; + +} // namespace ws_bridge +} // namespace esphome diff --git a/components/ws_bridge/ws_bridge_entity_json.h b/components/ws_bridge/ws_bridge_entity_json.h new file mode 100644 index 00000000..d0fca19d --- /dev/null +++ b/components/ws_bridge/ws_bridge_entity_json.h @@ -0,0 +1,36 @@ +#pragma once +#include +#include "esphome/core/entity_base.h" +#include "ws_protocol.h" + +namespace esphome { +namespace ws_bridge { + +// Adds the declare fields common to every ws_bridge entity platform +// (device_class, icon, entity_category) from the entity's own EntityBase +// metadata. Platform-specific fields (unit_of_measurement/state_class for +// sensor, options for select, min/max/step for number, ...) are added by each +// platform's own ws_bridge_declare() on top of this. +inline void add_common_entity_fields(JsonObject root, const EntityBase &entity) { + std::array dc_buf; + const char *dc = entity.get_device_class_to(dc_buf); + if (dc != nullptr && dc[0] != '\0') root["device_class"] = dc; + + std::array icon_buf; + const char *icon = entity.get_icon_to(icon_buf); + if (icon != nullptr && icon[0] != '\0') root["icon"] = icon; + + switch (entity.get_entity_category()) { + case ENTITY_CATEGORY_CONFIG: + root["entity_category"] = "config"; + break; + case ENTITY_CATEGORY_DIAGNOSTIC: + root["entity_category"] = "diagnostic"; + break; + default: + break; + } +} + +} // namespace ws_bridge +} // namespace esphome diff --git a/components/ws_bridge/ws_protocol.cpp b/components/ws_bridge/ws_protocol.cpp new file mode 100644 index 00000000..9b1379fe --- /dev/null +++ b/components/ws_bridge/ws_protocol.cpp @@ -0,0 +1,111 @@ +#include "ws_protocol.h" + +namespace esphome { +namespace ws_bridge { + +ParsedMessage parse_message(const std::string &raw) { + ParsedMessage msg; + json::parse_json(raw, [&](JsonObject root) -> bool { + if (!root["type"].is()) return false; + msg.type = root["type"].as(); + + if (msg.type == "result") { + msg.success = root["success"].is() && root["success"].as(); + return true; + } + + if (msg.type == "event") { + JsonObject event = root["event"].as(); + if (!event.isNull() && event["kind"].is() && + std::string(event["kind"].as()) == "command") { + if (event["unique_id"].is()) msg.command.unique_id = event["unique_id"].as(); + if (event["action"].is()) msg.command.action = event["action"].as(); + if (!event["value"].isNull()) { + msg.command.has_value = true; + if (event["value"].is()) { + msg.command.value_string = event["value"].as(); + } else { + msg.command.value_float = event["value"].as(); + } + } + } + return true; + } + + return true; // auth_required / auth_ok / auth_invalid: only msg.type is needed + }); + return msg; +} + +std::string build_auth(const std::string &access_token) { + return json::build_json([&](JsonObject root) { + root["type"] = "auth"; + root["access_token"] = access_token; + }); +} + +std::string build_connect(uint32_t id, const std::string &gateway_id, const std::string &name, + bool keep_last_state_on_disconnect) { + return json::build_json([&](JsonObject root) { + root["id"] = id; + root["type"] = "ws_bridge/connect"; + root["gateway_id"] = gateway_id; + if (!name.empty()) root["name"] = name; + root["keep_last_state_on_disconnect"] = keep_last_state_on_disconnect; + }); +} + +std::string build_entity_declare(uint32_t id, const std::string &unique_id, const std::string &platform, + const std::string &name, const std::string &device_id, + const std::string &device_name, + const std::function &extra) { + return json::build_json([&](JsonObject root) { + root["id"] = id; + root["type"] = "ws_bridge/entity"; + root["unique_id"] = unique_id; + root["platform"] = platform; + root["name"] = name; + if (!device_id.empty()) { + JsonObject dev = root["device"].to(); + dev["id"] = device_id; + if (!device_name.empty()) dev["name"] = device_name; + } + if (extra) extra(root); + }); +} + +std::string build_state_float(uint32_t id, const std::string &unique_id, float value) { + return json::build_json([&](JsonObject root) { + root["id"] = id; + root["type"] = "ws_bridge/state"; + JsonArray states = root["states"].to(); + JsonObject item = states.add(); + item["unique_id"] = unique_id; + item["value"] = value; + }); +} + +std::string build_state_bool(uint32_t id, const std::string &unique_id, bool value) { + return json::build_json([&](JsonObject root) { + root["id"] = id; + root["type"] = "ws_bridge/state"; + JsonArray states = root["states"].to(); + JsonObject item = states.add(); + item["unique_id"] = unique_id; + item["value"] = value; + }); +} + +std::string build_state_string(uint32_t id, const std::string &unique_id, const std::string &value) { + return json::build_json([&](JsonObject root) { + root["id"] = id; + root["type"] = "ws_bridge/state"; + JsonArray states = root["states"].to(); + JsonObject item = states.add(); + item["unique_id"] = unique_id; + item["value"] = value; + }); +} + +} // namespace ws_bridge +} // namespace esphome diff --git a/components/ws_bridge/ws_protocol.h b/components/ws_bridge/ws_protocol.h new file mode 100644 index 00000000..b7516836 --- /dev/null +++ b/components/ws_bridge/ws_protocol.h @@ -0,0 +1,50 @@ +#pragma once +#include +#include +#include +#include "esphome/components/json/json_util.h" + +namespace esphome { +namespace ws_bridge { + +// A command pushed by Home Assistant to a controllable entity +// (switch/number/select/button), per PROTOCOL.md §4. +struct WsCommand { + std::string unique_id; + std::string action; // "turn_on" | "turn_off" | "set_value" | "select_option" | "press" + bool has_value{false}; + float value_float{0}; + std::string value_string; +}; + +// Top-level parse result for one incoming WebSocket text frame. Only the +// fields this client cares about are extracted (the JsonDocument backing the +// parse is gone once parse_message() returns, so everything needed must be +// copied out as plain C++ values here). +struct ParsedMessage { + std::string type; // "auth_required" | "auth_ok" | "auth_invalid" | "result" | "event" | "" (unrecognized) + bool success{false}; // for "result" + WsCommand command; // populated when type == "event" and event.kind == "command" +}; + +ParsedMessage parse_message(const std::string &raw); + +// Outgoing message builders (HA websocket auth messages have no "id"; every +// ws_bridge/* command does). +std::string build_auth(const std::string &access_token); +std::string build_connect(uint32_t id, const std::string &gateway_id, const std::string &name, + bool keep_last_state_on_disconnect); + +// `extra` (may be empty) is called with the message's root JsonObject to add +// platform-specific declare fields (device_class, options, min/max/step, ...). +std::string build_entity_declare(uint32_t id, const std::string &unique_id, const std::string &platform, + const std::string &name, const std::string &device_id, + const std::string &device_name, + const std::function &extra); + +std::string build_state_float(uint32_t id, const std::string &unique_id, float value); +std::string build_state_bool(uint32_t id, const std::string &unique_id, bool value); +std::string build_state_string(uint32_t id, const std::string &unique_id, const std::string &value); + +} // namespace ws_bridge +} // namespace esphome From 415c909d4926717cbd204f999f8779d815b3e6b2 Mon Sep 17 00:00:00 2001 From: eigger Date: Sat, 18 Jul 2026 12:47:00 +0900 Subject: [PATCH 2/3] fix(ws_bridge): clear fragment reassembly buffer on connect/disconnect rx_accum_ (the WS fragment reassembly buffer) was only cleared after a complete message was dequeued. If the socket dropped mid-fragment (a message split across TCP segments that never finished), the leftover partial bytes would still be sitting in rx_accum_ when the connection came back, and would get silently prepended to the next connection's first fragmented message, corrupting the JSON. Clear rx_accum_ on WEBSOCKET_EVENT_CONNECTED/DISCONNECTED/ERROR/CLOSED (from the producer-task callback itself, preserving the single-writer invariant) so every connection starts message reassembly from a clean slate. Verified compiling for esp32dev (esp-idf) with ESPHome 2026.7. Co-Authored-By: Claude Opus 4.8 --- components/ws_bridge/ws_bridge.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/components/ws_bridge/ws_bridge.cpp b/components/ws_bridge/ws_bridge.cpp index 6c14376b..394f1a64 100644 --- a/components/ws_bridge/ws_bridge.cpp +++ b/components/ws_bridge/ws_bridge.cpp @@ -75,6 +75,15 @@ void WsBridgeComponent::ws_event_handler_(void *handler_args, esp_event_base_t b auto *data = static_cast(event_data); auto ws_event_id = static_cast(event_id); + // A (re)connect or drop always starts a fresh message stream: discard any + // partial fragment left over from a message that never completed (e.g. the + // socket dropped mid-fragment), so it can't get concatenated with data from + // a later connection. + if (ws_event_id == WEBSOCKET_EVENT_CONNECTED || ws_event_id == WEBSOCKET_EVENT_DISCONNECTED || + ws_event_id == WEBSOCKET_EVENT_ERROR || ws_event_id == WEBSOCKET_EVENT_CLOSED) { + self->rx_accum_.clear(); + } + if (ws_event_id == WEBSOCKET_EVENT_DATA) { if (data->op_code != WS_TRANSPORT_OPCODES_TEXT && data->op_code != WS_TRANSPORT_OPCODES_CONT) { return; // ignore ping/pong/close/binary frames From c1e5dd8dbaa1108af9f8bc3a04a346a77d3fe210 Mon Sep 17 00:00:00 2001 From: eigger Date: Sat, 18 Jul 2026 12:49:55 +0900 Subject: [PATCH 3/3] test(ws_bridge): add CI test config Adds tests/components/ws_bridge/test.esp32-idf.yaml exercising the hub and all six platforms (sensor/binary_sensor/switch/number/select/button), required by the repo's CI workflow (.github/workflows/esphome.yml) for any modified component. Verified with `esphome config` and `esphome compile` run the same way CI does (relative external_components path, cwd inside tests/components/ws_bridge). Co-Authored-By: Claude Opus 4.8 --- tests/components/ws_bridge/.gitignore | 5 ++ .../components/ws_bridge/test.esp32-idf.yaml | 72 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 tests/components/ws_bridge/.gitignore create mode 100644 tests/components/ws_bridge/test.esp32-idf.yaml diff --git a/tests/components/ws_bridge/.gitignore b/tests/components/ws_bridge/.gitignore new file mode 100644 index 00000000..d8b4157a --- /dev/null +++ b/tests/components/ws_bridge/.gitignore @@ -0,0 +1,5 @@ +# Gitignore settings for ESPHome +# This is an example and may include too much for your use-case. +# You can modify this file to suit your needs. +/.esphome/ +/secrets.yaml diff --git a/tests/components/ws_bridge/test.esp32-idf.yaml b/tests/components/ws_bridge/test.esp32-idf.yaml new file mode 100644 index 00000000..ea48e380 --- /dev/null +++ b/tests/components/ws_bridge/test.esp32-idf.yaml @@ -0,0 +1,72 @@ +esphome: + name: test-ws-bridge + +esp32: + board: esp32dev + framework: + type: esp-idf + +external_components: + - source: + type: local + path: ../../../components + components: [ ws_bridge ] + +wifi: + ssid: "test_ssid" + password: "test_password" + +logger: + +ws_bridge: + host: 192.168.0.10 + token: "test_token" + gateway_id: my_esp + name: "My ESP" + keep_last_state_on_disconnect: true + on_connected: + - logger.log: "ws_bridge connected" + on_disconnected: + - logger.log: "ws_bridge disconnected" + +sensor: + - platform: ws_bridge + unique_id: temp1 + name: "Temperature" + device_class: temperature + unit_of_measurement: "°C" + state_class: measurement + ws_device_id: sensor_hub_1 + ws_device_name: "Sensor Hub" + +binary_sensor: + - platform: ws_bridge + unique_id: motion1 + name: "Motion" + device_class: motion + +switch: + - platform: ws_bridge + unique_id: relay1 + name: "Relay" + +number: + - platform: ws_bridge + unique_id: setpoint1 + name: "Setpoint" + min_value: 0 + max_value: 100 + step: 0.5 + +select: + - platform: ws_bridge + unique_id: mode1 + name: "Mode" + options: + - "Auto" + - "Manual" + +button: + - platform: ws_bridge + unique_id: restart1 + name: "Restart"