somestuff-mqttwebstuff 0.1.4__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.
- somestuff/mqttwebstuff/__init__.py +123 -0
- somestuff/mqttwebstuff/hub.py +343 -0
- somestuff/mqttwebstuff/plugin_api.py +292 -0
- somestuff/mqttwebstuff/plugins/oepnv_view.py +138 -0
- somestuff/mqttwebstuff/plugins/templates/extra_head.html.j2 +32 -0
- somestuff/mqttwebstuff/plugins/templates/oepnv_alert.html.j2 +5 -0
- somestuff/mqttwebstuff/plugins/templates/oepnv_departures.html.j2 +21 -0
- somestuff/mqttwebstuff/plugins/templates/oepnv_line.html.j2 +14 -0
- somestuff/mqttwebstuff/plugins/templates/oepnv_stale.html.j2 +7 -0
- somestuff/mqttwebstuff/plugins/templates/oepnv_status.html.j2 +18 -0
- somestuff/mqttwebstuff/py.typed +0 -0
- somestuff/mqttwebstuff/serve.py +258 -0
- somestuff/mqttwebstuff/static/htmx-ext-sse.js +290 -0
- somestuff/mqttwebstuff/static/htmx.min.js +1 -0
- somestuff/mqttwebstuff/static/pico.min.css +4 -0
- somestuff/mqttwebstuff/templates/base.html.j2 +134 -0
- somestuff/mqttwebstuff/templates/generic_item.html.j2 +9 -0
- somestuff/mqttwebstuff/templates/generic_tree_item.html.j2 +20 -0
- somestuff/mqttwebstuff/webapp.py +167 -0
- somestuff_mqttwebstuff-0.1.4.dist-info/METADATA +230 -0
- somestuff_mqttwebstuff-0.1.4.dist-info/RECORD +24 -0
- somestuff_mqttwebstuff-0.1.4.dist-info/WHEEL +4 -0
- somestuff_mqttwebstuff-0.1.4.dist-info/entry_points.txt +2 -0
- somestuff_mqttwebstuff-0.1.4.dist-info/licenses/LICENSE.md +163 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""mqttwebstuff — live web view onto arbitrary MQTT streams.
|
|
2
|
+
|
|
3
|
+
A small FastAPI app subscribes to an MQTT broker, runs every message through a
|
|
4
|
+
*mapper plugin* (a plain Python file mounted into the container) and pushes the
|
|
5
|
+
rendered result to all connected browsers via Server-Sent Events. The browser
|
|
6
|
+
side is htmx + its SSE extension — no frontend build or framework.
|
|
7
|
+
|
|
8
|
+
The mapper plugin decides *what* is shown (filter), *where* (panel/key) and
|
|
9
|
+
*how* (Jinja2 template name); see :mod:`somestuff.mqttwebstuff.plugin_api` for the
|
|
10
|
+
contract and ``plugins/oepnv_view.py`` for the reference plugin rendering the
|
|
11
|
+
:mod:`oepnvstuff` departure board.
|
|
12
|
+
|
|
13
|
+
Central logging setup lives here (loguru), mirroring oepnvstuff's setup. The
|
|
14
|
+
modules in this package (and third-party libraries such as ``paho-mqtt`` and
|
|
15
|
+
``uvicorn``) log via the stdlib ``logging`` module, so
|
|
16
|
+
:func:`configure_logging` installs an intercept handler that funnels those
|
|
17
|
+
records into loguru instead of rewriting every call site.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import inspect
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
from types import FrameType
|
|
26
|
+
from typing import TYPE_CHECKING
|
|
27
|
+
|
|
28
|
+
from loguru import logger as glogger
|
|
29
|
+
from tabulate import tabulate
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from loguru import Record
|
|
33
|
+
|
|
34
|
+
__version__ = "0.1.4"
|
|
35
|
+
|
|
36
|
+
__all__ = ["__version__", "configure_logging", "print_banner"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _loguru_skiplog_filter(record: "Record") -> bool:
|
|
40
|
+
"""Decide whether a loguru record should be emitted.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
record: The loguru record about to be handled by a sink.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
``False`` for records whose ``extra['skiplog']`` flag is truthy (they are
|
|
47
|
+
hidden), ``True`` otherwise.
|
|
48
|
+
"""
|
|
49
|
+
return not record.get("extra", {}).get("skiplog", False)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _InterceptHandler(logging.Handler):
|
|
53
|
+
"""Route stdlib ``logging`` records into loguru with correct call-site info.
|
|
54
|
+
|
|
55
|
+
Every module in this package uses ``logging.getLogger(...)``; installing this
|
|
56
|
+
on the root logger lets :func:`configure_logging` govern all of them (format,
|
|
57
|
+
level, colour) without touching the individual call sites.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
61
|
+
"""Forward a single stdlib record to loguru.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
record: The stdlib log record to re-emit through loguru, preserving
|
|
65
|
+
its level, message, exception info and originating call site.
|
|
66
|
+
"""
|
|
67
|
+
# Map the stdlib level name to a loguru level, falling back to the number.
|
|
68
|
+
level: str | int
|
|
69
|
+
try:
|
|
70
|
+
level = glogger.level(record.levelname).name
|
|
71
|
+
except ValueError:
|
|
72
|
+
level = record.levelno
|
|
73
|
+
# Walk out of the logging module so {module}/{function}/{line} point at the
|
|
74
|
+
# real caller rather than at this handler (canonical loguru intercept recipe).
|
|
75
|
+
frame: FrameType | None = inspect.currentframe()
|
|
76
|
+
depth = 0
|
|
77
|
+
while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__):
|
|
78
|
+
frame = frame.f_back
|
|
79
|
+
depth += 1
|
|
80
|
+
glogger.bind(classname=record.name).opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def configure_logging(
|
|
84
|
+
verbose: bool = False, loguru_filter: Callable[["Record"], bool] = _loguru_skiplog_filter
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Configure a default ``loguru`` sink and funnel stdlib logging into it.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
verbose: ``True`` logs at DEBUG, otherwise honours ``LOGURU_LEVEL`` (INFO
|
|
90
|
+
by default).
|
|
91
|
+
loguru_filter: Predicate deciding, per loguru record, whether it is emitted
|
|
92
|
+
by the sink. Defaults to :func:`_loguru_skiplog_filter`.
|
|
93
|
+
"""
|
|
94
|
+
level_name: str = "DEBUG" if verbose else os.getenv("LOGURU_LEVEL", "INFO")
|
|
95
|
+
os.environ["LOGURU_LEVEL"] = level_name
|
|
96
|
+
glogger.remove()
|
|
97
|
+
logger_fmt: str = (
|
|
98
|
+
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{module}</cyan>::<cyan>{extra[classname]}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>"
|
|
99
|
+
)
|
|
100
|
+
glogger.add(sys.stderr, level=level_name, format=logger_fmt, filter=loguru_filter) # type: ignore[arg-type]
|
|
101
|
+
glogger.configure(extra={"classname": "None", "skiplog": False})
|
|
102
|
+
# Replace any stdlib handlers with the intercept so all ``logging`` output
|
|
103
|
+
# (this package's and third-party libraries') flows through loguru.
|
|
104
|
+
logging.basicConfig(handlers=[_InterceptHandler()], level=logging.getLevelNamesMapping()[level_name], force=True)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def print_banner() -> None:
|
|
108
|
+
"""Log the startup banner with version, build time and project links."""
|
|
109
|
+
startup_rows = [
|
|
110
|
+
["version", __version__],
|
|
111
|
+
["buildtime", os.getenv("BUILDTIME", "unknown")],
|
|
112
|
+
["github", "https://github.com/vroomfondel/somestuff/tree/main/somestuff/mqttwebstuff"],
|
|
113
|
+
["Docker Hub", "https://hub.docker.com/r/xomoxcc/somestuff"],
|
|
114
|
+
]
|
|
115
|
+
table_str = tabulate(startup_rows, tablefmt="mixed_grid")
|
|
116
|
+
lines = table_str.split("\n")
|
|
117
|
+
table_width = len(lines[0])
|
|
118
|
+
title = "mqttweb live stream viewer starting up"
|
|
119
|
+
title_border = "┍" + "━" * (table_width - 2) + "┑"
|
|
120
|
+
title_row = "│ " + title.center(table_width - 4) + " │"
|
|
121
|
+
separator = lines[0].replace("┍", "┝").replace("┑", "┥").replace("┯", "┿")
|
|
122
|
+
|
|
123
|
+
glogger.opt(raw=True).info(f"\n{title_border}\n{title_row}\n{separator}\n{'\n'.join(lines[1:])}\n")
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""The view hub: last-value cache + SSE fan-out between MQTT and the browsers.
|
|
2
|
+
|
|
3
|
+
The hub is the piece that turns a *non-retained* live stream into something a
|
|
4
|
+
freshly opened browser tab can show immediately: every mapped message is
|
|
5
|
+
rendered once and kept in an in-memory cache per ``(panel, key)``, so the
|
|
6
|
+
initial page load renders the full current board server-side and the SSE
|
|
7
|
+
stream afterwards only carries deltas.
|
|
8
|
+
|
|
9
|
+
Threading model: paho-mqtt delivers messages on its own network thread;
|
|
10
|
+
:meth:`ViewHub.submit` is the only thread-safe entry point and trampolines
|
|
11
|
+
into the asyncio event loop (``call_soon_threadsafe``). Everything else —
|
|
12
|
+
ingest, rendering, cache, subscriber queues — runs single-threaded on the
|
|
13
|
+
event loop, so no locks are needed.
|
|
14
|
+
|
|
15
|
+
Update granularity: one SSE event per *panel* (the panel's full body is
|
|
16
|
+
re-rendered from cache and swapped via htmx ``sse-swap``). At the message
|
|
17
|
+
rates of a poll-based stream this is trivially cheap and makes ordering,
|
|
18
|
+
replacement and expiry correct by construction. Only when a message creates a
|
|
19
|
+
panel the initial page did not know about is the whole board re-sent once
|
|
20
|
+
(event ``board``) so the new section materializes without a reload.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import html
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import re
|
|
28
|
+
import time
|
|
29
|
+
import unicodedata
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
import jinja2
|
|
34
|
+
|
|
35
|
+
from somestuff.mqttwebstuff.plugin_api import GENERIC_TEMPLATE, LoadedPlugin, ViewEvent
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
# German umlauts/eszett → ASCII digraphs, applied before the generic
|
|
40
|
+
# accent-stripping so "Straße" slugs to "strasse", not "strae".
|
|
41
|
+
_UMLAUTS = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue", "ß": "ss"})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def anchor_slug(text: str) -> str:
|
|
45
|
+
"""Turn a label/topic into a stable, URL-fragment-safe anchor id part.
|
|
46
|
+
|
|
47
|
+
Deterministic, so deep links (``/#g-departures-ellerbek-moordamm``) stay
|
|
48
|
+
valid across repaints and restarts as long as the label itself is stable.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
text: Display label or topic path.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Lowercased ASCII slug (never empty).
|
|
55
|
+
"""
|
|
56
|
+
text = unicodedata.normalize("NFKD", text.translate(_UMLAUTS)).encode("ascii", "ignore").decode()
|
|
57
|
+
return re.sub(r"[^a-zA-Z0-9]+", "-", text).strip("-").lower() or "x"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Per-subscriber queue depth; a browser that cannot drain this many panel
|
|
61
|
+
# updates gets the oldest ones dropped (the next update repaints the panel
|
|
62
|
+
# fully anyway, so drops only cost intermediate states).
|
|
63
|
+
_QUEUE_MAXSIZE = 256
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(slots=True)
|
|
67
|
+
class _Entry:
|
|
68
|
+
"""One cached board item: pre-rendered HTML plus grouping/ordering/expiry."""
|
|
69
|
+
|
|
70
|
+
html: str
|
|
71
|
+
sort: str
|
|
72
|
+
group: str
|
|
73
|
+
expires: float | None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def decode_payload(raw: str | bytes) -> Any:
|
|
77
|
+
"""Decode a raw MQTT payload: JSON when it parses, the string otherwise.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
raw: The payload as delivered by the broker.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
``dict``/``list``/scalar for valid JSON, otherwise the decoded string.
|
|
84
|
+
"""
|
|
85
|
+
text = raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else raw
|
|
86
|
+
stripped = text.strip()
|
|
87
|
+
if stripped.startswith(("{", "[")):
|
|
88
|
+
try:
|
|
89
|
+
return json.loads(stripped)
|
|
90
|
+
except ValueError:
|
|
91
|
+
pass
|
|
92
|
+
return text
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ViewHub:
|
|
96
|
+
"""Cache + broadcast hub; single-threaded on the asyncio event loop.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
env: Jinja2 environment used to render item templates.
|
|
100
|
+
plugin: The mapper plugin deciding filter/panel/template per message.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(self, env: jinja2.Environment, plugin: LoadedPlugin) -> None:
|
|
104
|
+
self._env = env
|
|
105
|
+
self._plugin = plugin
|
|
106
|
+
# Declared panels exist from the start (stable board layout even while
|
|
107
|
+
# empty); undeclared ones are appended when their first item arrives.
|
|
108
|
+
self._panels: dict[str, dict[str, _Entry]] = {name: {} for name in plugin.panels}
|
|
109
|
+
self._subscribers: set[asyncio.Queue[tuple[str, str]]] = set()
|
|
110
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def plugin(self) -> LoadedPlugin:
|
|
114
|
+
"""The mapper plugin this hub runs."""
|
|
115
|
+
return self._plugin
|
|
116
|
+
|
|
117
|
+
def attach_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
118
|
+
"""Bind the hub to the running event loop (called from app startup).
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
loop: The loop all ingest/broadcast work must run on.
|
|
122
|
+
"""
|
|
123
|
+
self._loop = loop
|
|
124
|
+
|
|
125
|
+
# ── thread-safe entry point (paho network thread) ───────────────────────
|
|
126
|
+
|
|
127
|
+
def submit(self, topic: str, raw: str | bytes) -> None:
|
|
128
|
+
"""Hand one MQTT message over to the event loop; safe from any thread.
|
|
129
|
+
|
|
130
|
+
Messages arriving before the loop is attached (or after shutdown) are
|
|
131
|
+
dropped — with a non-retained live stream the next cycle re-delivers
|
|
132
|
+
the current state anyway.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
topic: Full MQTT topic of the message.
|
|
136
|
+
raw: Raw payload.
|
|
137
|
+
"""
|
|
138
|
+
loop = self._loop
|
|
139
|
+
if loop is None or loop.is_closed():
|
|
140
|
+
return
|
|
141
|
+
payload = decode_payload(raw)
|
|
142
|
+
try:
|
|
143
|
+
loop.call_soon_threadsafe(self.ingest, topic, payload)
|
|
144
|
+
except RuntimeError:
|
|
145
|
+
# Loop shut down between the check and the call — shutdown race, drop.
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
# ── event-loop side ─────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
def ingest(self, topic: str, payload: Any) -> None:
|
|
151
|
+
"""Map, render and cache one message, then broadcast the delta.
|
|
152
|
+
|
|
153
|
+
A mapper may return one :class:`ViewEvent` or a sequence of them (e.g.
|
|
154
|
+
a leaf plus synthesized ancestor rows for a tree view); affected panels
|
|
155
|
+
are each repainted once. A crashing mapper or template must never take
|
|
156
|
+
the stream down: errors are logged and the message is dropped.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
topic: Full MQTT topic of the message.
|
|
160
|
+
payload: Decoded payload (see :func:`decode_payload`).
|
|
161
|
+
"""
|
|
162
|
+
try:
|
|
163
|
+
result = self._plugin.map_message(topic, payload)
|
|
164
|
+
except Exception:
|
|
165
|
+
logger.exception(f"mapper failed for topic {topic}")
|
|
166
|
+
return
|
|
167
|
+
if result is None:
|
|
168
|
+
return
|
|
169
|
+
events = [result] if isinstance(result, ViewEvent) else list(result)
|
|
170
|
+
if not events:
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
new_panel = False
|
|
174
|
+
touched: set[str] = set()
|
|
175
|
+
for event in events:
|
|
176
|
+
try:
|
|
177
|
+
rendered = self._render_item(event, topic)
|
|
178
|
+
except Exception:
|
|
179
|
+
logger.exception(f"template {event.template or GENERIC_TEMPLATE} failed for topic {topic}")
|
|
180
|
+
continue
|
|
181
|
+
new_panel = new_panel or event.panel not in self._panels
|
|
182
|
+
panel = self._panels.setdefault(event.panel, {})
|
|
183
|
+
panel[event.key] = _Entry(
|
|
184
|
+
html=rendered,
|
|
185
|
+
sort=event.sort or event.key,
|
|
186
|
+
group=event.group,
|
|
187
|
+
expires=(time.monotonic() + event.ttl) if event.ttl is not None else None,
|
|
188
|
+
)
|
|
189
|
+
touched.add(event.panel)
|
|
190
|
+
|
|
191
|
+
if new_panel:
|
|
192
|
+
self._broadcast("board", self.render_board())
|
|
193
|
+
else:
|
|
194
|
+
for name in touched:
|
|
195
|
+
self._broadcast(f"panel:{name}", self.render_panel_body(name))
|
|
196
|
+
|
|
197
|
+
def _render_item(self, event: ViewEvent, topic: str) -> str:
|
|
198
|
+
"""Render one item to its HTML fragment.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
event: The mapped view event.
|
|
202
|
+
topic: Originating topic, exposed to the template as ``topic``.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
The rendered fragment.
|
|
206
|
+
"""
|
|
207
|
+
template = self._env.get_template(event.template or GENERIC_TEMPLATE)
|
|
208
|
+
return template.render(data=event.data, topic=topic, key=event.key, panel=event.panel, title=event.title)
|
|
209
|
+
|
|
210
|
+
def render_panel_body(self, name: str) -> str:
|
|
211
|
+
"""Render a panel's body: items in sort order, or a placeholder.
|
|
212
|
+
|
|
213
|
+
Items carrying a ``group`` label are clustered under a sub-heading per
|
|
214
|
+
label (labels sorted alphabetically, items within a group by their
|
|
215
|
+
``sort`` key); ungrouped items render first.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
name: Panel name.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
HTML for the panel's inner container.
|
|
222
|
+
"""
|
|
223
|
+
entries = self._panels.get(name, {})
|
|
224
|
+
if not entries:
|
|
225
|
+
return '<p class="mqttweb-empty">– keine Daten –</p>'
|
|
226
|
+
|
|
227
|
+
ungrouped = sorted((e for e in entries.values() if not e.group), key=lambda e: e.sort)
|
|
228
|
+
groups: dict[str, list[_Entry]] = {}
|
|
229
|
+
for entry in entries.values():
|
|
230
|
+
if entry.group:
|
|
231
|
+
groups.setdefault(entry.group, []).append(entry)
|
|
232
|
+
|
|
233
|
+
parts: list[str] = [e.html for e in ungrouped]
|
|
234
|
+
for label in sorted(groups):
|
|
235
|
+
items = "\n".join(e.html for e in sorted(groups[label], key=lambda e: e.sort))
|
|
236
|
+
# Stable anchor id so each group (e.g. one stop) is deep-linkable
|
|
237
|
+
# via /#g-<panel>-<label>; the heading doubles as the self-link.
|
|
238
|
+
anchor = f"g-{anchor_slug(name)}-{anchor_slug(label)}"
|
|
239
|
+
parts.append(
|
|
240
|
+
f'<div class="mqttweb-group-box" id="{anchor}">'
|
|
241
|
+
f'<h3 class="mqttweb-group"><a class="mqttweb-anchor" href="#{anchor}">{html.escape(label)}</a></h3>'
|
|
242
|
+
f"\n{items}</div>"
|
|
243
|
+
)
|
|
244
|
+
return "\n".join(parts)
|
|
245
|
+
|
|
246
|
+
def render_board(self) -> str:
|
|
247
|
+
"""Render the whole board (all panels) from cache.
|
|
248
|
+
|
|
249
|
+
Declared panels keep their declared order; panels created at runtime
|
|
250
|
+
follow alphabetically.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
HTML for the board container.
|
|
254
|
+
"""
|
|
255
|
+
declared = [name for name in self._plugin.panels if name in self._panels]
|
|
256
|
+
extra = sorted(name for name in self._panels if name not in self._plugin.panels)
|
|
257
|
+
sections: list[str] = []
|
|
258
|
+
for name in declared + extra:
|
|
259
|
+
# An empty declared heading renders the panel "plain": no title and
|
|
260
|
+
# no panel chrome — its items (e.g. group boxes) stand on their own.
|
|
261
|
+
heading = self._plugin.panels.get(name, name)
|
|
262
|
+
css = "mqttweb-panel" if heading else "mqttweb-panel mqttweb-panel-plain"
|
|
263
|
+
title_html = f"<h2>{html.escape(heading)}</h2>" if heading else ""
|
|
264
|
+
sections.append(
|
|
265
|
+
f'<section class="{css}" id="panel-{html.escape(name, quote=True)}">{title_html}'
|
|
266
|
+
f'<div class="mqttweb-panel-body" sse-swap="panel:{html.escape(name, quote=True)}">'
|
|
267
|
+
f"{self.render_panel_body(name)}</div></section>"
|
|
268
|
+
)
|
|
269
|
+
return "\n".join(sections)
|
|
270
|
+
|
|
271
|
+
# ── expiry ──────────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
def sweep_once(self, now: float | None = None) -> list[str]:
|
|
274
|
+
"""Drop expired items and broadcast every panel that changed.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
now: Reference monotonic time (defaults to ``time.monotonic()``;
|
|
278
|
+
injectable for tests).
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
Names of the panels that lost at least one item.
|
|
282
|
+
"""
|
|
283
|
+
now = time.monotonic() if now is None else now
|
|
284
|
+
changed: list[str] = []
|
|
285
|
+
for name, entries in self._panels.items():
|
|
286
|
+
expired = [key for key, entry in entries.items() if entry.expires is not None and entry.expires <= now]
|
|
287
|
+
if not expired:
|
|
288
|
+
continue
|
|
289
|
+
for key in expired:
|
|
290
|
+
del entries[key]
|
|
291
|
+
changed.append(name)
|
|
292
|
+
self._broadcast(f"panel:{name}", self.render_panel_body(name))
|
|
293
|
+
return changed
|
|
294
|
+
|
|
295
|
+
async def sweeper(self, interval: float = 5.0) -> None:
|
|
296
|
+
"""Run :meth:`sweep_once` forever; cancelled on app shutdown.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
interval: Seconds between sweeps.
|
|
300
|
+
"""
|
|
301
|
+
while True:
|
|
302
|
+
await asyncio.sleep(interval)
|
|
303
|
+
self.sweep_once()
|
|
304
|
+
|
|
305
|
+
# ── SSE subscribers ─────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
def subscribe(self) -> "asyncio.Queue[tuple[str, str]]":
|
|
308
|
+
"""Register a new SSE subscriber.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
The subscriber's ``(event_name, html)`` queue.
|
|
312
|
+
"""
|
|
313
|
+
queue: asyncio.Queue[tuple[str, str]] = asyncio.Queue(maxsize=_QUEUE_MAXSIZE)
|
|
314
|
+
self._subscribers.add(queue)
|
|
315
|
+
return queue
|
|
316
|
+
|
|
317
|
+
def unsubscribe(self, queue: "asyncio.Queue[tuple[str, str]]") -> None:
|
|
318
|
+
"""Remove an SSE subscriber.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
queue: The queue previously returned by :meth:`subscribe`.
|
|
322
|
+
"""
|
|
323
|
+
self._subscribers.discard(queue)
|
|
324
|
+
|
|
325
|
+
def _broadcast(self, event: str, fragment: str) -> None:
|
|
326
|
+
"""Fan one event out to all subscribers, dropping oldest on backlog.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
event: SSE event name (``panel:<name>`` or ``board``).
|
|
330
|
+
fragment: The rendered HTML payload.
|
|
331
|
+
"""
|
|
332
|
+
for queue in self._subscribers:
|
|
333
|
+
try:
|
|
334
|
+
queue.put_nowait((event, fragment))
|
|
335
|
+
except asyncio.QueueFull:
|
|
336
|
+
try:
|
|
337
|
+
queue.get_nowait()
|
|
338
|
+
except asyncio.QueueEmpty:
|
|
339
|
+
pass
|
|
340
|
+
try:
|
|
341
|
+
queue.put_nowait((event, fragment))
|
|
342
|
+
except asyncio.QueueFull:
|
|
343
|
+
pass
|