led-ticker-pool 0.1.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.
@@ -0,0 +1,11 @@
1
+ """led-ticker-pool: a pool water-temperature monitor widget for led-ticker.
2
+
3
+ Contributed via the ``led_ticker.plugins`` entry point. The entry-point name
4
+ ``pool`` is the plugin namespace, so the widget is ``type = "pool.monitor"``.
5
+ """
6
+
7
+ from led_ticker_pool.monitor import PoolMonitor
8
+
9
+
10
+ def register(api):
11
+ api.widget("monitor")(PoolMonitor)
@@ -0,0 +1,609 @@
1
+ """Pool water-temperature widget backed by the pool_monitor InfluxDB v2 server."""
2
+
3
+ import csv
4
+ import difflib
5
+ import io
6
+ import logging
7
+ import os
8
+ import re
9
+ from datetime import UTC, datetime
10
+ from typing import Any, Self
11
+
12
+ import aiohttp
13
+ import attrs
14
+ from led_ticker.plugin import (
15
+ Color,
16
+ ColorProviderBase,
17
+ Font,
18
+ SegmentMessage,
19
+ TwoRowMessage,
20
+ colors,
21
+ make_color,
22
+ resolve_font,
23
+ run_monitor_loop,
24
+ spawn_tracked,
25
+ )
26
+
27
+ # Deadband (in the display unit) below which a change reads as "steady" —
28
+ # avoids flicker on sub-degree sensor noise.
29
+ _TREND_DEADBAND: float = 0.5
30
+
31
+ _SENSOR_ID_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9_-]+$")
32
+
33
+ # Supported layouts and the per-row font knobs that only apply to "two_row".
34
+ _VALID_LAYOUTS: tuple[str, ...] = ("ticker", "two_row")
35
+ _TWO_ROW_ONLY: tuple[str, ...] = (
36
+ "top_font",
37
+ "top_font_size",
38
+ "top_font_threshold",
39
+ "bottom_font",
40
+ "bottom_font_size",
41
+ "bottom_font_threshold",
42
+ "top_row_height",
43
+ )
44
+
45
+ # Color palette.
46
+ #
47
+ # DIM is reserved for the stale-temp signal (sensor data older than
48
+ # `stale_after`) — kept distinctly washed-out so users can tell the
49
+ # temperature isn't current.
50
+ #
51
+ # The prefix labels ("Pool 24h", "Pool 7D", etc.) and separators ("/")
52
+ # use the widget's configurable `label_color` field — defaults to white
53
+ # but can be tinted (e.g. an icy cyan for a pool widget).
54
+ #
55
+ # AVG_COLOR is the 7-day mean — pink, deliberately distinct from the
56
+ # HI/LO orange/blue axis and from white labels. The 7D AVG is the only
57
+ # value on its row that isn't an extreme, so it gets its own attention-
58
+ # grabbing color.
59
+ #
60
+ # STEADY_COLOR is the trend-arrow "no change" case (used only when
61
+ # `_trend_arrow` returns the steady glyph). Kept neutral gray so the
62
+ # arrow reads as the absence of trend rather than a third alert color.
63
+ DIM: Color = make_color(110, 110, 110)
64
+ AVG_COLOR: Color = colors.PINK
65
+ STEADY_COLOR: Color = make_color(210, 210, 210)
66
+ HI_COLOR: Color = colors.ORANGE
67
+ LO_COLOR: Color = colors.BLUE
68
+
69
+
70
+ class _HiLoColorProvider(ColorProviderBase):
71
+ """Per-char color for combined HI/LO bottom rows like "84/72F".
72
+
73
+ Paints the HI portion in `hi_color`, the LO portion in `lo_color`,
74
+ and the separator + unit-letter suffix in `label_color`. Indices
75
+ are passed in at construction so the provider doesn't need to know
76
+ the underlying text — keeps it dumb.
77
+
78
+ `per_char = True` triggers TwoRowMessage's per-character render
79
+ path. `frame_invariant = True` because no animation.
80
+ """
81
+
82
+ per_char: bool = True
83
+ frame_invariant: bool = True
84
+
85
+ def __init__(
86
+ self,
87
+ *,
88
+ hi_end: int,
89
+ lo_start: int,
90
+ lo_end: int,
91
+ hi_color: Color,
92
+ lo_color: Color,
93
+ label_color: Color,
94
+ ) -> None:
95
+ self._hi_end = hi_end
96
+ self._lo_start = lo_start
97
+ self._lo_end = lo_end
98
+ self._hi_color = hi_color
99
+ self._lo_color = lo_color
100
+ self._label_color = label_color
101
+
102
+ def color_for(self, frame: int, char_index: int, total_chars: int) -> Color:
103
+ if char_index < self._hi_end:
104
+ return self._hi_color
105
+ if char_index < self._lo_start:
106
+ return self._label_color
107
+ if char_index < self._lo_end:
108
+ return self._lo_color
109
+ return self._label_color
110
+
111
+
112
+ def _zone_color(temp_f: float) -> Color:
113
+ """Color for a water temp by dashboard zone (boundaries in °F)."""
114
+ if temp_f < 70.0:
115
+ return colors.BLUE
116
+ if temp_f < 80.0:
117
+ return colors.GREEN
118
+ if temp_f < 90.0:
119
+ return colors.ORANGE
120
+ return colors.RED
121
+
122
+
123
+ def _c_to_display(temp_c: float, units: str) -> float:
124
+ """Convert stored Celsius to the display unit."""
125
+ if units == "imperial":
126
+ return temp_c * 9.0 / 5.0 + 32.0
127
+ return temp_c
128
+
129
+
130
+ def _fmt_temp(temp_display: float, units: str) -> str:
131
+ """Whole-degree temp with unit suffix, e.g. '82F'.
132
+
133
+ No degree symbol — the hires Inter rasterized at small `font_size`
134
+ drops the U+00B0 glyph (renders as '?'), and the weather widget
135
+ already uses bare 'F'/'C' for the same reason. Stay consistent.
136
+ """
137
+ suffix = "F" if units == "imperial" else "C"
138
+ return f"{round(temp_display)}{suffix}"
139
+
140
+
141
+ def _trend_arrow(
142
+ now_f: float, past_f: float | None, *, ascii_only: bool
143
+ ) -> tuple[str, Color]:
144
+ """Return (glyph, color) for the trend vs ~30 min ago.
145
+
146
+ `ascii_only` selects the lores-safe glyph set. Color: green up,
147
+ red down, gray steady.
148
+ """
149
+ up = ("^" if ascii_only else "▲", colors.GREEN)
150
+ down = ("v" if ascii_only else "▼", colors.RED)
151
+ steady = ("-" if ascii_only else "–", STEADY_COLOR)
152
+ if past_f is None:
153
+ return steady
154
+ delta = now_f - past_f
155
+ if delta > _TREND_DEADBAND:
156
+ return up
157
+ if delta < -_TREND_DEADBAND:
158
+ return down
159
+ return steady
160
+
161
+
162
+ def _build_flux(
163
+ *, bucket: str, sensor_id: str | None, range_start: str, agg: str
164
+ ) -> str:
165
+ """Build a single-scalar Flux query.
166
+
167
+ `range_start` is a Flux duration ('-7d', '-1h') or an RFC3339
168
+ timestamp. `agg` is one of 'last', 'mean', 'min', 'max'.
169
+
170
+ A `group()` is inserted before the aggregation so that buckets
171
+ with multiple sensors (pool water + ambient air + heater coil etc.)
172
+ return a single global aggregate row, not one row per series.
173
+ Without `group()` the CSV parser would pick the first series's
174
+ aggregate — which depends on InfluxDB's tag-value sort order and
175
+ on which sensors happen to have data in the query range. That
176
+ inconsistency surfaced as "season HI 37°F but pool app shows 90°F"
177
+ on a multi-sensor bucket: for short ranges only the pool sensor
178
+ had data so its max returned first; for year-to-date the ambient
179
+ air sensor had data too and sorted earlier.
180
+
181
+ Set `sensor_id` in config to pin a specific sensor and skip the
182
+ cross-sensor aggregation.
183
+ """
184
+ sensor_clause = f' and r.id == "{sensor_id}"' if sensor_id else ""
185
+ return (
186
+ f'from(bucket: "{bucket}")\n'
187
+ f" |> range(start: {range_start})\n"
188
+ f' |> filter(fn: (r) => r._measurement == "mqtt_consumer"'
189
+ f' and r._field == "temperature_C"{sensor_clause})\n'
190
+ f" |> group()\n"
191
+ f" |> {agg}()"
192
+ )
193
+
194
+
195
+ def _parse_scalar_csv(text: str) -> tuple[float | None, str | None]:
196
+ """Parse an InfluxDB v2 annotated-CSV response into (value, time).
197
+
198
+ Returns (None, None) when there is no data row. Reads the first
199
+ data row's `_value` (float) and `_time` columns.
200
+ """
201
+ reader = csv.reader(io.StringIO(text))
202
+ header: list[str] | None = None
203
+ for row in reader:
204
+ if not row or all(c == "" for c in row):
205
+ continue
206
+ if row[0].startswith("#"):
207
+ continue
208
+ if header is None:
209
+ header = row
210
+ continue
211
+ record = dict(zip(header, row, strict=False))
212
+ raw = record.get("_value", "")
213
+ if raw == "":
214
+ return None, None
215
+ return float(raw), record.get("_time") or None
216
+ return None, None
217
+
218
+
219
+ logger = logging.getLogger(__name__)
220
+
221
+ _DEFAULT_INTERVAL = 300
222
+
223
+
224
+ @attrs.define
225
+ class PoolMonitor:
226
+ """Pool water temperature, cycled as title/today/7-day/season screens."""
227
+
228
+ session: aiohttp.ClientSession
229
+ title: str = "POOL TEMPS"
230
+ sensor_id: str | None = None
231
+ units: str = "imperial"
232
+ # How far back to look for the latest reading. Decoupled from
233
+ # `stale_after`: this is the hard cutoff below which there's nothing
234
+ # to show (→ "--"); a found reading older than `stale_after` still
235
+ # displays, dimmed. Flux duration string (e.g. "-24h", "-90m").
236
+ current_window: str = "-24h"
237
+ # Seconds since the last reading before it's shown dim-gray as stale.
238
+ # 4 h default so a reading that rode out a multi-hour sensor gap shows
239
+ # (dimmed) rather than dimming almost immediately.
240
+ stale_after: float = 14400.0
241
+ influxdb_url: str = attrs.field(
242
+ factory=lambda: os.getenv("INFLUXDB_URL", "http://influxdb:8086")
243
+ )
244
+ influxdb_org: str = attrs.field(factory=lambda: os.getenv("INFLUXDB_ORG", "pool"))
245
+ influxdb_bucket: str = attrs.field(
246
+ factory=lambda: os.getenv("INFLUXDB_BUCKET", "pool_temps")
247
+ )
248
+ influxdb_token: str = attrs.field(factory=lambda: os.getenv("INFLUXDB_TOKEN", ""))
249
+ font: Font = attrs.field(factory=lambda: resolve_font("6x12"), kw_only=True)
250
+ layout: str = attrs.field(default="ticker", kw_only=True)
251
+ label_color: Color = attrs.field(factory=lambda: colors.RGB_WHITE, kw_only=True)
252
+ top_font: Font | None = attrs.field(default=None, kw_only=True)
253
+ bottom_font: Font | None = attrs.field(default=None, kw_only=True)
254
+ top_row_height: int | None = attrs.field(default=None, kw_only=True)
255
+ feed_title: SegmentMessage | TwoRowMessage | None = attrs.field(
256
+ init=False, default=None
257
+ )
258
+ feed_stories: list[SegmentMessage | TwoRowMessage] = attrs.field(
259
+ init=False, factory=list
260
+ )
261
+
262
+ @classmethod
263
+ def validate_config(cls, cfg):
264
+ msgs = []
265
+ cw = cfg.get("current_window")
266
+ if cw is not None and not re.match(r"^-(\d+(ns|us|ms|s|m|h|d|w))+$", str(cw)):
267
+ msgs.append(
268
+ "current_window must be a negative Flux duration "
269
+ f'(e.g. "-24h", "-90m"); got {cw!r}'
270
+ )
271
+ sid = cfg.get("sensor_id")
272
+ if sid is not None and not _SENSOR_ID_RE.match(str(sid)):
273
+ msgs.append(f"sensor_id must match [A-Za-z0-9_-]+; got {sid!r}")
274
+ layout = cfg.get("layout")
275
+ if layout is not None and layout not in _VALID_LAYOUTS:
276
+ hint = ""
277
+ close = difflib.get_close_matches(str(layout), _VALID_LAYOUTS, n=1)
278
+ if close:
279
+ hint = f" (did you mean {close[0]!r}?)"
280
+ msgs.append(f"layout must be one of {_VALID_LAYOUTS}; got {layout!r}{hint}")
281
+ if cfg.get("layout", "ticker") != "two_row":
282
+ present = [k for k in _TWO_ROW_ONLY if k in cfg]
283
+ if present:
284
+ msgs.append(
285
+ f'{", ".join(present)} only valid with layout = "two_row" '
286
+ f"(current layout: {cfg.get('layout', 'ticker')!r})"
287
+ )
288
+ return msgs
289
+
290
+ @classmethod
291
+ async def start(
292
+ cls,
293
+ session: aiohttp.ClientSession,
294
+ update_interval: int = _DEFAULT_INTERVAL,
295
+ **kwargs: Any,
296
+ ) -> Self:
297
+ widget = cls(session=session, **kwargs)
298
+ if not widget.influxdb_token:
299
+ raise ValueError("INFLUXDB_TOKEN not set. Add it to your .env file.")
300
+ if widget.sensor_id is not None and not _SENSOR_ID_RE.match(widget.sensor_id):
301
+ raise ValueError(
302
+ f"Invalid sensor_id {widget.sensor_id!r}: must match [A-Za-z0-9_-]+"
303
+ )
304
+ widget._set_placeholder()
305
+ try:
306
+ await widget.update()
307
+ except Exception:
308
+ logger.exception("Pool initial update failed; showing placeholder")
309
+ spawn_tracked(run_monitor_loop(widget, update_interval))
310
+ return widget
311
+
312
+ async def _query(
313
+ self, range_start: str, agg: str
314
+ ) -> tuple[float | None, str | None]:
315
+ flux = _build_flux(
316
+ bucket=self.influxdb_bucket,
317
+ sensor_id=self.sensor_id,
318
+ range_start=range_start,
319
+ agg=agg,
320
+ )
321
+ url = f"{self.influxdb_url}/api/v2/query?org={self.influxdb_org}"
322
+ headers = {
323
+ "Authorization": f"Token {self.influxdb_token}",
324
+ "Content-Type": "application/vnd.flux",
325
+ "Accept": "application/csv",
326
+ }
327
+ async with self.session.post(url, data=flux, headers=headers) as resp:
328
+ resp.raise_for_status()
329
+ text = await resp.text()
330
+ value, ts = _parse_scalar_csv(text)
331
+ # DEBUG-level so production logs stay quiet; flip --log-level DEBUG
332
+ # to verify each scalar query is returning a sensible value when
333
+ # the displayed numbers look wrong (e.g. season HI too low).
334
+ logger.debug(
335
+ "pool query: range=%s agg=%s → value=%s ts=%s",
336
+ range_start,
337
+ agg,
338
+ value,
339
+ ts,
340
+ )
341
+ return value, ts
342
+
343
+ async def update(self) -> None:
344
+ year_start = f"{datetime.now(UTC).year}-01-01T00:00:00Z"
345
+ current_c, current_time = await self._query(self.current_window, "last")
346
+ past_c, _ = await self._query("-45m", "mean") # ~30 min lookback avg
347
+ today_min_c, _ = await self._query("today()", "min")
348
+ today_max_c, _ = await self._query("today()", "max")
349
+ d7_mean_c, _ = await self._query("-7d", "mean")
350
+ d7_min_c, _ = await self._query("-7d", "min")
351
+ d7_max_c, _ = await self._query("-7d", "max")
352
+ season_min_c, _ = await self._query(year_start, "min")
353
+ season_max_c, _ = await self._query(year_start, "max")
354
+
355
+ if current_c is None:
356
+ # The single most common "temps aren't showing" cause: the
357
+ # `-1h last` query came back with no data row. Surface it at
358
+ # WARNING (default log level) with enough context to tell
359
+ # apart an empty bucket, a wrong bucket/sensor, or an
360
+ # unreachable server — without ever logging the token.
361
+ sensor = self.sensor_id or "<all>"
362
+ logger.warning(
363
+ "pool: no current temperature from InfluxDB "
364
+ "(url=%s org=%s bucket=%s sensor=%s) → showing placeholder. "
365
+ "Check the bucket has recent temperature_C data and that "
366
+ "url/org/bucket/sensor_id match your InfluxDB.",
367
+ self.influxdb_url,
368
+ self.influxdb_org,
369
+ self.influxdb_bucket,
370
+ sensor,
371
+ )
372
+ self._set_placeholder()
373
+ return
374
+
375
+ age = self._age_seconds(current_time)
376
+ # One INFO line per successful update (Container contract): a
377
+ # silent log stream after startup means the background task died.
378
+ now_display = _c_to_display(current_c, self.units)
379
+ logger.info(
380
+ "pool updated: current=%s age=%.0fs stale=%s "
381
+ "today=%s/%s 7d=%s/%s season=%s/%s (layout=%s)",
382
+ _fmt_temp(now_display, self.units),
383
+ age,
384
+ age > self.stale_after,
385
+ self._disp(today_max_c),
386
+ self._disp(today_min_c),
387
+ self._disp(d7_max_c),
388
+ self._disp(d7_min_c),
389
+ self._disp(season_max_c),
390
+ self._disp(season_min_c),
391
+ self.layout,
392
+ )
393
+ if self.layout == "two_row":
394
+ self._build_two_row_screens(
395
+ current_c=current_c,
396
+ current_age_s=age,
397
+ past_c=past_c,
398
+ today_min_c=today_min_c,
399
+ today_max_c=today_max_c,
400
+ d7_mean_c=d7_mean_c,
401
+ d7_min_c=d7_min_c,
402
+ d7_max_c=d7_max_c,
403
+ season_min_c=season_min_c,
404
+ season_max_c=season_max_c,
405
+ )
406
+ else:
407
+ self._build_ticker_screens(
408
+ current_c=current_c,
409
+ current_age_s=age,
410
+ past_c=past_c,
411
+ today_min_c=today_min_c,
412
+ today_max_c=today_max_c,
413
+ d7_mean_c=d7_mean_c,
414
+ d7_min_c=d7_min_c,
415
+ d7_max_c=d7_max_c,
416
+ season_min_c=season_min_c,
417
+ season_max_c=season_max_c,
418
+ )
419
+
420
+ @staticmethod
421
+ def _age_seconds(ts: str | None) -> float:
422
+ if not ts:
423
+ return float("inf")
424
+ try:
425
+ t = datetime.fromisoformat(ts.replace("Z", "+00:00"))
426
+ except ValueError:
427
+ return float("inf")
428
+ return (datetime.now(UTC) - t).total_seconds()
429
+
430
+ def _disp(self, c: float | None) -> str:
431
+ if c is None:
432
+ return "--"
433
+ return str(round(_c_to_display(c, self.units)))
434
+
435
+ def _build_two_row_screens(
436
+ self,
437
+ *,
438
+ current_c: float,
439
+ current_age_s: float,
440
+ past_c: float | None,
441
+ today_min_c: float | None,
442
+ today_max_c: float | None,
443
+ d7_mean_c: float | None,
444
+ d7_min_c: float | None,
445
+ d7_max_c: float | None,
446
+ season_min_c: float | None,
447
+ season_max_c: float | None,
448
+ ) -> None:
449
+ """Build feed_title + feed_stories in two_row layout. See spec
450
+ docs/superpowers/specs/2026-05-28-pool-two-row-layout-design.md.
451
+ """
452
+ now_display = _c_to_display(current_c, self.units)
453
+ zone_f = _c_to_display(current_c, "imperial")
454
+ stale = current_age_s > self.stale_after
455
+
456
+ kw = {
457
+ "font": self.font,
458
+ "top_font": self.top_font,
459
+ "bottom_font": self.bottom_font,
460
+ "top_row_height": self.top_row_height,
461
+ "top_color": self.label_color,
462
+ }
463
+
464
+ self.feed_title = TwoRowMessage(
465
+ top_text="POOL",
466
+ bottom_text="TEMPS",
467
+ bottom_color=colors.RGB_WHITE,
468
+ **kw,
469
+ )
470
+
471
+ unit_letter = "F" if self.units == "imperial" else "C"
472
+ today_bottom_color = DIM if stale else _zone_color(zone_f)
473
+ today = TwoRowMessage(
474
+ top_text="POOL 24H",
475
+ bottom_text=_fmt_temp(now_display, self.units),
476
+ bottom_color=today_bottom_color,
477
+ **kw,
478
+ )
479
+
480
+ d7_hi = self._disp(d7_max_c)
481
+ d7_lo = self._disp(d7_min_c)
482
+ d7_text = f"{d7_hi}/{d7_lo}{unit_letter}"
483
+ d7 = TwoRowMessage(
484
+ top_text="POOL 7D",
485
+ bottom_text=d7_text,
486
+ bottom_color=_HiLoColorProvider(
487
+ hi_end=len(d7_hi),
488
+ lo_start=len(d7_hi) + 1,
489
+ lo_end=len(d7_hi) + 1 + len(d7_lo),
490
+ hi_color=HI_COLOR,
491
+ lo_color=LO_COLOR,
492
+ label_color=self.label_color,
493
+ ),
494
+ **kw,
495
+ )
496
+
497
+ season_hi = self._disp(season_max_c)
498
+ season_lo = self._disp(season_min_c)
499
+ season_text = f"{season_hi}/{season_lo}{unit_letter}"
500
+ season = TwoRowMessage(
501
+ top_text="POOL SEASON",
502
+ bottom_text=season_text,
503
+ bottom_color=_HiLoColorProvider(
504
+ hi_end=len(season_hi),
505
+ lo_start=len(season_hi) + 1,
506
+ lo_end=len(season_hi) + 1 + len(season_lo),
507
+ hi_color=HI_COLOR,
508
+ lo_color=LO_COLOR,
509
+ label_color=self.label_color,
510
+ ),
511
+ **kw,
512
+ )
513
+ self.feed_stories = [today, d7, season]
514
+
515
+ def _build_ticker_screens(
516
+ self,
517
+ *,
518
+ current_c: float,
519
+ current_age_s: float,
520
+ past_c: float | None,
521
+ today_min_c: float | None,
522
+ today_max_c: float | None,
523
+ d7_mean_c: float | None,
524
+ d7_min_c: float | None,
525
+ d7_max_c: float | None,
526
+ season_min_c: float | None,
527
+ season_max_c: float | None,
528
+ ) -> None:
529
+ now_display = _c_to_display(current_c, self.units)
530
+ past_display = _c_to_display(past_c, self.units) if past_c is not None else None
531
+ # Zone color always evaluated in °F so thresholds are consistent across units.
532
+ zone_f = _c_to_display(current_c, "imperial")
533
+ stale = current_age_s > self.stale_after
534
+
535
+ self.feed_title = SegmentMessage(
536
+ [(self.title, colors.RGB_WHITE)], center=True, font=self.font
537
+ )
538
+
539
+ temp_color = DIM if stale else _zone_color(zone_f)
540
+ arrow, arrow_color = _trend_arrow(now_display, past_display, ascii_only=True)
541
+ today = SegmentMessage(
542
+ [
543
+ ("Pool 24h ", self.label_color),
544
+ (_fmt_temp(now_display, self.units), temp_color),
545
+ (f" {arrow} ", arrow_color),
546
+ (self._disp(today_max_c), HI_COLOR),
547
+ ("/", self.label_color),
548
+ (self._disp(today_min_c), LO_COLOR),
549
+ ],
550
+ center=True,
551
+ font=self.font,
552
+ )
553
+ d7 = SegmentMessage(
554
+ [
555
+ ("Pool 7D AVG ", self.label_color),
556
+ (self._disp(d7_mean_c), AVG_COLOR),
557
+ (" ", self.label_color),
558
+ (self._disp(d7_max_c), HI_COLOR),
559
+ ("/", self.label_color),
560
+ (self._disp(d7_min_c), LO_COLOR),
561
+ ],
562
+ center=True,
563
+ font=self.font,
564
+ )
565
+ season = SegmentMessage(
566
+ [
567
+ ("Pool Season HI ", self.label_color),
568
+ (self._disp(season_max_c), HI_COLOR),
569
+ (" LO ", self.label_color),
570
+ (self._disp(season_min_c), LO_COLOR),
571
+ ],
572
+ center=True,
573
+ font=self.font,
574
+ )
575
+ self.feed_stories = [today, d7, season]
576
+
577
+ def _set_placeholder(self) -> None:
578
+ if self.layout == "two_row":
579
+ kw = {
580
+ "font": self.font,
581
+ "top_font": self.top_font,
582
+ "bottom_font": self.bottom_font,
583
+ "top_row_height": self.top_row_height,
584
+ "top_color": self.label_color,
585
+ "bottom_color": self.label_color,
586
+ }
587
+ self.feed_title = TwoRowMessage(
588
+ top_text="POOL",
589
+ bottom_text="TEMPS",
590
+ **kw,
591
+ )
592
+ self.feed_stories = [
593
+ TwoRowMessage(
594
+ top_text=self.title,
595
+ bottom_text="--",
596
+ **kw,
597
+ )
598
+ ]
599
+ return
600
+ self.feed_title = SegmentMessage(
601
+ [(self.title, colors.RGB_WHITE)], center=True, font=self.font
602
+ )
603
+ self.feed_stories = [
604
+ SegmentMessage(
605
+ [(f"{self.title} ", self.label_color), ("--", self.label_color)],
606
+ center=True,
607
+ font=self.font,
608
+ )
609
+ ]
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.4
2
+ Name: led-ticker-pool
3
+ Version: 0.1.0
4
+ Summary: Pool water-temperature monitor widget for led-ticker (InfluxDB v2 backed).
5
+ Project-URL: Homepage, https://docs.ledticker.dev
6
+ Project-URL: Repository, https://github.com/JamesAwesome/led-ticker-plugins
7
+ Project-URL: Issues, https://github.com/JamesAwesome/led-ticker-plugins/issues
8
+ Author-email: James Awesome <james@morelli.nyc>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Topic :: Multimedia :: Graphics
16
+ Requires-Python: >=3.14
17
+ Requires-Dist: aiohttp
18
+ Requires-Dist: led-ticker-core>=2.0
19
+ Provides-Extra: dev
20
+ Requires-Dist: pre-commit>=4.0; extra == 'dev'
21
+ Requires-Dist: pyright>=1.1; extra == 'dev'
22
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
23
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
24
+ Requires-Dist: pytest>=8.0; extra == 'dev'
25
+ Requires-Dist: ruff>=0.4; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # led-ticker-pool
29
+
30
+ A pool water-temperature monitor **widget** for [led-ticker](https://github.com/JamesAwesome/led-ticker), backed by an InfluxDB v2 server (e.g. [pool_monitor](https://github.com/JamesAwesome/pool_monitor)). It's a led-ticker **plugin** — installing this package contributes a `pool.monitor` widget you reference in your led-ticker config.
31
+
32
+ It cycles four screens — a title card, today's current temperature with a trend arrow (`^`/`v`/`-`) and hi/lo, a 7-day mean with hi/lo, and a season (current-year) hi/lo. Temperature is zone-colored — blue below 70°F, green 70–79°F, orange 80–89°F, red 90°F+ — so the comfort level is readable at a glance. Data is fetched in the background via async polling, so the display keeps running even if the server is briefly unreachable.
33
+
34
+ ## Screenshots
35
+
36
+ **`layout = "ticker"`** (default — single-row segmented screens, smallsign-friendly):
37
+
38
+ ![Pool widget in ticker layout — single-row segmented screens with trend arrow and hi/lo](docs/widget-pool.gif)
39
+
40
+ **`layout = "two_row"`** (stacked label-on-top / big-number-on-bottom, bigsign / longboi):
41
+
42
+ ![Pool widget in two_row layout — stacked label-on-top, big-number-on-bottom](docs/widget-pool-two-row.gif)
43
+
44
+ ## Prerequisites
45
+
46
+ - **A running led-ticker** (the sign + its config). This widget plugs into it.
47
+ - **A running InfluxDB v2 server** holding pool temperature data. The reference stack is [pool_monitor](https://github.com/JamesAwesome/pool_monitor), which provides the bucket and sensor schema this widget expects.
48
+ - **An InfluxDB auth token** — set `INFLUXDB_TOKEN` in your led-ticker `.env` (or as a per-widget config key). The widget raises `ValueError` at startup if it's missing. See [InfluxDB setup](#influxdb-setup) for all four connection variables.
49
+
50
+ ## Install
51
+
52
+ The widget auto-registers via the `led_ticker.plugins` entry point — once the package is installed, no `[plugins]` config change is needed.
53
+
54
+ **Into a containerized led-ticker (recommended):** add this package to `config/requirements-plugins.txt` (copy it from `config/requirements-plugins.example.txt`, which already lists it), then rebuild:
55
+
56
+ ```bash
57
+ # in your led-ticker checkout
58
+ cp config/requirements-plugins.example.txt config/requirements-plugins.txt
59
+ docker compose up -d --build
60
+ ```
61
+
62
+ **Standalone (bare-metal / a venv that already has led-ticker):**
63
+
64
+ ```bash
65
+ pip install "git+https://github.com/JamesAwesome/led-ticker-pool.git@main"
66
+ ```
67
+
68
+ (led-ticker isn't on PyPI, so `pip` can't fetch it — this path works only where led-ticker is already installed, e.g. inside the led-ticker Docker image or a venv set up as in [Development](#development) below. See the led-ticker [Plugins docs](https://docs.ledticker.dev/plugins/) for the constraint-based install the image uses.)
69
+
70
+ ## Configuration
71
+
72
+ Reference the widget in a playlist section by `type = "pool.monitor"`:
73
+
74
+ ```toml
75
+ [[playlist.section.widget]]
76
+ type = "pool.monitor"
77
+ title = "POOL TEMPS"
78
+ units = "imperial"
79
+ ```
80
+
81
+ ### Options
82
+
83
+ | Option | Type | Default | Description |
84
+ |--------|------|---------|-------------|
85
+ | `title` | string | `"POOL TEMPS"` | Label shown on the title screen. |
86
+ | `sensor_id` | string | none | Sensor ID to filter on. Omit to use the only/first sensor in the bucket. Must match `[A-Za-z0-9_-]+`. |
87
+ | `units` | string | `"imperial"` | `"imperial"` (°F) or `"metric"` (°C). |
88
+ | `update_interval` | int | `300` | Seconds between InfluxDB fetches (5 min default). |
89
+ | `current_window` | string | `"-24h"` | How far back to search for the latest reading, as a negative Flux duration (`"-24h"`, `"-90m"`). Older than this → `--` placeholder. Widen it if your sensor reports infrequently. |
90
+ | `stale_after` | float | `14400` | Seconds since the last reading before the temperature dims to gray (stale signal). 4 h default. |
91
+ | `influxdb_url` | string | `$INFLUXDB_URL` / `"http://influxdb:8086"` | InfluxDB v2 base URL. Config overrides the env var. |
92
+ | `influxdb_org` | string | `$INFLUXDB_ORG` / `"pool"` | InfluxDB organization. |
93
+ | `influxdb_bucket` | string | `$INFLUXDB_BUCKET` / `"pool_temps"` | InfluxDB bucket. |
94
+ | `influxdb_token` | string | `$INFLUXDB_TOKEN` | InfluxDB v2 token. **Required** — the widget raises `ValueError` at startup if it's missing. |
95
+ | `layout` | `"ticker"` \| `"two_row"` | `"ticker"` | Render mode (see below). |
96
+ | `label_color` | `[r,g,b]` | white | Color for prefix labels / separators. |
97
+ | `font` / `font_size` / `font_threshold` | font name / int / int | `"6x12"` | Main text font. A BDF alias (`6x12`, `5x8`) or a hires font name (`Inter-Regular`) with `font_size` in real pixels. In `two_row`, the per-row knobs below override this. |
98
+ | `top_font` / `top_font_size` / `top_font_threshold` | font / int / int | inherit `font` | **two_row only:** top (label) row font knobs. |
99
+ | `bottom_font` / `bottom_font_size` / `bottom_font_threshold` | font / int / int | inherit | **two_row only:** bottom (value) row font knobs. |
100
+ | `top_row_height` | int (logical rows) | `None` | **two_row only:** top band height. `None` = symmetric 8/8 split. |
101
+
102
+ The per-row knobs apply ONLY when `layout = "two_row"`; setting them under `ticker` fails config validation.
103
+
104
+ ### Layouts
105
+
106
+ - **`ticker`** (default) — single-row segmented screens; the today screen shows current temp + trend arrow and hi/lo, the 7-day screen the mean + hi/lo, the season screen HI/LO together. Best for small panels (smallsign 160×16).
107
+ - **`two_row`** — stacked label-on-top / big-number-on-bottom. Cycles four screens with top-row labels `POOL` (title), `POOL 24H` (current temp, zone-colored), `POOL 7D` (7-day HI/LO), and `POOL SEASON` (season HI/LO) — HI in orange, LO in blue, shown together on one screen (e.g. `84/72F`). The trend arrow is dropped (bottom is the value only). Best for bigsign / longboi (256×64 / 512×64).
108
+
109
+ A `two_row` example:
110
+
111
+ ```toml
112
+ [[playlist.section.widget]]
113
+ type = "pool.monitor"
114
+ title = "POOL TEMPS"
115
+ layout = "two_row"
116
+ units = "imperial"
117
+ font = "Inter-Regular"
118
+ font_size = 32
119
+ label_color = [130, 220, 255]
120
+ ```
121
+
122
+ ## InfluxDB setup
123
+
124
+ The widget reads connection details from your led-ticker `.env` (or per-widget overrides). `INFLUXDB_TOKEN` is required; the rest default to the standard pool_monitor Docker Compose stack.
125
+
126
+ | Variable | Required | Default | Description |
127
+ |----------|----------|---------|-------------|
128
+ | `INFLUXDB_TOKEN` | **yes** | — | InfluxDB v2 auth token. |
129
+ | `INFLUXDB_URL` | no | `http://influxdb:8086` | Base URL. |
130
+ | `INFLUXDB_ORG` | no | `pool` | Organization. |
131
+ | `INFLUXDB_BUCKET` | no | `pool_temps` | Bucket. |
132
+
133
+ The widget queries water-temperature readings with Flux over HTTP and computes today / 7-day / season aggregates. Stale data (older than `stale_after`) renders dim gray; the trend arrow compares the latest reading to a ~45-minute trailing average (sub-0.5°F shows `-`).
134
+
135
+ ## Development
136
+
137
+ led-ticker isn't on PyPI, so install it editable from a sibling checkout. This repo's `pyproject.toml` pins `led-ticker` to `../led-ticker` via `[tool.uv.sources]`:
138
+
139
+ ```bash
140
+ git clone https://github.com/JamesAwesome/led-ticker ../led-ticker # sibling checkout
141
+ git clone https://github.com/JamesAwesome/led-ticker-pool && cd led-ticker-pool
142
+ uv venv
143
+ uv pip install -e ../led-ticker -e ".[dev]"
144
+ uv run pytest -q
145
+ ```
146
+
147
+ > **Note:** led-ticker's `graphics` surface works headless via its bundled stub, but the full `RGBMatrix`/canvas test stub lives in led-ticker's `tests/stubs/` and isn't shipped. This repo's tests put it on the path via `pyproject.toml`'s `[tool.pytest.ini_options] pythonpath = ["../led-ticker/tests/stubs"]`.
148
+
149
+ ## Links
150
+
151
+ - led-ticker project: <https://github.com/JamesAwesome/led-ticker>
152
+ - led-ticker plugin system: <https://docs.ledticker.dev/plugins/>
@@ -0,0 +1,7 @@
1
+ led_ticker_pool/__init__.py,sha256=DfvdIFE6ET8PL80lWnVj8b54yHF5uJ0_MfEi-Mep_QQ,345
2
+ led_ticker_pool/monitor.py,sha256=CN0bpuRYvt-7zfO2dDUA-0g_G0VkRrHJKG_0EOhQ68M,21934
3
+ led_ticker_pool-0.1.0.dist-info/METADATA,sha256=6kYINf_BkvaDYJK7cCTEmr-RqsNRPW2-cIg5StRwD5Q,8814
4
+ led_ticker_pool-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
5
+ led_ticker_pool-0.1.0.dist-info/entry_points.txt,sha256=_X9VYKBc6VWSlR9BvRa4cK0Umzvqb_BbgMPgEaz8xrU,53
6
+ led_ticker_pool-0.1.0.dist-info/licenses/LICENSE,sha256=kFECdLBfsSUUTLoH4nlAu-gQPyXoHbWMZDg8p2DGAP4,1070
7
+ led_ticker_pool-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [led_ticker.plugins]
2
+ pool = led_ticker_pool:register
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 James Awesome
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.