"""WebSocket (Socket.IO) client for BragerOne realtime events."""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Awaitable, Callable
from contextlib import suppress
from typing import (
Any,
Protocol,
TypedDict,
runtime_checkable,
)
import socketio
from ..models.events import MODULE_CONNECTION_STATUS_CHANGED, MODULE_MEMORY_UPDATED, CloudOutageReason
from ..utils import spawn
from .client import format_expected_failure_reason, is_expected_upstream_unavailable
from .constants import IO_BASE, ONE_BASE, SOCK_PATH, WS_NAMESPACE
from .ws_reasons import classify_ws_failure_reason
log = logging.getLogger(__name__)
sio_log = logging.getLogger(__name__ + ".sio")
eio_log = logging.getLogger(__name__ + ".eio")
def _is_expected_ws_reconnect_failure(err: BaseException) -> bool:
"""Return whether a reconnect failure is an expected upstream/transient error."""
if is_expected_upstream_unavailable(err):
return True
if isinstance(err, TimeoutError):
return True
current: BaseException | None = err
seen: set[int] = set()
while current is not None and id(current) not in seen:
seen.add(id(current))
module = getattr(type(current), "__module__", "") or ""
if isinstance(current, ConnectionError) and (module.startswith("socketio") or module.startswith("engineio")):
return True
current = current.__cause__ or (current.__context__ if current.__context__ is not current.__cause__ else None)
return False
# Signature for a generic event handler used by the gateway.
EventHandler = Callable[[str, Any], None]
# Signature for a callback invoked on connection establishment.
ConnectedCb = Callable[[], Awaitable[None] | None]
[docs]
@runtime_checkable
class EventDispatcher(Protocol):
"""Protocol for an event dispatcher used by the realtime manager."""
def __call__(self, event_name: str, payload: Any) -> Awaitable[None] | None:
"""Handle an event with the given name and payload.
Args:
event_name: The event name.
payload: The event payload (varies by event).
"""
...
class _SubPayload(TypedDict, total=False):
"""Payload for subscription events."""
modules: list[str]
devids: list[str]
group_id: int
[docs]
class RealtimeManager:
"""Thin Socket.IO wrapper for BragerOne realtime channel.
The manager keeps a single AsyncClient connection, exposes the Engine.IO
SID and the namespace SID, and forwards selected events to a user-provided
callback (``EventHandler``). It does **not** interpret payloads; that is the
gateway's responsibility.
Notes:
- Authentication is provided **only** via HTTP headers (Bearer token).
- We always connect to the `:data:`~.constants.WS_NAMESPACE`` namespace.
- We listen to: ``snapshot`` and the various ``*:parameters:change`` events.
- Subscriptions are emitted in a few payload variants (``modules`` /
``devids``) and optionally include ``group_id``.
"""
def __init__(
self,
token: str,
*,
origin: str = ONE_BASE,
referer: str = f"{ONE_BASE}/",
io_base: str = IO_BASE,
socket_path: str = SOCK_PATH,
namespace: str = WS_NAMESPACE,
token_provider: Callable[[], Awaitable[str]] | None = None,
connect_timeout_s: float = 20.0,
) -> None:
"""Initialize the realtime manager.
Args:
token: Bearer token used for the initial Socket.IO HTTP upgrade.
origin: HTTP ``Origin`` header value (default: :data:`~.constants.ONE_BASE`).
referer: HTTP ``Referer`` header value (default: :data:`~.constants.ONE_BASE` + ``/``).
io_base: Base URL of the Engine.IO/Socket.IO server (default: :data:`~.constants.IO_BASE`).
socket_path: Socket.IO path on the server (default: :data:`~.constants.SOCK_PATH`).
namespace: The namespace to join (default: :data:`~.constants.WS_NAMESPACE`).
token_provider: Optional async callable returning a fresh access token (the
``Bearer `` prefix is added by the manager). When set, every (re)connect
attempt resolves the token through it, so long outages that outlive the
token TTL can still recover. Falls back to the static ``token`` when unset
or on error.
connect_timeout_s: Maximum seconds a single connect attempt may take before it is aborted
and retried by the supervisor (default: 20). Guards against hung TCP/DNS handshakes.
"""
self._token = token
self._token_provider = token_provider
self._connect_timeout_s = connect_timeout_s
self._origin = origin
self._referer = referer
self._io_base = io_base.rstrip("/")
self._socket_path = socket_path
self._namespace = namespace
self._connected = asyncio.Event()
self._on_connected: list[ConnectedCb] = []
self._on_disconnected: list[ConnectedCb] = []
self._on_event: EventDispatcher | None = None
self._modules: list[str] = []
self._group_id: int | None = None
self._connect_lock = asyncio.Lock()
self._supervisor_task: asyncio.Task[None] | None = None
self._supervisor_running = False
self._supervisor_interval_s = 15.0
# Cap leftover Engine.IO teardown so an aborted websocket cannot wedge reconnect.
self._disconnect_timeout_s = max(0.05, min(5.0, float(connect_timeout_s)))
# True until the first successful connect, then after each disconnect notify.
self._disconnect_notified = True
self._last_disconnect_reason: CloudOutageReason | None = None
# Reconnection is owned exclusively by our supervisor loop (with fresh-token
# resolution and a hard connect timeout); the built-in socket.io reconnect
# loop would bypass both and race with the supervisor, so it stays disabled.
self._sio: socketio.AsyncClient = self._make_sio()
self._register_sio_handlers()
# ---- Built-in handlers ----
async def _on_connect(self) -> None:
ns_sid = self.sid()
eng_sid = self.engine_sid()
log.info("WS connected, SID=%s", eng_sid)
log.info(
"WS connected, namespace_sid=%s, engine_sid=%s, namespaces=%s",
ns_sid,
eng_sid,
list(self._sio.namespaces),
)
for cb in list(self._on_connected):
try:
res = cb()
if asyncio.iscoroutine(res):
spawn(res, "on_connected_cb", log)
except Exception:
log.exception("Error in on_connected callback")
self._disconnect_notified = False
self._last_disconnect_reason = None
self._connected.set()
async def _on_disconnect(self, reason: Any | None = None) -> None:
"""Handle Socket.IO ``disconnect`` (may include an Engine.IO reason string)."""
classified = classify_ws_failure_reason(reason, default="disconnect")
if reason is None:
log.info("WS disconnected")
else:
log.info("WS disconnected (%s → %s)", reason, classified)
self._connected.clear()
self._notify_disconnected(reason=classified)
async def _on_connect_error(self, data: Any | None = None) -> None:
log.warning("WS connect_error: %s", data)
was_connected = self._connected.is_set()
self._connected.clear()
if was_connected:
self._notify_disconnected(reason=classify_ws_failure_reason(data, default="connect_error"))
def _notify_disconnected(
self,
*,
force: bool = False,
reason: CloudOutageReason | None = None,
) -> None:
"""Invoke disconnect callbacks (sync or async).
Args:
force: When True, notify even if a previous drop was already reported.
The supervisor reconnect loop and Socket.IO ``disconnect`` pass False
so a wedged client does not spam session-down callbacks.
reason: Optional stable outage token retained for :meth:`last_disconnect_reason`.
Ignored when this call is suppressed (already notified and ``force`` is False).
A coarse ``disconnect`` token does not overwrite a finer reason already
recorded (e.g. ``reconnect_error`` / ``empty_queue`` before notify).
"""
if self._disconnect_notified and not force:
return
if reason is not None:
existing = self._last_disconnect_reason
keep_finer = not force and reason == "disconnect" and existing is not None and existing != "disconnect"
if not keep_finer:
self._last_disconnect_reason = reason
elif self._last_disconnect_reason is None:
self._last_disconnect_reason = "disconnect"
self._disconnect_notified = True
for cb in list(self._on_disconnected):
try:
res = cb()
if asyncio.iscoroutine(res):
spawn(res, "on_disconnected_cb", log)
except Exception:
log.exception("Error in on_disconnected callback")
async def _on_reconnect(self) -> None:
log.info("WS reconnect OK")
async def _on_reconnect_attempt(self, number: int) -> None:
log.info("WS reconnect attempt #%s", number)
async def _on_reconnect_error(self, data: Any | None = None) -> None:
log.warning("WS reconnect_error: %s", data)
self._last_disconnect_reason = classify_ws_failure_reason(data, default="reconnect_error")
async def _on_error(self, data: Any) -> None:
log.error("WS ERROR: %s", data)
async def _on_message(self, data: Any) -> None:
log.debug("WS message → %s", data)
# --- Key domain events ---
async def _on_snapshot(self, payload: Any) -> None:
log.debug("WS EVENT snapshot → %s", payload)
self._dispatch("snapshot", payload)
async def _on_app_modules_parameters_change(self, payload: Any) -> None:
log.debug("WS EVENT app:modules:parameters:change → %s", payload)
self._dispatch("app:modules:parameters:change", payload)
# Fallback alt names occasionally seen in traces
async def _on_modules_parameters_change(self, payload: Any) -> None:
log.debug("WS EVENT modules:parameters:change → %s", payload)
self._dispatch("modules:parameters:change", payload)
async def _on_parameters_change(self, payload: Any) -> None:
log.debug("WS EVENT parameters:change → %s", payload)
self._dispatch("parameters:change", payload)
# Optional task-related events; kept for completeness/diagnostics.
async def _on_app_modules_task_created(self, p: Any) -> None:
log.debug("WS EVENT app:module:task:created → %s", p)
self._dispatch("app:module:task:created", p)
async def _on_app_modules_task_status_changed(self, p: Any) -> None:
log.debug("WS EVENT app:module:task:status:changed → %s", p)
self._dispatch("app:module:task:status:changed", p)
async def _on_app_modules_task_completed(self, p: Any) -> None:
log.debug("WS EVENT app:module:task:completed → %s", p)
self._dispatch("app:module:task:completed", p)
async def _on_app_module_connection_status_changed(self, p: Any) -> None:
log.debug("WS EVENT %s → %s", MODULE_CONNECTION_STATUS_CHANGED, p)
self._dispatch(MODULE_CONNECTION_STATUS_CHANGED, p)
# Numeric fallbacks observed in some builds
async def _on_ev60(self, p: Any) -> None:
log.debug("WS EVENT 60 → %s", p)
self._dispatch("app:module:task:status:changed", p)
async def _on_ev61(self, p: Any) -> None:
log.debug("WS EVENT 61 → %s", p)
self._dispatch("app:module:task:created", p)
async def _on_ev63(self, p: Any) -> None:
log.debug("WS EVENT 63 → %s", p)
self._dispatch("app:module:task:completed", p)
async def _on_module_memory_updated(self, p: Any) -> None:
log.debug("WS EVENT %s → %s", MODULE_MEMORY_UPDATED, p)
self._dispatch(MODULE_MEMORY_UPDATED, p)
# ---------------- Public API ----------------
[docs]
async def connect(self) -> None:
"""Open a Socket.IO connection with appropriate headers and wait for join."""
await self._ensure_connected(initial=True)
# Short grace period to ensure the namespace is fully established
await self._connected.wait()
await asyncio.sleep(0.1)
self._start_supervisor()
[docs]
async def force_reconnect(self) -> None:
"""Tear down a still-"connected" Socket.IO session and reconnect.
Used when ParamUpdates go silent while the client still reports up
(zombie Engine.IO that skipped disconnect callbacks). The SPA recovers
via built-in Socket.IO reconnect then ``connect`` →
``ModulesService.connect`` + REST ``/modules/parameters``; our supervisor
only acts when ``connected`` looks down, so this forces that same path
(``on_connected`` → gateway resubscribe + prime).
Waits for the namespace join (same as :meth:`connect`) so callers can
safely ``sid()`` / ``modules.connect`` immediately afterwards.
"""
log.warning("Forcing WS hard reconnect (zombie session recovery)")
self._notify_disconnected(force=True, reason="force_reconnect")
# Clear the namespace-joined bit so ``_ensure_connected`` does not treat a
# wedged ``sio.connected=True`` session as healthy.
self._connected.clear()
try:
await self._ensure_connected(initial=False)
try:
await asyncio.wait_for(self._connected.wait(), timeout=self._connect_timeout_s)
except TimeoutError:
log.warning("WS force reconnect: namespace join timed out")
return
await asyncio.sleep(0.1)
except Exception:
# Defensive: callers (gateway poll) must keep running after a failed force.
log.exception("WS force reconnect failed unexpectedly")
[docs]
async def hard_reset(self) -> None:
"""Abandon the Socket.IO client and open a brand-new transport session.
Stronger than :meth:`force_reconnect`: stops the supervisor, replaces the
``AsyncClient`` (handlers re-bound), then runs a full :meth:`connect`.
Used when disconnect/reconnect on the same client fails to restore live
``ParamUpdate`` traffic.
"""
log.warning("Hard-resetting WS transport (zombie session recovery)")
self._notify_disconnected(force=True, reason="hard_reset")
self._supervisor_running = False
task = self._supervisor_task
self._supervisor_task = None
if task is not None and not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
self._connected.clear()
try:
await asyncio.wait_for(self._sio.disconnect(), timeout=self._disconnect_timeout_s)
except Exception:
log.debug("WS hard_reset disconnect ignored", exc_info=True)
self._replace_client()
await self.connect()
def _start_supervisor(self) -> None:
if self._supervisor_task is not None and not self._supervisor_task.done():
return
self._supervisor_running = True
task = asyncio.create_task(
self._connection_supervisor(),
name="ws_connection_supervisor",
)
self._supervisor_task = task
def _supervisor_done(done_task: asyncio.Task[None]) -> None:
with suppress(asyncio.CancelledError):
done_task.result()
if self._supervisor_task is done_task:
self._supervisor_task = None
task.add_done_callback(_supervisor_done)
def _make_sio(self) -> socketio.AsyncClient:
"""Create a Socket.IO client with library reconnect disabled."""
return socketio.AsyncClient(
reconnection=False,
logger=sio_log, # pyright: ignore[reportArgumentType] # route socket.io logs to a sub-logger
engineio_logger=eio_log, # route engine.io logs to a sub-logger
)
def _register_sio_handlers(self) -> None:
"""Bind namespace handlers on the current Socket.IO client."""
ns = self._namespace
self._sio.on("connect", self._on_connect, namespace=ns)
self._sio.on("disconnect", self._on_disconnect, namespace=ns)
self._sio.on("connect_error", self._on_connect_error, namespace=ns)
self._sio.on("reconnect", self._on_reconnect, namespace=ns)
self._sio.on("reconnect_attempt", self._on_reconnect_attempt, namespace=ns)
self._sio.on("reconnect_error", self._on_reconnect_error, namespace=ns)
self._sio.on("error", self._on_error, namespace=ns)
self._sio.on("message", self._on_message, namespace=ns)
self._sio.on("snapshot", self._on_snapshot, namespace=ns)
self._sio.on(
"app:modules:parameters:change",
self._on_app_modules_parameters_change,
namespace=ns,
)
self._sio.on(
"modules:parameters:change", # fallback alt name
self._on_modules_parameters_change,
namespace=ns,
)
self._sio.on(
"parameters:change",
self._on_parameters_change,
namespace=ns,
)
self._sio.on(
"app:module:task:created",
self._on_app_modules_task_created,
namespace=ns,
)
self._sio.on(
"app:module:task:status:changed",
self._on_app_modules_task_status_changed,
namespace=ns,
)
self._sio.on(
"app:module:task:completed",
self._on_app_modules_task_completed,
namespace=ns,
)
self._sio.on(
MODULE_CONNECTION_STATUS_CHANGED,
self._on_app_module_connection_status_changed,
namespace=ns,
)
self._sio.on(
MODULE_MEMORY_UPDATED,
self._on_module_memory_updated,
namespace=ns,
)
self._sio.on("60", self._on_ev60, namespace=ns)
self._sio.on("61", self._on_ev61, namespace=ns)
self._sio.on("63", self._on_ev63, namespace=ns)
def _abandon_client(self, old: socketio.AsyncClient) -> None:
"""Best-effort abort of a wedged Engine.IO client without blocking reconnect."""
eio = getattr(old, "eio", None)
disconnect = getattr(eio, "disconnect", None) if eio is not None else None
if not callable(disconnect):
return
try:
result = disconnect(abort=True)
except TypeError:
try:
result = disconnect()
except Exception:
log.exception("Failed to abort leftover Engine.IO client")
return
except Exception:
log.exception("Failed to abort leftover Engine.IO client")
return
if asyncio.iscoroutine(result):
spawn(result, "eio_abort_disconnect", log)
def _replace_client(self) -> None:
"""Swap in a fresh Socket.IO client after a hung/aborted transport."""
old = self._sio
self._sio = self._make_sio()
self._register_sio_handlers()
self._abandon_client(old)
async def _reset_transport(self) -> None:
"""Drop a half-open Engine.IO session so the next ``connect()`` is clean.
Engine.IO abort (``WSMsgType.CLOSED``) can deadlock ``disconnect()`` waiting
for read/write loops. Bound that wait, then replace the client if it is still
wedged so the supervisor can reconnect.
"""
self._connected.clear()
try:
await asyncio.wait_for(self._sio.disconnect(), timeout=self._disconnect_timeout_s)
except TimeoutError:
log.warning("WS transport disconnect timed out; replacing Socket.IO client")
self._replace_client()
except Exception:
log.warning("WS transport disconnect failed; replacing Socket.IO client", exc_info=True)
self._replace_client()
async def _resolve_token(self) -> str:
"""Return a token for the next connect attempt, refreshing via the provider if set."""
if self._token_provider is None:
return self._token
try:
# Bound the provider too: re-authentication can stall during the same
# outage, and it must not wedge the supervisor before connect() even runs.
token = await asyncio.wait_for(self._token_provider(), timeout=self._connect_timeout_s)
except Exception:
# Auth backend unreachable or stalled — retry with the last known token.
log.warning("WS token refresh failed; falling back to the previous token", exc_info=True)
return self._token
self._token = token
return token
async def _ensure_connected(self, *, initial: bool) -> None:
if self._sio.connected and self._connected.is_set():
return
async with self._connect_lock:
if self._sio.connected and self._connected.is_set():
return
# Engine.IO abort / connect timeout can leave ``connected=True`` without a
# namespace join. A second ``connect()`` then hangs or no-ops — drop first.
await self._reset_transport()
token = await self._resolve_token()
headers = {
"Authorization": f"Bearer {token}",
"Origin": self._origin,
"Referer": self._referer,
"Accept-Language": "pl-PL,pl;q=0.9",
"x-appversion": "1.1.78",
}
try:
# Hard timeout: a hung TCP/DNS handshake must not wedge the supervisor forever.
await asyncio.wait_for(
self._sio.connect(
self._io_base,
headers=headers,
transports=["pooling", "websocket"],
socketio_path=self._socket_path,
namespaces=[self._namespace],
),
timeout=self._connect_timeout_s,
)
except Exception as err:
await self._reset_transport()
if initial:
raise
if _is_expected_ws_reconnect_failure(err):
log.warning(
"WS supervisor reconnect failed (expected upstream/transient): %s",
format_expected_failure_reason(err),
)
else:
log.warning("WS supervisor reconnect failed", exc_info=True)
async def _connection_supervisor(self) -> None:
try:
while self._supervisor_running:
await asyncio.sleep(self._supervisor_interval_s)
if self._sio.connected and self._connected.is_set():
continue
log.warning("WS disconnected state detected; forcing reconnect")
# Engine.IO abort often skips the Socket.IO disconnect callback.
# Notify now so CloudSession goes down and REST-prime can run even if
# leftover disconnect() is still wedged.
self._notify_disconnected(reason="supervisor_stale")
try:
await self._ensure_connected(initial=False)
except Exception:
# Defensive: the supervisor must never die on an unexpected error.
log.exception("WS supervisor iteration failed unexpectedly")
except asyncio.CancelledError:
raise
[docs]
def sid(self) -> str | None:
"""Return the namespace SID (``/ws``), if available."""
try:
return str(self._sio.get_sid(self._namespace))
except Exception:
return None
[docs]
def engine_sid(self) -> str | None:
"""Return the underlying Engine.IO SID (transport-level)."""
return getattr(self._sio, "sid", None)
[docs]
def last_disconnect_reason(self) -> CloudOutageReason | None:
"""Return the most recent classified disconnect reason, if any."""
return self._last_disconnect_reason
[docs]
async def disconnect(self) -> None:
"""Close the Socket.IO connection if open."""
self._supervisor_running = False
task = self._supervisor_task
self._supervisor_task = None
if task is not None and not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
if self._sio.connected:
await self._sio.disconnect()
[docs]
def on_event(self, handler: EventDispatcher) -> None:
"""Register a single event dispatcher (gateway attaches here)."""
self._on_event = handler
[docs]
def add_on_connected(self, cb: ConnectedCb) -> None:
"""Register a callback to be called when the connection is established."""
self._on_connected.append(cb)
[docs]
def add_on_disconnected(self, cb: ConnectedCb) -> None:
"""Register a callback to be called when the Socket.IO session drops."""
self._on_disconnected.append(cb)
@property
def group_id(self) -> int | None:
"""Return the optional ``group_id`` included in subscription payloads."""
return self._group_id
@group_id.setter
def group_id(self, group_id: int | None) -> None:
"""Set an optional ``group_id`` to be included in subscription payloads."""
self._group_id = group_id
[docs]
async def subscribe(self, modules: list[str]) -> None:
"""Emit listen events for the provided devices (devids/modules)."""
self._modules = sorted(set(modules))
if not self._modules:
return
# Base variants: "modules" and alt. "devids"
base: _SubPayload = {"modules": self._modules}
base_alt: _SubPayload = {"devids": self._modules}
# Optionally include "group_id"
if self.group_id is not None:
base["group_id"] = self.group_id
base_alt["group_id"] = self.group_id
payloads: list[tuple[str, _SubPayload]] = [
("app:modules:parameters:listen", base),
("app:modules:parameters:listen", base_alt),
("app:modules:activity:quantity:listen", base),
("app:modules:activity:quantity:listen", base_alt),
("app:modules:alarms:quantity:listen", base),
("app:modules:alarms:quantity:listen", base_alt),
]
for ev, pl in payloads:
log.debug("EMIT %s %s", ev, pl)
try:
await self._sio.emit(ev, pl, namespace=self._namespace)
except Exception:
log.exception("Emit failed for %s", ev)
[docs]
async def resubscribe(self) -> None:
"""Re-emit subscription events after a reconnect."""
if self._modules:
await self.subscribe(self._modules)
# ---------------- Internal ----------------
def _dispatch(self, name: str, payload: Any) -> None:
"""Forward an event to the registered dispatcher (if any)."""
if self._on_event:
try:
self._on_event(name, payload)
except Exception:
log.exception("Error in on_event(%s) callback", name)