tescmd 0.2.0__py3-none-any.whl → 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tescmd/__init__.py +1 -1
- tescmd/api/client.py +41 -4
- tescmd/api/command.py +1 -1
- tescmd/api/errors.py +5 -0
- tescmd/api/signed_command.py +19 -14
- tescmd/auth/oauth.py +5 -1
- tescmd/auth/server.py +6 -1
- tescmd/auth/token_store.py +8 -1
- tescmd/cache/response_cache.py +8 -1
- tescmd/cli/_client.py +142 -20
- tescmd/cli/_options.py +2 -4
- tescmd/cli/auth.py +96 -14
- tescmd/cli/energy.py +2 -0
- tescmd/cli/main.py +27 -8
- tescmd/cli/mcp_cmd.py +153 -0
- tescmd/cli/nav.py +3 -1
- tescmd/cli/openclaw.py +169 -0
- tescmd/cli/security.py +7 -1
- tescmd/cli/serve.py +923 -0
- tescmd/cli/setup.py +18 -7
- tescmd/cli/sharing.py +2 -0
- tescmd/cli/status.py +1 -1
- tescmd/cli/trunk.py +8 -17
- tescmd/cli/user.py +16 -1
- tescmd/cli/vehicle.py +135 -462
- tescmd/deploy/github_pages.py +8 -0
- tescmd/mcp/__init__.py +7 -0
- tescmd/mcp/server.py +648 -0
- tescmd/models/auth.py +5 -2
- tescmd/openclaw/__init__.py +23 -0
- tescmd/openclaw/bridge.py +330 -0
- tescmd/openclaw/config.py +167 -0
- tescmd/openclaw/dispatcher.py +522 -0
- tescmd/openclaw/emitter.py +175 -0
- tescmd/openclaw/filters.py +123 -0
- tescmd/openclaw/gateway.py +687 -0
- tescmd/openclaw/telemetry_store.py +53 -0
- tescmd/output/rich_output.py +46 -14
- tescmd/protocol/commands.py +2 -2
- tescmd/protocol/encoder.py +16 -13
- tescmd/protocol/payloads.py +132 -11
- tescmd/protocol/session.py +8 -5
- tescmd/protocol/signer.py +3 -17
- tescmd/telemetry/__init__.py +9 -0
- tescmd/telemetry/cache_sink.py +154 -0
- tescmd/telemetry/csv_sink.py +180 -0
- tescmd/telemetry/dashboard.py +4 -4
- tescmd/telemetry/fanout.py +49 -0
- tescmd/telemetry/fields.py +308 -129
- tescmd/telemetry/mapper.py +239 -0
- tescmd/telemetry/server.py +26 -19
- tescmd/telemetry/setup.py +468 -0
- tescmd/telemetry/tui.py +1716 -0
- tescmd/triggers/__init__.py +18 -0
- tescmd/triggers/manager.py +264 -0
- tescmd/triggers/models.py +93 -0
- {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/METADATA +80 -32
- {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/RECORD +61 -39
- {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/WHEEL +0 -0
- {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/entry_points.txt +0 -0
- {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Cache-warming telemetry sink.
|
|
2
|
+
|
|
3
|
+
Receives decoded telemetry frames, translates field values via
|
|
4
|
+
:class:`~tescmd.telemetry.mapper.TelemetryMapper`, and merges them into
|
|
5
|
+
a :class:`~tescmd.cache.response_cache.ResponseCache`. This keeps the
|
|
6
|
+
MCP tool cache warm so read operations are free while telemetry is active.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import time
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from tescmd.cache.response_cache import ResponseCache
|
|
17
|
+
from tescmd.telemetry.decoder import TelemetryFrame
|
|
18
|
+
from tescmd.telemetry.mapper import TelemetryMapper
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _deep_set(target: dict[str, Any], dotted_path: str, value: Any) -> None:
|
|
24
|
+
"""Set a value in a nested dict using a dotted path.
|
|
25
|
+
|
|
26
|
+
Creates intermediate dicts as needed.
|
|
27
|
+
|
|
28
|
+
>>> d: dict[str, Any] = {}
|
|
29
|
+
>>> _deep_set(d, "charge_state.battery_level", 80)
|
|
30
|
+
>>> d
|
|
31
|
+
{'charge_state': {'battery_level': 80}}
|
|
32
|
+
"""
|
|
33
|
+
keys = dotted_path.split(".")
|
|
34
|
+
for key in keys[:-1]:
|
|
35
|
+
if key not in target or not isinstance(target[key], dict):
|
|
36
|
+
target[key] = {}
|
|
37
|
+
target = target[key]
|
|
38
|
+
target[keys[-1]] = value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> None:
|
|
42
|
+
"""Recursively merge *overlay* into *base* (mutates *base*).
|
|
43
|
+
|
|
44
|
+
Dict values are merged recursively; all other values are overwritten.
|
|
45
|
+
"""
|
|
46
|
+
for key, value in overlay.items():
|
|
47
|
+
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
|
|
48
|
+
_deep_merge(base[key], value)
|
|
49
|
+
else:
|
|
50
|
+
base[key] = value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CacheSink:
|
|
54
|
+
"""Telemetry sink that warms the response cache.
|
|
55
|
+
|
|
56
|
+
Accumulates telemetry updates in a buffer and flushes them to the
|
|
57
|
+
:class:`ResponseCache` at a configurable interval. While active,
|
|
58
|
+
cached data is given a generous TTL so MCP reads don't trigger API
|
|
59
|
+
requests.
|
|
60
|
+
|
|
61
|
+
Parameters:
|
|
62
|
+
cache: The file-based response cache to write into.
|
|
63
|
+
mapper: Field mapper for telemetry → VehicleData translation.
|
|
64
|
+
vin: Vehicle VIN to cache data for.
|
|
65
|
+
flush_interval: Minimum seconds between disk flushes.
|
|
66
|
+
telemetry_ttl: TTL in seconds for cache entries while streaming.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
cache: ResponseCache,
|
|
72
|
+
mapper: TelemetryMapper,
|
|
73
|
+
vin: str,
|
|
74
|
+
*,
|
|
75
|
+
flush_interval: float = 1.0,
|
|
76
|
+
telemetry_ttl: int = 120,
|
|
77
|
+
) -> None:
|
|
78
|
+
self._cache = cache
|
|
79
|
+
self._mapper = mapper
|
|
80
|
+
self._vin = vin
|
|
81
|
+
self._flush_interval = flush_interval
|
|
82
|
+
self._telemetry_ttl = telemetry_ttl
|
|
83
|
+
self._pending: dict[str, Any] = {}
|
|
84
|
+
self._last_flush: float = float("-inf")
|
|
85
|
+
self._frame_count: int = 0
|
|
86
|
+
self._field_count: int = 0
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def frame_count(self) -> int:
|
|
90
|
+
"""Total frames processed."""
|
|
91
|
+
return self._frame_count
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def field_count(self) -> int:
|
|
95
|
+
"""Total field updates applied to cache."""
|
|
96
|
+
return self._field_count
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def pending_count(self) -> int:
|
|
100
|
+
"""Number of buffered updates not yet flushed."""
|
|
101
|
+
return self._count_leaves(self._pending)
|
|
102
|
+
|
|
103
|
+
async def on_frame(self, frame: TelemetryFrame) -> None:
|
|
104
|
+
"""Process a decoded telemetry frame into the cache buffer.
|
|
105
|
+
|
|
106
|
+
Skips frames for other VINs. Maps each datum through the
|
|
107
|
+
:class:`TelemetryMapper` and buffers the results for the next
|
|
108
|
+
flush cycle.
|
|
109
|
+
"""
|
|
110
|
+
if frame.vin != self._vin:
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
self._frame_count += 1
|
|
114
|
+
|
|
115
|
+
for datum in frame.data:
|
|
116
|
+
for path, value in self._mapper.map(datum.field_name, datum.value):
|
|
117
|
+
_deep_set(self._pending, path, value)
|
|
118
|
+
self._field_count += 1
|
|
119
|
+
|
|
120
|
+
now = time.monotonic()
|
|
121
|
+
if now - self._last_flush >= self._flush_interval:
|
|
122
|
+
self.flush()
|
|
123
|
+
self._last_flush = now
|
|
124
|
+
|
|
125
|
+
def flush(self) -> None:
|
|
126
|
+
"""Merge buffered updates into the response cache immediately.
|
|
127
|
+
|
|
128
|
+
Called automatically during ``on_frame`` when the flush interval
|
|
129
|
+
has elapsed, or manually for cleanup / testing.
|
|
130
|
+
"""
|
|
131
|
+
if not self._pending:
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
# Read-modify-write: merge into existing cached data
|
|
135
|
+
existing = self._cache.get(self._vin)
|
|
136
|
+
blob: dict[str, Any] = existing.data if existing else {"vin": self._vin, "state": "online"}
|
|
137
|
+
|
|
138
|
+
_deep_merge(blob, self._pending)
|
|
139
|
+
self._cache.put(self._vin, blob, ttl=self._telemetry_ttl)
|
|
140
|
+
self._cache.put_wake_state(self._vin, "online", ttl=self._telemetry_ttl)
|
|
141
|
+
self._pending.clear()
|
|
142
|
+
|
|
143
|
+
logger.debug("Cache flushed for %s (%d fields)", self._vin, self._field_count)
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def _count_leaves(d: dict[str, Any]) -> int:
|
|
147
|
+
"""Count leaf (non-dict) values in a nested dict."""
|
|
148
|
+
count = 0
|
|
149
|
+
for v in d.values():
|
|
150
|
+
if isinstance(v, dict):
|
|
151
|
+
count += CacheSink._count_leaves(v)
|
|
152
|
+
else:
|
|
153
|
+
count += 1
|
|
154
|
+
return count
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Wide-format CSV log sink for vehicle telemetry.
|
|
2
|
+
|
|
3
|
+
Writes one row per telemetry frame with one column per subscribed field.
|
|
4
|
+
The header extends dynamically as new fields are discovered.
|
|
5
|
+
|
|
6
|
+
Output format::
|
|
7
|
+
|
|
8
|
+
timestamp,vin,BatteryLevel,ChargeLimitSoc,InsideTemp,...
|
|
9
|
+
2026-02-01T12:34:55Z,5YJ3E...,80,90,22.5,...
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import csv
|
|
15
|
+
import logging
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import IO, TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from tescmd.telemetry.decoder import TelemetryFrame
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# Fixed columns that always appear first.
|
|
26
|
+
_FIXED_COLUMNS = ("timestamp", "vin")
|
|
27
|
+
|
|
28
|
+
# Flush to disk every N frames for crash safety.
|
|
29
|
+
_FLUSH_INTERVAL = 10
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def create_log_path(vin: str, config_dir: Path | None = None) -> Path:
|
|
33
|
+
"""Build a timestamped CSV log path under the config directory.
|
|
34
|
+
|
|
35
|
+
Returns a path like ``~/.config/tescmd/logs/serve-{VIN}-{YYYYMMDD-HHMMSS}.csv``.
|
|
36
|
+
Creates the ``logs/`` subdirectory if it does not exist.
|
|
37
|
+
"""
|
|
38
|
+
if config_dir is None:
|
|
39
|
+
config_dir = Path.home() / ".config" / "tescmd"
|
|
40
|
+
log_dir = config_dir / "logs"
|
|
41
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
|
|
43
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
44
|
+
return log_dir / f"serve-{vin}-{stamp}.csv"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CSVLogSink:
|
|
48
|
+
"""Telemetry sink that writes wide-format CSV.
|
|
49
|
+
|
|
50
|
+
Raw API values are written without unit conversion (Celsius, miles, bar)
|
|
51
|
+
so the CSV is a faithful record of what the vehicle reported.
|
|
52
|
+
|
|
53
|
+
Parameters:
|
|
54
|
+
path: Destination CSV file path.
|
|
55
|
+
vin: Only log frames matching this VIN (``None`` = log all).
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, path: Path, vin: str | None = None) -> None:
|
|
59
|
+
import atexit
|
|
60
|
+
|
|
61
|
+
self._path = path
|
|
62
|
+
self._vin = vin
|
|
63
|
+
self._fh: IO[str] | None = None
|
|
64
|
+
self._writer: csv.DictWriter[str] | None = None
|
|
65
|
+
self._fieldnames: list[str] = list(_FIXED_COLUMNS)
|
|
66
|
+
self._frame_count: int = 0
|
|
67
|
+
self._since_flush: int = 0
|
|
68
|
+
atexit.register(self.close)
|
|
69
|
+
|
|
70
|
+
# -- Properties -----------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def log_path(self) -> Path:
|
|
74
|
+
"""The CSV file path."""
|
|
75
|
+
return self._path
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def frame_count(self) -> int:
|
|
79
|
+
"""Total frames written."""
|
|
80
|
+
return self._frame_count
|
|
81
|
+
|
|
82
|
+
# -- Sink callback --------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
async def on_frame(self, frame: TelemetryFrame) -> None:
|
|
85
|
+
"""Write a single telemetry frame as a CSV row.
|
|
86
|
+
|
|
87
|
+
Called by :class:`~tescmd.telemetry.fanout.FrameFanout`.
|
|
88
|
+
Frames for other VINs are silently skipped.
|
|
89
|
+
"""
|
|
90
|
+
if self._vin is not None and frame.vin != self._vin:
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
# Build the row dict from frame data.
|
|
94
|
+
row: dict[str, Any] = {
|
|
95
|
+
"timestamp": frame.created_at.isoformat(),
|
|
96
|
+
"vin": frame.vin,
|
|
97
|
+
}
|
|
98
|
+
for datum in frame.data:
|
|
99
|
+
value = datum.value
|
|
100
|
+
# Flatten location dicts to a string representation.
|
|
101
|
+
if isinstance(value, dict):
|
|
102
|
+
value = ";".join(f"{k}={v}" for k, v in value.items())
|
|
103
|
+
row[datum.field_name] = value
|
|
104
|
+
|
|
105
|
+
# Discover new fields and rewrite the header if needed.
|
|
106
|
+
new_fields = [f for f in row if f not in self._fieldnames]
|
|
107
|
+
if new_fields:
|
|
108
|
+
self._fieldnames.extend(new_fields)
|
|
109
|
+
if self._fh is not None:
|
|
110
|
+
self._rewrite_header()
|
|
111
|
+
|
|
112
|
+
# Lazily open the file on the first frame.
|
|
113
|
+
if self._fh is None:
|
|
114
|
+
self._open()
|
|
115
|
+
|
|
116
|
+
assert self._writer is not None
|
|
117
|
+
self._writer.writerow(row)
|
|
118
|
+
self._frame_count += 1
|
|
119
|
+
self._since_flush += 1
|
|
120
|
+
|
|
121
|
+
if self._since_flush >= _FLUSH_INTERVAL:
|
|
122
|
+
self._flush()
|
|
123
|
+
|
|
124
|
+
# -- Lifecycle ------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def close(self) -> None:
|
|
127
|
+
"""Flush and close the CSV file."""
|
|
128
|
+
if self._fh is not None:
|
|
129
|
+
self._flush()
|
|
130
|
+
self._fh.close()
|
|
131
|
+
self._fh = None
|
|
132
|
+
self._writer = None
|
|
133
|
+
|
|
134
|
+
# -- Internals ------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
def _open(self) -> None:
|
|
137
|
+
"""Open the CSV file and write the initial header."""
|
|
138
|
+
self._fh = open(self._path, "w", newline="", encoding="utf-8") # noqa: SIM115
|
|
139
|
+
self._writer = csv.DictWriter(
|
|
140
|
+
self._fh,
|
|
141
|
+
fieldnames=self._fieldnames,
|
|
142
|
+
extrasaction="ignore",
|
|
143
|
+
)
|
|
144
|
+
self._writer.writeheader()
|
|
145
|
+
|
|
146
|
+
def _rewrite_header(self) -> None:
|
|
147
|
+
"""Rewrite the file with the expanded header, preserving existing rows.
|
|
148
|
+
|
|
149
|
+
This is called when a new telemetry field is discovered mid-stream.
|
|
150
|
+
The approach is: read existing content, rewrite with new fieldnames.
|
|
151
|
+
"""
|
|
152
|
+
if self._fh is None:
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
self._fh.flush()
|
|
156
|
+
self._fh.close()
|
|
157
|
+
|
|
158
|
+
# Read existing rows.
|
|
159
|
+
rows: list[dict[str, str]] = []
|
|
160
|
+
with open(self._path, newline="", encoding="utf-8") as f:
|
|
161
|
+
reader = csv.DictReader(f)
|
|
162
|
+
for r in reader:
|
|
163
|
+
rows.append(r)
|
|
164
|
+
|
|
165
|
+
# Rewrite with expanded header.
|
|
166
|
+
self._fh = open(self._path, "w", newline="", encoding="utf-8") # noqa: SIM115
|
|
167
|
+
self._writer = csv.DictWriter(
|
|
168
|
+
self._fh,
|
|
169
|
+
fieldnames=self._fieldnames,
|
|
170
|
+
extrasaction="ignore",
|
|
171
|
+
)
|
|
172
|
+
self._writer.writeheader()
|
|
173
|
+
for r in rows:
|
|
174
|
+
self._writer.writerow(r)
|
|
175
|
+
|
|
176
|
+
def _flush(self) -> None:
|
|
177
|
+
"""Flush the file handle to disk."""
|
|
178
|
+
if self._fh is not None:
|
|
179
|
+
self._fh.flush()
|
|
180
|
+
self._since_flush = 0
|
tescmd/telemetry/dashboard.py
CHANGED
|
@@ -158,8 +158,8 @@ class TelemetryDashboard:
|
|
|
158
158
|
temp_fields = {
|
|
159
159
|
"InsideTemp",
|
|
160
160
|
"OutsideTemp",
|
|
161
|
-
"
|
|
162
|
-
"
|
|
161
|
+
"HvacLeftTemperatureRequest",
|
|
162
|
+
"HvacRightTemperatureRequest",
|
|
163
163
|
"ModuleTempMax",
|
|
164
164
|
"ModuleTempMin",
|
|
165
165
|
}
|
|
@@ -173,7 +173,7 @@ class TelemetryDashboard:
|
|
|
173
173
|
"Odometer",
|
|
174
174
|
"EstBatteryRange",
|
|
175
175
|
"IdealBatteryRange",
|
|
176
|
-
"
|
|
176
|
+
"RatedRange",
|
|
177
177
|
"MilesToArrival",
|
|
178
178
|
}
|
|
179
179
|
if field_name in distance_fields and isinstance(value, (int, float)):
|
|
@@ -182,7 +182,7 @@ class TelemetryDashboard:
|
|
|
182
182
|
return f"{value:.1f} mi"
|
|
183
183
|
|
|
184
184
|
# Speed fields (API returns mph)
|
|
185
|
-
speed_fields = {"VehicleSpeed", "CruiseSetSpeed", "
|
|
185
|
+
speed_fields = {"VehicleSpeed", "CruiseSetSpeed", "CurrentLimitMph"}
|
|
186
186
|
if field_name in speed_fields and isinstance(value, (int, float)):
|
|
187
187
|
if self._units.distance == DistanceUnit.KM:
|
|
188
188
|
return f"{value * 1.60934:.0f} km/h"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Fan-out dispatcher for telemetry frames.
|
|
2
|
+
|
|
3
|
+
Multiplexes a single ``on_frame`` callback to N sinks, each error-isolated.
|
|
4
|
+
One sink failing does not affect others.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Awaitable, Callable
|
|
14
|
+
|
|
15
|
+
from tescmd.telemetry.decoder import TelemetryFrame
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FrameFanout:
|
|
21
|
+
"""Fan-out dispatcher: delivers each telemetry frame to all registered sinks."""
|
|
22
|
+
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
self._sinks: list[Callable[[TelemetryFrame], Awaitable[None]]] = []
|
|
25
|
+
|
|
26
|
+
def add_sink(self, callback: Callable[[TelemetryFrame], Awaitable[None]]) -> None:
|
|
27
|
+
"""Register a sink to receive telemetry frames."""
|
|
28
|
+
self._sinks.append(callback)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def sink_count(self) -> int:
|
|
32
|
+
"""Number of registered sinks."""
|
|
33
|
+
return len(self._sinks)
|
|
34
|
+
|
|
35
|
+
def has_sinks(self) -> bool:
|
|
36
|
+
"""Return ``True`` if at least one sink is registered."""
|
|
37
|
+
return len(self._sinks) > 0
|
|
38
|
+
|
|
39
|
+
async def on_frame(self, frame: TelemetryFrame) -> None:
|
|
40
|
+
"""Dispatch *frame* to all registered sinks.
|
|
41
|
+
|
|
42
|
+
Each sink is called independently. If a sink raises, the exception
|
|
43
|
+
is logged and the remaining sinks still receive the frame.
|
|
44
|
+
"""
|
|
45
|
+
for sink in self._sinks:
|
|
46
|
+
try:
|
|
47
|
+
await sink(frame)
|
|
48
|
+
except Exception:
|
|
49
|
+
logger.warning("Sink %s failed for frame", sink, exc_info=True)
|