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.
- led_ticker_pool/__init__.py +11 -0
- led_ticker_pool/monitor.py +609 -0
- led_ticker_pool-0.1.0.dist-info/METADATA +152 -0
- led_ticker_pool-0.1.0.dist-info/RECORD +7 -0
- led_ticker_pool-0.1.0.dist-info/WHEEL +4 -0
- led_ticker_pool-0.1.0.dist-info/entry_points.txt +2 -0
- led_ticker_pool-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+

|
|
39
|
+
|
|
40
|
+
**`layout = "two_row"`** (stacked label-on-top / big-number-on-bottom, bigsign / longboi):
|
|
41
|
+
|
|
42
|
+

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