Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions components/ws_bridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ update:
| `reannounce_interval` | | `60s` | How often to resend `ws_bridge/connect` + all entity/state declarations while nominally connected. Guards against the HA-side integration losing track of this gateway (e.g. its config entry reloaded) while the raw connection and ping/pong stay healthy — that scenario is otherwise invisible, since HA answers pings regardless of our integration's state. If a re-announce goes unanswered, forces a full reconnect rather than repeating the same no-op |
| `trackers` | | - | List of GPS `device_tracker` entities — see [GPS device trackers](#gps-device-trackers) below |
| `entities` | | - | Existing ESPHome entities to expose without a parallel `platform: ws_bridge` entity — see [Exposing existing entities](#exposing-existing-entities) |
| `snapshots` | | - | Optional ESP32 camera snapshots uploaded as HA `camera` or `image` entities — see [Camera snapshots](#camera-snapshots) |

"Templatable" fields accept a plain string or a `!lambda return std::string(...);` — the lambda is re-evaluated on every `ws_bridge/connect` (the initial connect and each `reannounce_interval`), not just once at boot.

Expand Down Expand Up @@ -270,6 +271,52 @@ left out. `datetime_id:` must point at an entity of the same `type:`.
strings (`2026-08-13`, `21:30:00`, `2026-08-13T21:30:00`); Home Assistant
attaches its own local timezone to them rather than reading them as UTC.

## Camera snapshots

`snapshots:` exposes JPEG frames from an `esp32_camera` as either a Home
Assistant `camera` (the default) or `image`. It requires **ESPHome 2026.10+
and ESP-IDF** because it uses ESPHome's generic `camera::Camera` listener API.
This requirement applies only to configurations that use `snapshots:`; normal
`ws_bridge` entity configurations keep their existing compatibility.

```yaml
esp32_camera:
id: front_sensor
# ...normal esp32_camera pins and settings...

ws_bridge:
host: !secret ha_host
token: !secret ha_token
snapshots:
- camera_id: front_sensor
unique_id: front_camera
name: "Front camera"
update_interval: 30s # request a fresh JPEG periodically
- camera_id: front_sensor
unique_id: front_thumbnail
name: "Front thumbnail"
platform: image # default is camera
update_interval: 2min
```

Each list item has a separate, ESP-initiated media WebSocket because Home
Assistant binds one media connection to exactly one declared entity. The
firmware first uses the existing authenticated control connection to request
`ws_bridge/media_open`, then authenticates a second standard `/api/websocket`
connection with the same token and sends `ws_bridge/media_connect`. Its JPEG
is one logical binary WebSocket message: the assigned handler byte immediately
followed by the JPEG bytes. This remains compatible with Home Assistant remote
access/reverse proxies: Home Assistant never connects back to the ESP device.

The worker retains a camera frame until its complete send finishes and keeps at
most one pending/latest frame per configured snapshot. A failed fragment closes
that media connection and obtains a fresh one-use session rather than trying to
continue an incomplete binary message. Frames larger than 1 MiB are discarded.
The media socket is recreated after a real control reconnect; routine control
re-announces leave an established media socket alone. See the HA-side
[wire protocol](https://github.com/eigger/hass-ws-bridge/blob/main/docs/PROTOCOL.md#321-authenticated-jpeg-upload-ws_bridgemedia_open--ws_bridgemedia_connect)
for authentication and frame limits.

## Exposing existing entities

Two ways to put an already-declared ESPHome entity onto Home Assistant.
Expand Down
84 changes: 84 additions & 0 deletions components/ws_bridge/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@
CONF_REANNOUNCE_INTERVAL,
CONF_ENTITIES,
CONF_SOURCE_ID,
CONF_SNAPSHOTS,
CONF_CAMERA_ID,
CONF_PLATFORM,
)

CODEOWNERS = ["@eigger"]
Expand All @@ -70,6 +73,10 @@
ws_bridge_ns = cg.esphome_ns.namespace("ws_bridge")
WsBridgeComponent = ws_bridge_ns.class_("WsBridgeComponent", cg.Component)
WsBridgeDevice = ws_bridge_ns.class_("WsBridgeDevice")
# Camera is deliberately not imported here. A normal ws_bridge configuration
# must not auto-load the camera integration; snapshots import/check it only
# inside to_code when their optional list is actually present.
WsBridgeSnapshot = ws_bridge_ns.class_("WsBridgeSnapshot", cg.PollingComponent)

ConnectedTrigger = ws_bridge_ns.class_("ConnectedTrigger", automation.Trigger.template())
DisconnectedTrigger = ws_bridge_ns.class_("DisconnectedTrigger", automation.Trigger.template())
Expand Down Expand Up @@ -145,6 +152,20 @@
}
)

SNAPSHOT_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(WsBridgeSnapshot),
# Kept loosely typed until to_code so importing this component alone
# does not auto-load camera. The conditional check there still
# rejects non-camera ids with a useful message.
cv.Required(CONF_CAMERA_ID): cv.use_id(cg.Component),
cv.Required(CONF_UNIQUE_ID): cv.string_strict,
cv.Required(CONF_NAME): cv.string_strict,
# HA's image and camera entities share the same JPEG upload transport.
cv.Optional(CONF_PLATFORM, default="camera"): cv.one_of("camera", "image", lower=True),
}
).extend(cv.polling_component_schema("60s"))


async def to_code_entity_ref(hub_var, conf):
source_id = conf[CONF_SOURCE_ID]
Expand Down Expand Up @@ -185,6 +206,14 @@ def _validate_esp_idf(config):
return config


def _validate_snapshot_version(config):
# Generic camera::CameraListener landed in ESPHome 2026.10. Keep this
# opt-in check separate from the hub's long-standing configuration.
if config.get(CONF_SNAPSHOTS):
cv.require_esphome_version(2026, 10, 0)(config)
return config


CONFIG_SCHEMA = cv.All(
cv.Schema(
{
Expand Down Expand Up @@ -261,9 +290,14 @@ def _validate_esp_idf(config):
# `platform: ws_bridge` entity per value — see ENTITY_SCHEMA above
# and the README's "Exposing existing entities" section.
cv.Optional(CONF_ENTITIES, default=[]): cv.ensure_list(ENTITY_SCHEMA),
# Optional snapshot media. This is intentionally not AUTO_LOADed:
# only configs using it need a camera platform and ESPHome 2026.10
# (the generic camera::Camera listener API used by this feature).
cv.Optional(CONF_SNAPSHOTS, default=[]): cv.ensure_list(SNAPSHOT_SCHEMA),
}
).extend(cv.COMPONENT_SCHEMA),
_validate_esp_idf,
_validate_snapshot_version,
)

# Shared schema every ws_bridge platform must extend, mirroring uartex's
Expand Down Expand Up @@ -291,6 +325,28 @@ async def register_ws_bridge_device(var, config):

async def to_code(config):
esp32.add_idf_component(name="espressif/esp_websocket_client", ref=ESP_WEBSOCKET_CLIENT_VERSION)
# The managed WebSocket client links ESP-TLS even for ws:// builds, so it
# needs ESPHome's trimmed mbedTLS client symbols in every configuration.
if hasattr(esp32, "request_tls"):
esp32.request_tls()
elif hasattr(esp32, "include_builtin_idf_component"):
# ESPHome releases with component trimming but before request_tls().
esp32.include_builtin_idf_component("esp-tls")
if hasattr(esp32, "add_idf_sdkconfig_option"):
esp32.add_idf_sdkconfig_option("CONFIG_MBEDTLS_TLS_ENABLED", True)
# Older ESPHome releases did not trim these TLS symbols, so no helper is
# needed (and their esp32 module does not expose the newer APIs above).
if config[CONF_SSL]:
# esp_websocket_client itself does not request ESPHome's trimmed TLS
# pieces. Explicitly retain the TLS stack and public CA bundle used by
# esp_crt_bundle_attach; otherwise a minimal ws_bridge firmware cannot
# even compile its TLS configuration.
cg.add_define("USE_WS_BRIDGE_CERT_BUNDLE")
if hasattr(esp32, "require_certificate_bundle"):
esp32.require_certificate_bundle()
elif hasattr(esp32, "require_full_certificate_bundle"):
# Older ESPHome's full-bundle request also enables the bundle.
esp32.require_full_certificate_bundle()

var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
Expand Down Expand Up @@ -356,3 +412,31 @@ async def to_code(config):

for conf in config.get(CONF_ENTITIES, []):
await to_code_entity_ref(var, conf)

snapshots = config.get(CONF_SNAPSHOTS, [])
if snapshots:
# Keep ws_bridge's existing minimum unchanged; snapshot users require
# the newer generic camera listener API (documented in README).
# ESPHome's generic C++ camera::Camera has no Python MockObjClass;
# esp32_camera is the currently supported source of that interface.
# Import it only here so ordinary ws_bridge configurations stay free
# of a camera dependency.
from esphome.components import esp32_camera

cg.add_define("USE_WS_BRIDGE_SNAPSHOT")
for conf in snapshots:
camera_full_id, camera_var = await cg.get_variable_with_full_id(conf[CONF_CAMERA_ID])
if not isinstance(camera_full_id.type, cg.MockObjClass) or not camera_full_id.type.inherits_from(
esp32_camera.ESP32Camera
):
raise cv.Invalid(
f"'{conf[CONF_CAMERA_ID].id}' is not an esp32_camera id; "
"snapshot currently supports esp32_camera"
)
snapshot = cg.new_Pvariable(conf[CONF_ID])
await cg.register_component(snapshot, conf)
cg.add(snapshot.set_parent(var))
cg.add(snapshot.set_camera(camera_var))
cg.add(snapshot.set_unique_id(conf[CONF_UNIQUE_ID]))
cg.add(snapshot.set_name(conf[CONF_NAME]))
cg.add(snapshot.set_platform(conf[CONF_PLATFORM]))
3 changes: 3 additions & 0 deletions components/ws_bridge/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
CONF_HW_VERSION = "hw_version"
CONF_KEEP_LAST_STATE_ON_DISCONNECT = "keep_last_state_on_disconnect"
CONF_SYNC_ENTITIES = "sync_entities"
CONF_SNAPSHOTS = "snapshots"
CONF_CAMERA_ID = "camera_id"
CONF_PLATFORM = "platform"

CONF_UNIQUE_ID = "unique_id"
# Prefixed with ws_ to avoid colliding with ESPHome's own reserved
Expand Down
10 changes: 5 additions & 5 deletions components/ws_bridge/event/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@
WsBridgeEvent = ws_bridge_ns.class_("WsBridgeEvent", event.Event, cg.Component, ws_bridge.WsBridgeDevice)

# An empty Python list renders as a bare `{}`, which is ambiguous across
# Event::set_event_types()'s initializer_list / FixedVector / vector overloads
# (including the =delete'd std::string ones that exist to catch mistakes). Name
# the type so the vector overload is an exact match. The wrapper's real list is
# copied off the source in setup() — see WsBridgeEvent::setup().
_NO_EVENT_TYPES = cg.RawExpression("std::vector<const char *>{}")
# Event::set_event_types() has a FixedVector overload in both supported older
# ESPHome and 2026.10+, while its std::vector overload was removed in 2026.10.
# Explicitly select the durable overload for an event_id: wrapper whose real
# list is copied from its source during setup().
_NO_EVENT_TYPES = cg.RawExpression("esphome::FixedVector<const char *>{}")


def validate_event_types(config):
Expand Down
12 changes: 12 additions & 0 deletions components/ws_bridge/ws_bridge.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#include "ws_bridge.h"
#include <algorithm>
#ifdef USE_WS_BRIDGE_CERT_BUNDLE
#include "esp_crt_bundle.h"
#endif
#include "esp_transport_ws.h"
#include "esphome/components/network/util.h"
#include "esphome/core/application.h"
Expand All @@ -23,7 +25,9 @@ void WsBridgeComponent::setup() {
config.path = "/api/websocket";
config.transport = this->ssl_ ? WEBSOCKET_TRANSPORT_OVER_SSL : WEBSOCKET_TRANSPORT_OVER_TCP;
if (this->ssl_) {
#ifdef USE_WS_BRIDGE_CERT_BUNDLE
config.crt_bundle_attach = esp_crt_bundle_attach;
#endif
}
config.disable_auto_reconnect = false;
config.reconnect_timeout_ms = 10000;
Expand Down Expand Up @@ -501,6 +505,14 @@ void WsBridgeComponent::handle_message_(const std::string &raw) {
this->awaiting_connect_result_ = false;
}
}
this->message_cb_.call(msg);
}

uint32_t WsBridgeComponent::open_media_session(const std::string &unique_id) {
if (!this->is_connected())
return 0;
uint32_t id = this->next_id_();
return this->send_raw_(build_media_open(id, unique_id)) ? id : 0;
}

void WsBridgeComponent::route_command_(const WsCommand &command) {
Expand Down
13 changes: 13 additions & 0 deletions components/ws_bridge/ws_bridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ class WsBridgeComponent : public Component {
// declarations must hook this rather than on_connected, so that a
// re-announce which heals a lost HA-side registration re-declares them too.
void add_on_declare_callback(std::function<void()> &&cb) { this->declare_cb_.add(std::move(cb)); }
// Receives complete, parsed control-channel messages on the ESPHome loop.
// Optional features (such as snapshot media) use this for their own result
// IDs without handling messages in the websocket client task.
void add_message_callback(std::function<void(const ParsedMessage &)> &&cb) { this->message_cb_.add(std::move(cb)); }

void setup() override;
void loop() override;
Expand All @@ -89,6 +93,14 @@ class WsBridgeComponent : public Component {
// atomic. See ws_event_handler_ for why the disconnect-family transitions
// in particular can't wait for the main loop to process a queued event.
bool is_connected() const { return this->state_.load(std::memory_order_acquire) == WS_BRIDGE_CONNECTED; }
const std::string &get_host() const { return this->host_; }
uint16_t get_port() const { return this->port_; }
bool get_ssl() const { return this->ssl_; }
const std::string &get_token() const { return this->token_; }

// Opens a one-use, HA-authenticated media session for an already declared
// entity. The caller correlates the returned control-channel message ID.
uint32_t open_media_session(const std::string &unique_id);

// Called by platform entities (via WsBridgeDevice helpers) to push state
// and declarations. No-ops while not connected; the next (re)connect will
Expand Down Expand Up @@ -314,6 +326,7 @@ class WsBridgeComponent : public Component {
CallbackManager<void()> connected_cb_{};
CallbackManager<void()> disconnected_cb_{};
CallbackManager<void()> declare_cb_{};
CallbackManager<void(const ParsedMessage &)> message_cb_{};
};

} // namespace ws_bridge
Expand Down
Loading
Loading