embedded-lab-control 0.2.0__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.
- embedded_lab_control-0.2.0.dist-info/METADATA +855 -0
- embedded_lab_control-0.2.0.dist-info/RECORD +64 -0
- embedded_lab_control-0.2.0.dist-info/WHEEL +5 -0
- embedded_lab_control-0.2.0.dist-info/entry_points.txt +2 -0
- embedded_lab_control-0.2.0.dist-info/licenses/LICENSE +21 -0
- embedded_lab_control-0.2.0.dist-info/top_level.txt +1 -0
- labctl/__init__.py +8 -0
- labctl/actuators/__init__.py +56 -0
- labctl/actuators/base.py +183 -0
- labctl/actuators/lcus1.py +232 -0
- labctl/actuators/mock.py +81 -0
- labctl/actuators/runtime.py +1178 -0
- labctl/cli.py +7485 -0
- labctl/core/__init__.py +36 -0
- labctl/core/admin_files.py +388 -0
- labctl/core/audit.py +269 -0
- labctl/core/claim_keepalive.py +342 -0
- labctl/core/config.py +1068 -0
- labctl/core/database.py +880 -0
- labctl/core/images.py +503 -0
- labctl/core/manager.py +2815 -0
- labctl/core/models.py +815 -0
- labctl/core/safe_open.py +109 -0
- labctl/extras.py +128 -0
- labctl/health/__init__.py +43 -0
- labctl/health/alerts.py +275 -0
- labctl/health/checks.py +533 -0
- labctl/health/daemon.py +397 -0
- labctl/mcp_server.py +5254 -0
- labctl/power/__init__.py +13 -0
- labctl/power/base.py +145 -0
- labctl/power/kasa.py +446 -0
- labctl/power/shelly.py +89 -0
- labctl/power/tasmota.py +95 -0
- labctl/sdwire/__init__.py +10 -0
- labctl/sdwire/controller.py +1116 -0
- labctl/sdwire/power_guard.py +190 -0
- labctl/serial/__init__.py +18 -0
- labctl/serial/boot_test.py +342 -0
- labctl/serial/capture.py +430 -0
- labctl/serial/keys.py +89 -0
- labctl/serial/proxy.py +802 -0
- labctl/serial/ser2net.py +338 -0
- labctl/serial/udev.py +273 -0
- labctl/services.py +210 -0
- labctl/web/__init__.py +9 -0
- labctl/web/activity_broadcaster.py +204 -0
- labctl/web/api.py +1003 -0
- labctl/web/app.py +202 -0
- labctl/web/auth.py +97 -0
- labctl/web/claims.py +232 -0
- labctl/web/static/css/style.css +477 -0
- labctl/web/static/js/app.js +76 -0
- labctl/web/templates/activity.html +202 -0
- labctl/web/templates/base.html +49 -0
- labctl/web/templates/claim_refused.html +38 -0
- labctl/web/templates/console.html +237 -0
- labctl/web/templates/dashboard.html +100 -0
- labctl/web/templates/login.html +25 -0
- labctl/web/templates/sbc_detail.html +306 -0
- labctl/web/templates/settings.html +78 -0
- labctl/web/templates/status_history.html +65 -0
- labctl/web/views.py +496 -0
- labctl/web/websocket.py +208 -0
labctl/services.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Status checks for systemd services labctl depends on.
|
|
3
|
+
|
|
4
|
+
Provides a single call that reports on all of labctl's own services
|
|
5
|
+
plus external dependencies (ser2net). Used by `labctl services status`
|
|
6
|
+
to give operators a quick at-a-glance health view without having to
|
|
7
|
+
run `systemctl` against each unit individually.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime, timedelta
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
# Units labctl depends on. First three are labctl's own; ser2net is
|
|
19
|
+
# the hardware-facing TCP-to-serial proxy used for every console.
|
|
20
|
+
DEFAULT_UNITS: tuple[str, ...] = (
|
|
21
|
+
"labctl-monitor",
|
|
22
|
+
"labctl-mcp",
|
|
23
|
+
"labctl-web",
|
|
24
|
+
"ser2net",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ServiceStatus:
|
|
30
|
+
"""One service's health snapshot."""
|
|
31
|
+
|
|
32
|
+
unit: str
|
|
33
|
+
active_state: str = "unknown" # active | inactive | failed | activating | ...
|
|
34
|
+
sub_state: str = "" # running | dead | exited | ...
|
|
35
|
+
active_since: Optional[datetime] = None
|
|
36
|
+
n_restarts: int = 0
|
|
37
|
+
exec_main_status: Optional[int] = None
|
|
38
|
+
result: str = "" # success | exit-code | signal | oom-kill | ...
|
|
39
|
+
recent_errors: list[str] = field(default_factory=list)
|
|
40
|
+
error: Optional[str] = None # set if systemctl itself failed
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def healthy(self) -> bool:
|
|
44
|
+
return self.active_state == "active" and self.sub_state in {"running", "exited"}
|
|
45
|
+
|
|
46
|
+
def uptime_str(self) -> str:
|
|
47
|
+
if not self.active_since:
|
|
48
|
+
return "-"
|
|
49
|
+
delta = datetime.now().astimezone() - self.active_since
|
|
50
|
+
return _format_duration(delta)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _format_duration(delta: timedelta) -> str:
|
|
54
|
+
total = int(delta.total_seconds())
|
|
55
|
+
if total < 0:
|
|
56
|
+
return "-"
|
|
57
|
+
days, rem = divmod(total, 86400)
|
|
58
|
+
hours, rem = divmod(rem, 3600)
|
|
59
|
+
mins, secs = divmod(rem, 60)
|
|
60
|
+
if days:
|
|
61
|
+
return f"{days}d{hours}h"
|
|
62
|
+
if hours:
|
|
63
|
+
return f"{hours}h{mins}m"
|
|
64
|
+
if mins:
|
|
65
|
+
return f"{mins}m{secs}s"
|
|
66
|
+
return f"{secs}s"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _run(cmd: list[str], timeout: float = 5.0) -> tuple[int, str]:
|
|
70
|
+
"""Run a subprocess and capture combined output."""
|
|
71
|
+
try:
|
|
72
|
+
result = subprocess.run(
|
|
73
|
+
cmd,
|
|
74
|
+
capture_output=True,
|
|
75
|
+
text=True,
|
|
76
|
+
timeout=timeout,
|
|
77
|
+
check=False,
|
|
78
|
+
)
|
|
79
|
+
return result.returncode, (result.stdout or "") + (result.stderr or "")
|
|
80
|
+
except FileNotFoundError as e:
|
|
81
|
+
return 127, str(e)
|
|
82
|
+
except subprocess.TimeoutExpired:
|
|
83
|
+
return 124, f"timeout after {timeout}s"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _parse_systemctl_show(output: str) -> dict[str, str]:
|
|
87
|
+
"""Parse `systemctl show -p Key1,Key2 unit` KEY=VALUE lines."""
|
|
88
|
+
props: dict[str, str] = {}
|
|
89
|
+
for line in output.splitlines():
|
|
90
|
+
if "=" in line:
|
|
91
|
+
k, v = line.split("=", 1)
|
|
92
|
+
props[k.strip()] = v.strip()
|
|
93
|
+
return props
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _parse_systemd_timestamp(value: str) -> Optional[datetime]:
|
|
97
|
+
"""Parse a systemd timestamp like 'Sun 2026-04-19 16:08:26 PDT'.
|
|
98
|
+
|
|
99
|
+
Returns None for empty / sentinel values ("n/a", "0", "").
|
|
100
|
+
"""
|
|
101
|
+
if not value or value in {"n/a", "0"}:
|
|
102
|
+
return None
|
|
103
|
+
# Format example: "Sun 2026-04-19 16:08:26 PDT"
|
|
104
|
+
for fmt in (
|
|
105
|
+
"%a %Y-%m-%d %H:%M:%S %Z",
|
|
106
|
+
"%Y-%m-%d %H:%M:%S %Z",
|
|
107
|
+
"%a %Y-%m-%d %H:%M:%S",
|
|
108
|
+
"%Y-%m-%d %H:%M:%S",
|
|
109
|
+
):
|
|
110
|
+
try:
|
|
111
|
+
dt = datetime.strptime(value, fmt)
|
|
112
|
+
return dt.astimezone()
|
|
113
|
+
except ValueError:
|
|
114
|
+
continue
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _collect_recent_errors(unit: str, hours: int = 24, limit: int = 3) -> list[str]:
|
|
119
|
+
"""Pull the last few distinct error/warning messages from the unit's journal.
|
|
120
|
+
|
|
121
|
+
Uses journalctl with priority <= err. Returns at most `limit` lines.
|
|
122
|
+
Silently returns [] if journalctl isn't available.
|
|
123
|
+
"""
|
|
124
|
+
if shutil.which("journalctl") is None:
|
|
125
|
+
return []
|
|
126
|
+
code, out = _run(
|
|
127
|
+
[
|
|
128
|
+
"journalctl",
|
|
129
|
+
"-u",
|
|
130
|
+
unit,
|
|
131
|
+
f"--since={hours} hours ago",
|
|
132
|
+
"--no-pager",
|
|
133
|
+
"-q",
|
|
134
|
+
"-p",
|
|
135
|
+
"err",
|
|
136
|
+
"-o",
|
|
137
|
+
"short-iso",
|
|
138
|
+
],
|
|
139
|
+
timeout=5.0,
|
|
140
|
+
)
|
|
141
|
+
if code != 0:
|
|
142
|
+
return []
|
|
143
|
+
lines = [ln.strip() for ln in out.splitlines() if ln.strip()]
|
|
144
|
+
# De-duplicate while preserving order, keep the last `limit` distinct.
|
|
145
|
+
seen: set[str] = set()
|
|
146
|
+
uniq: list[str] = []
|
|
147
|
+
for ln in reversed(lines):
|
|
148
|
+
# Strip leading timestamp + hostname + unit[pid]: to dedupe by message.
|
|
149
|
+
msg = ln.split(": ", 1)[-1] if ": " in ln else ln
|
|
150
|
+
if msg in seen:
|
|
151
|
+
continue
|
|
152
|
+
seen.add(msg)
|
|
153
|
+
uniq.append(ln)
|
|
154
|
+
if len(uniq) >= limit:
|
|
155
|
+
break
|
|
156
|
+
return list(reversed(uniq))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def check_service(unit: str) -> ServiceStatus:
|
|
160
|
+
"""Check one systemd unit and return a structured status."""
|
|
161
|
+
status = ServiceStatus(unit=unit)
|
|
162
|
+
|
|
163
|
+
if shutil.which("systemctl") is None:
|
|
164
|
+
status.error = "systemctl not found"
|
|
165
|
+
return status
|
|
166
|
+
|
|
167
|
+
code, out = _run(
|
|
168
|
+
[
|
|
169
|
+
"systemctl",
|
|
170
|
+
"show",
|
|
171
|
+
unit,
|
|
172
|
+
"-p",
|
|
173
|
+
"ActiveState,SubState,ActiveEnterTimestamp,NRestarts,"
|
|
174
|
+
"ExecMainStatus,Result,LoadState",
|
|
175
|
+
]
|
|
176
|
+
)
|
|
177
|
+
if code != 0:
|
|
178
|
+
status.error = out.strip() or f"systemctl show exited {code}"
|
|
179
|
+
return status
|
|
180
|
+
|
|
181
|
+
props = _parse_systemctl_show(out)
|
|
182
|
+
if props.get("LoadState") in {"not-found", "masked"}:
|
|
183
|
+
status.error = f"unit not loaded (LoadState={props.get('LoadState')})"
|
|
184
|
+
status.active_state = "not-found"
|
|
185
|
+
return status
|
|
186
|
+
|
|
187
|
+
status.active_state = props.get("ActiveState", "unknown")
|
|
188
|
+
status.sub_state = props.get("SubState", "")
|
|
189
|
+
status.active_since = _parse_systemd_timestamp(
|
|
190
|
+
props.get("ActiveEnterTimestamp", "")
|
|
191
|
+
)
|
|
192
|
+
try:
|
|
193
|
+
status.n_restarts = int(props.get("NRestarts", "0"))
|
|
194
|
+
except ValueError:
|
|
195
|
+
status.n_restarts = 0
|
|
196
|
+
try:
|
|
197
|
+
status.exec_main_status = int(props.get("ExecMainStatus", "0"))
|
|
198
|
+
except ValueError:
|
|
199
|
+
status.exec_main_status = None
|
|
200
|
+
status.result = props.get("Result", "")
|
|
201
|
+
|
|
202
|
+
if not status.healthy or status.n_restarts > 0:
|
|
203
|
+
status.recent_errors = _collect_recent_errors(unit)
|
|
204
|
+
|
|
205
|
+
return status
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def check_all(units: tuple[str, ...] = DEFAULT_UNITS) -> list[ServiceStatus]:
|
|
209
|
+
"""Check all services. Returned in the order given."""
|
|
210
|
+
return [check_service(u) for u in units]
|
labctl/web/__init__.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Activity-stream broadcaster for the web server.
|
|
3
|
+
|
|
4
|
+
A single background thread polls `audit_log` for new rows and fans
|
|
5
|
+
them out to per-subscriber queues. SSE endpoints iterate their queue
|
|
6
|
+
to stream events to clients.
|
|
7
|
+
|
|
8
|
+
Polling the DB (rather than hooking `audit.emit()` directly) means
|
|
9
|
+
events from CLI, MCP, and the monitor daemon — which run in separate
|
|
10
|
+
processes — all reach the browser.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import queue
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Iterator, Optional
|
|
22
|
+
|
|
23
|
+
from labctl.core.database import Database
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
POLL_INTERVAL_SECONDS = 0.5
|
|
28
|
+
BATCH_LIMIT = 500
|
|
29
|
+
SUBSCRIBER_QUEUE_MAXSIZE = 1000
|
|
30
|
+
INITIAL_BACKFILL = 200 # Hydration events a new subscriber receives immediately.
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ActivityEvent:
|
|
35
|
+
"""One event pushed to subscribers."""
|
|
36
|
+
|
|
37
|
+
id: int
|
|
38
|
+
logged_at: str
|
|
39
|
+
actor: str
|
|
40
|
+
source: str
|
|
41
|
+
action: str
|
|
42
|
+
entity_type: str
|
|
43
|
+
entity_id: Optional[int]
|
|
44
|
+
entity_name: Optional[str]
|
|
45
|
+
result: str
|
|
46
|
+
details: Optional[str] # JSON string as stored
|
|
47
|
+
claim_id: Optional[int]
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> dict:
|
|
50
|
+
return {
|
|
51
|
+
"id": self.id,
|
|
52
|
+
"logged_at": self.logged_at,
|
|
53
|
+
"actor": self.actor,
|
|
54
|
+
"source": self.source,
|
|
55
|
+
"action": self.action,
|
|
56
|
+
"entity_type": self.entity_type,
|
|
57
|
+
"entity_id": self.entity_id,
|
|
58
|
+
"entity_name": self.entity_name,
|
|
59
|
+
"result": self.result,
|
|
60
|
+
"details": self.details,
|
|
61
|
+
"claim_id": self.claim_id,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
def to_sse(self) -> str:
|
|
65
|
+
"""Render as a single SSE frame."""
|
|
66
|
+
payload = json.dumps(self.to_dict(), separators=(",", ":"))
|
|
67
|
+
return f"id: {self.id}\nevent: activity\ndata: {payload}\n\n"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _row_to_event(row) -> ActivityEvent:
|
|
71
|
+
return ActivityEvent(
|
|
72
|
+
id=row["id"],
|
|
73
|
+
logged_at=row["logged_at"] or "",
|
|
74
|
+
actor=row["actor"] or "internal",
|
|
75
|
+
source=row["source"] or "internal",
|
|
76
|
+
action=row["action"] or "",
|
|
77
|
+
entity_type=row["entity_type"] or "",
|
|
78
|
+
entity_id=row["entity_id"],
|
|
79
|
+
entity_name=row["entity_name"],
|
|
80
|
+
result=row["result"] or "ok",
|
|
81
|
+
details=row["details"],
|
|
82
|
+
claim_id=row["claim_id"],
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ActivityBroadcaster:
|
|
87
|
+
"""Singleton broadcaster: polls the audit_log and feeds subscriber queues."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, db: Database, poll_interval: float = POLL_INTERVAL_SECONDS):
|
|
90
|
+
self.db = db
|
|
91
|
+
self.poll_interval = poll_interval
|
|
92
|
+
self._last_id: int = 0
|
|
93
|
+
self._subscribers: set[queue.Queue] = set()
|
|
94
|
+
self._subscribers_lock = threading.Lock()
|
|
95
|
+
self._stop_event = threading.Event()
|
|
96
|
+
self._thread: Optional[threading.Thread] = None
|
|
97
|
+
|
|
98
|
+
def start(self) -> None:
|
|
99
|
+
"""Start the background poll thread. Safe to call multiple times."""
|
|
100
|
+
if self._thread and self._thread.is_alive():
|
|
101
|
+
return
|
|
102
|
+
# Seed the cursor with the current max id so we don't replay history.
|
|
103
|
+
row = self.db.execute_one("SELECT COALESCE(MAX(id), 0) AS m FROM audit_log")
|
|
104
|
+
self._last_id = row["m"] if row else 0
|
|
105
|
+
self._stop_event.clear()
|
|
106
|
+
self._thread = threading.Thread(
|
|
107
|
+
target=self._run, name="activity-broadcaster", daemon=True
|
|
108
|
+
)
|
|
109
|
+
self._thread.start()
|
|
110
|
+
logger.info(
|
|
111
|
+
"ActivityBroadcaster started (last_id=%d, interval=%.2fs)",
|
|
112
|
+
self._last_id,
|
|
113
|
+
self.poll_interval,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def stop(self) -> None:
|
|
117
|
+
"""Signal the poll thread to stop. Does not join."""
|
|
118
|
+
self._stop_event.set()
|
|
119
|
+
|
|
120
|
+
def subscribe(self) -> queue.Queue:
|
|
121
|
+
"""Register a new subscriber queue. Call unsubscribe() when done."""
|
|
122
|
+
q: queue.Queue = queue.Queue(maxsize=SUBSCRIBER_QUEUE_MAXSIZE)
|
|
123
|
+
with self._subscribers_lock:
|
|
124
|
+
self._subscribers.add(q)
|
|
125
|
+
return q
|
|
126
|
+
|
|
127
|
+
def unsubscribe(self, q: queue.Queue) -> None:
|
|
128
|
+
with self._subscribers_lock:
|
|
129
|
+
self._subscribers.discard(q)
|
|
130
|
+
|
|
131
|
+
def recent(self, limit: int = INITIAL_BACKFILL) -> list[ActivityEvent]:
|
|
132
|
+
"""Return the most recent N events (oldest-first for replay)."""
|
|
133
|
+
rows = self.db.execute(
|
|
134
|
+
"SELECT * FROM audit_log ORDER BY id DESC LIMIT ?",
|
|
135
|
+
(int(limit),),
|
|
136
|
+
)
|
|
137
|
+
return [_row_to_event(r) for r in reversed(rows)]
|
|
138
|
+
|
|
139
|
+
def _run(self) -> None:
|
|
140
|
+
while not self._stop_event.is_set():
|
|
141
|
+
try:
|
|
142
|
+
self._poll_once()
|
|
143
|
+
except Exception as e: # noqa: BLE001
|
|
144
|
+
logger.warning("ActivityBroadcaster poll failed: %s", e)
|
|
145
|
+
self._stop_event.wait(self.poll_interval)
|
|
146
|
+
|
|
147
|
+
def _poll_once(self) -> None:
|
|
148
|
+
rows = self.db.execute(
|
|
149
|
+
"SELECT * FROM audit_log WHERE id > ? ORDER BY id ASC LIMIT ?",
|
|
150
|
+
(self._last_id, BATCH_LIMIT),
|
|
151
|
+
)
|
|
152
|
+
if not rows:
|
|
153
|
+
return
|
|
154
|
+
events = [_row_to_event(r) for r in rows]
|
|
155
|
+
self._last_id = events[-1].id
|
|
156
|
+
self._fanout(events)
|
|
157
|
+
|
|
158
|
+
def _fanout(self, events: list[ActivityEvent]) -> None:
|
|
159
|
+
"""Push events to every subscriber queue. Drop slow consumers."""
|
|
160
|
+
with self._subscribers_lock:
|
|
161
|
+
subscribers = list(self._subscribers)
|
|
162
|
+
for q in subscribers:
|
|
163
|
+
for event in events:
|
|
164
|
+
try:
|
|
165
|
+
q.put_nowait(event)
|
|
166
|
+
except queue.Full:
|
|
167
|
+
# Slow consumer: drop all events to catch up rather than
|
|
168
|
+
# block others. The client will still have its initial
|
|
169
|
+
# backfill + whatever lands next.
|
|
170
|
+
try:
|
|
171
|
+
while True:
|
|
172
|
+
q.get_nowait()
|
|
173
|
+
except queue.Empty:
|
|
174
|
+
pass
|
|
175
|
+
logger.info("ActivityBroadcaster: dropped slow subscriber queue")
|
|
176
|
+
break # don't keep adding to a drained queue this tick
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def iter_sse_frames(
|
|
180
|
+
broadcaster: ActivityBroadcaster,
|
|
181
|
+
backfill: int = INITIAL_BACKFILL,
|
|
182
|
+
heartbeat_seconds: float = 15.0,
|
|
183
|
+
) -> Iterator[str]:
|
|
184
|
+
"""Yield SSE frames: first a hydration batch, then live events.
|
|
185
|
+
|
|
186
|
+
Sends a comment-only heartbeat line every `heartbeat_seconds` so
|
|
187
|
+
intermediaries (nginx, browsers) don't drop an idle connection.
|
|
188
|
+
"""
|
|
189
|
+
q = broadcaster.subscribe()
|
|
190
|
+
try:
|
|
191
|
+
yield "retry: 2000\n\n"
|
|
192
|
+
for event in broadcaster.recent(backfill):
|
|
193
|
+
yield event.to_sse()
|
|
194
|
+
last_beat = time.monotonic()
|
|
195
|
+
while True:
|
|
196
|
+
timeout = max(0.0, heartbeat_seconds - (time.monotonic() - last_beat))
|
|
197
|
+
try:
|
|
198
|
+
event = q.get(timeout=timeout or 0.1)
|
|
199
|
+
yield event.to_sse()
|
|
200
|
+
except queue.Empty:
|
|
201
|
+
yield ": keepalive\n\n"
|
|
202
|
+
last_beat = time.monotonic()
|
|
203
|
+
finally:
|
|
204
|
+
broadcaster.unsubscribe(q)
|