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.
@@ -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