led-ticker-weather 0.2.0__tar.gz
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_weather-0.2.0/.gitignore +12 -0
- led_ticker_weather-0.2.0/CLAUDE.md +65 -0
- led_ticker_weather-0.2.0/LICENSE +21 -0
- led_ticker_weather-0.2.0/PKG-INFO +90 -0
- led_ticker_weather-0.2.0/README.md +63 -0
- led_ticker_weather-0.2.0/pyproject.toml +56 -0
- led_ticker_weather-0.2.0/src/led_ticker_weather/__init__.py +12 -0
- led_ticker_weather-0.2.0/src/led_ticker_weather/weather.py +281 -0
- led_ticker_weather-0.2.0/tests/conftest.py +35 -0
- led_ticker_weather-0.2.0/tests/test_import_purity.py +29 -0
- led_ticker_weather-0.2.0/tests/test_smoke.py +15 -0
- led_ticker_weather-0.2.0/tests/test_weather.py +747 -0
- led_ticker_weather-0.2.0/tests/test_weather_icons.py +62 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
Guidance for Claude Code when working in **led-ticker-weather**, an external plugin for
|
|
4
|
+
[led-ticker](https://github.com/JamesAwesome/led-ticker).
|
|
5
|
+
|
|
6
|
+
`README.md` is the source of truth for the user-facing surface (config options, install).
|
|
7
|
+
This file keeps the **load-bearing invariants** a contributor must respect.
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
This plugin contributes, via the `led_ticker.plugins` entry point, a single widget:
|
|
12
|
+
|
|
13
|
+
- `weather.current` — a current-conditions widget backed by `WeatherWidget`
|
|
14
|
+
(`src/led_ticker_weather/weather.py`). It fetches current conditions from
|
|
15
|
+
[WeatherAPI.com](https://www.weatherapi.com/) (key via the `WEATHERAPI_KEY` env var) and
|
|
16
|
+
draws the location label, temperature, and a condition icon.
|
|
17
|
+
|
|
18
|
+
The entry-point name `weather` is the plugin namespace, so the config `type` is
|
|
19
|
+
`weather.current`. `register()` in `__init__.py` calls `api.widget("current")(WeatherWidget)`.
|
|
20
|
+
|
|
21
|
+
This package split out of `led-ticker-feeds` (was `feeds.weather`); history is preserved.
|
|
22
|
+
|
|
23
|
+
## Load-bearing invariants
|
|
24
|
+
|
|
25
|
+
- **Public surface only:** `weather.py` imports ONLY from `led_ticker.plugin` (plus stdlib +
|
|
26
|
+
`aiohttp` + `attrs`). Never reach into `led_ticker.<internal>`. Enforced by
|
|
27
|
+
`tests/test_import_purity.py` (AST scan of `src/led_ticker_weather`).
|
|
28
|
+
- **Deps:** `aiohttp` only (no `feedparser` — that is an rss-only dep).
|
|
29
|
+
- **Condition → icon mapping:** `_match_condition(condition) -> slug` is the icon resolver
|
|
30
|
+
(sun / cloud / rain / snow / thunder / fog; unknown defaults to sun). `test_weather_icons.py`
|
|
31
|
+
is the per-branch tripwire; `test_weather.py` asserts every slug it can return exists in both
|
|
32
|
+
the lowres and hires emoji registries.
|
|
33
|
+
- **`WEATHERAPI_KEY`:** `test_weather.py` provides it via an autouse `monkeypatch.setenv`
|
|
34
|
+
fixture — never hardcode a real key. `test_weather_icons.py` calls the pure resolver and
|
|
35
|
+
needs no key.
|
|
36
|
+
- **No `from __future__ import annotations`** (Python 3.14 / PEP 649 rule, same as core).
|
|
37
|
+
|
|
38
|
+
## Commands
|
|
39
|
+
|
|
40
|
+
led-ticker is **not on PyPI**; it resolves from a sibling checkout via the monorepo root
|
|
41
|
+
`[tool.uv.sources]`. The rgbmatrix stub is vendored at the monorepo root and put on the
|
|
42
|
+
import path by the **root** `pyproject.toml`. Run tooling from the repo root:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uv sync --extra dev
|
|
46
|
+
uv run pytest plugins/weather
|
|
47
|
+
uv run ruff check plugins/weather
|
|
48
|
+
uv run pyright plugins/weather/src
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Python **3.14+** only.
|
|
52
|
+
|
|
53
|
+
## Package layout
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
src/led_ticker_weather/
|
|
57
|
+
__init__.py # register(api) → api.widget("current")(WeatherWidget)
|
|
58
|
+
weather.py # WeatherWidget + _match_condition icon resolver
|
|
59
|
+
tests/
|
|
60
|
+
test_weather.py # widget behavior (autouse WEATHERAPI_KEY fixture)
|
|
61
|
+
test_weather_icons.py # _match_condition per-branch tripwire
|
|
62
|
+
test_import_purity.py # AST: only led_ticker.plugin imports
|
|
63
|
+
test_smoke.py # entry-point registers weather.current
|
|
64
|
+
conftest.py # shared canvas / make_widget fixtures
|
|
65
|
+
```
|
|
@@ -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.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: led-ticker-weather
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Current-conditions weather widget for led-ticker (weather.current).
|
|
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-weather
|
|
29
|
+
|
|
30
|
+
A current-conditions weather **plugin** for [led-ticker](https://github.com/JamesAwesome/led-ticker), backed by [WeatherAPI.com](https://www.weatherapi.com/). It contributes a single `weather.current` widget that shows the location label, current temperature, and a condition icon.
|
|
31
|
+
|
|
32
|
+
This package split out of `led-ticker-feeds` (its `feeds.weather` widget); the type is now `weather.current`.
|
|
33
|
+
|
|
34
|
+
## Prerequisites
|
|
35
|
+
|
|
36
|
+
- A working [led-ticker](https://github.com/JamesAwesome/led-ticker) install.
|
|
37
|
+
- A free [WeatherAPI.com](https://www.weatherapi.com/) API key, exported as `WEATHERAPI_KEY`.
|
|
38
|
+
- Internet access on the Pi (the widget calls the WeatherAPI.com API).
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
The widget auto-registers via the `led_ticker.plugins` entry point — once the package is installed, no `[plugins]` config change is needed.
|
|
43
|
+
|
|
44
|
+
**Into a containerized led-ticker (recommended):** add this package to `config/requirements-plugins.txt` (copy it from `config/requirements-plugins.example.txt`), then rebuild:
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
git+https://github.com/JamesAwesome/led-ticker-plugins.git@weather-v0.2.0#subdirectory=plugins/weather
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# in your led-ticker checkout
|
|
52
|
+
docker compose up -d --build
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Standalone (a venv that already has led-ticker):**
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install "git+https://github.com/JamesAwesome/led-ticker-plugins.git@weather-v0.2.0#subdirectory=plugins/weather"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
led-ticker isn't on PyPI, so this path only works where led-ticker is already installed. See the led-ticker [Plugins docs](https://docs.ledticker.dev/plugins/) for the constraint-based install the Docker image uses.
|
|
62
|
+
|
|
63
|
+
## Configuration
|
|
64
|
+
|
|
65
|
+
Set the API key in your `.env`:
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
WEATHERAPI_KEY=your-key-here
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Then add the widget:
|
|
72
|
+
|
|
73
|
+
```toml
|
|
74
|
+
[[sections]]
|
|
75
|
+
[[sections.widgets]]
|
|
76
|
+
type = "weather.current"
|
|
77
|
+
location = "London"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The widget polls WeatherAPI.com in the background and renders the label, temperature, and a condition icon. Conditions map to icon slugs via `_match_condition` (sun / cloud / rain / snow / thunder / fog).
|
|
81
|
+
|
|
82
|
+
## Development
|
|
83
|
+
|
|
84
|
+
This package lives in the [led-ticker-plugins](https://github.com/JamesAwesome/led-ticker-plugins) monorepo. Run tooling from the repo root:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
uv sync --extra dev
|
|
88
|
+
uv run pytest plugins/weather
|
|
89
|
+
uv run ruff check plugins/weather
|
|
90
|
+
```
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# led-ticker-weather
|
|
2
|
+
|
|
3
|
+
A current-conditions weather **plugin** for [led-ticker](https://github.com/JamesAwesome/led-ticker), backed by [WeatherAPI.com](https://www.weatherapi.com/). It contributes a single `weather.current` widget that shows the location label, current temperature, and a condition icon.
|
|
4
|
+
|
|
5
|
+
This package split out of `led-ticker-feeds` (its `feeds.weather` widget); the type is now `weather.current`.
|
|
6
|
+
|
|
7
|
+
## Prerequisites
|
|
8
|
+
|
|
9
|
+
- A working [led-ticker](https://github.com/JamesAwesome/led-ticker) install.
|
|
10
|
+
- A free [WeatherAPI.com](https://www.weatherapi.com/) API key, exported as `WEATHERAPI_KEY`.
|
|
11
|
+
- Internet access on the Pi (the widget calls the WeatherAPI.com API).
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
The widget auto-registers via the `led_ticker.plugins` entry point — once the package is installed, no `[plugins]` config change is needed.
|
|
16
|
+
|
|
17
|
+
**Into a containerized led-ticker (recommended):** add this package to `config/requirements-plugins.txt` (copy it from `config/requirements-plugins.example.txt`), then rebuild:
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
git+https://github.com/JamesAwesome/led-ticker-plugins.git@weather-v0.2.0#subdirectory=plugins/weather
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# in your led-ticker checkout
|
|
25
|
+
docker compose up -d --build
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**Standalone (a venv that already has led-ticker):**
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install "git+https://github.com/JamesAwesome/led-ticker-plugins.git@weather-v0.2.0#subdirectory=plugins/weather"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
led-ticker isn't on PyPI, so this path only works where led-ticker is already installed. See the led-ticker [Plugins docs](https://docs.ledticker.dev/plugins/) for the constraint-based install the Docker image uses.
|
|
35
|
+
|
|
36
|
+
## Configuration
|
|
37
|
+
|
|
38
|
+
Set the API key in your `.env`:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
WEATHERAPI_KEY=your-key-here
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Then add the widget:
|
|
45
|
+
|
|
46
|
+
```toml
|
|
47
|
+
[[sections]]
|
|
48
|
+
[[sections.widgets]]
|
|
49
|
+
type = "weather.current"
|
|
50
|
+
location = "London"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The widget polls WeatherAPI.com in the background and renders the label, temperature, and a condition icon. Conditions map to icon slugs via `_match_condition` (sun / cloud / rain / snow / thunder / fog).
|
|
54
|
+
|
|
55
|
+
## Development
|
|
56
|
+
|
|
57
|
+
This package lives in the [led-ticker-plugins](https://github.com/JamesAwesome/led-ticker-plugins) monorepo. Run tooling from the repo root:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
uv sync --extra dev
|
|
61
|
+
uv run pytest plugins/weather
|
|
62
|
+
uv run ruff check plugins/weather
|
|
63
|
+
```
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "led-ticker-weather"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Current-conditions weather widget for led-ticker (weather.current)."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.14"
|
|
9
|
+
authors = [{ name = "James Awesome", email = "james@morelli.nyc" }]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Programming Language :: Python :: 3",
|
|
13
|
+
"Programming Language :: Python :: 3.14",
|
|
14
|
+
"Operating System :: POSIX :: Linux",
|
|
15
|
+
"Topic :: Multimedia :: Graphics",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"led-ticker-core>=2.0",
|
|
19
|
+
"aiohttp",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
# Entry-point NAME ("weather") is the plugin namespace -> TOML `type = "weather.current"`.
|
|
23
|
+
[project.entry-points."led_ticker.plugins"]
|
|
24
|
+
weather = "led_ticker_weather:register"
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = [
|
|
28
|
+
"pytest>=8.0",
|
|
29
|
+
"pytest-asyncio>=0.23",
|
|
30
|
+
"pytest-cov>=5.0",
|
|
31
|
+
"pre-commit>=4.0",
|
|
32
|
+
"ruff>=0.4",
|
|
33
|
+
"pyright>=1.1",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://docs.ledticker.dev"
|
|
38
|
+
Repository = "https://github.com/JamesAwesome/led-ticker-plugins"
|
|
39
|
+
Issues = "https://github.com/JamesAwesome/led-ticker-plugins/issues"
|
|
40
|
+
|
|
41
|
+
[build-system]
|
|
42
|
+
requires = ["hatchling"]
|
|
43
|
+
build-backend = "hatchling.build"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["src/led_ticker_weather"]
|
|
47
|
+
|
|
48
|
+
[tool.ruff]
|
|
49
|
+
target-version = "py314"
|
|
50
|
+
src = ["src"]
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint]
|
|
53
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
54
|
+
|
|
55
|
+
[tool.coverage.report]
|
|
56
|
+
fail_under = 90
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""led-ticker-weather: current-conditions widget (weather.current) contributed
|
|
2
|
+
via the ``led_ticker.plugins`` entry point.
|
|
3
|
+
|
|
4
|
+
The entry-point name ``weather`` is the plugin namespace, so the widget is
|
|
5
|
+
referenced in config.toml as ``type = "weather.current"``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from led_ticker_weather.weather import WeatherWidget
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def register(api):
|
|
12
|
+
api.widget("current")(WeatherWidget)
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Weather widget using WeatherAPI.com (weather.current)."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from typing import Any, Self
|
|
6
|
+
|
|
7
|
+
import aiohttp
|
|
8
|
+
import attrs
|
|
9
|
+
from led_ticker.plugin import (
|
|
10
|
+
FONT_DEFAULT,
|
|
11
|
+
Canvas,
|
|
12
|
+
Color,
|
|
13
|
+
ColorProvider,
|
|
14
|
+
DrawResult,
|
|
15
|
+
Font,
|
|
16
|
+
FrameAwareBase,
|
|
17
|
+
as_color_provider,
|
|
18
|
+
compute_baseline,
|
|
19
|
+
compute_cursor,
|
|
20
|
+
draw_emoji_at,
|
|
21
|
+
draw_text,
|
|
22
|
+
draw_text_per_char,
|
|
23
|
+
get_text_width,
|
|
24
|
+
make_color,
|
|
25
|
+
measure_emoji_at,
|
|
26
|
+
run_monitor_loop,
|
|
27
|
+
spawn_tracked,
|
|
28
|
+
)
|
|
29
|
+
from led_ticker.plugin import (
|
|
30
|
+
colors as _colors,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
WEATHERAPI_URL: str = "https://api.weatherapi.com/v1/current.json"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _coerce_provider(value: Any) -> ColorProvider:
|
|
37
|
+
"""Coerce a color field to a ColorProvider.
|
|
38
|
+
|
|
39
|
+
None -> default; an existing provider (has ``color_for``) -> as-is; a raw
|
|
40
|
+
[r, g, b] list/tuple -> ``as_color_provider(make_color(...))``; an existing
|
|
41
|
+
``graphics.Color`` -> ``as_color_provider``. Config strings/tables (e.g.
|
|
42
|
+
"rainbow", {style=...}) are coerced into providers by the loader BEFORE
|
|
43
|
+
construction, so they arrive here already as providers and pass through.
|
|
44
|
+
"""
|
|
45
|
+
if value is None:
|
|
46
|
+
return as_color_provider(_colors.DEFAULT_COLOR)
|
|
47
|
+
if hasattr(value, "color_for"):
|
|
48
|
+
return value
|
|
49
|
+
if isinstance(value, (list, tuple)):
|
|
50
|
+
return as_color_provider(make_color(*value))
|
|
51
|
+
return as_color_provider(value) # already a graphics.Color
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _match_condition(condition: str) -> str:
|
|
55
|
+
"""Map a WeatherAPI condition string to an emoji slug."""
|
|
56
|
+
c = condition.lower()
|
|
57
|
+
if "thunder" in c:
|
|
58
|
+
return "thunder"
|
|
59
|
+
if "snow" in c or "blizzard" in c or "ice" in c or "sleet" in c:
|
|
60
|
+
return "snow"
|
|
61
|
+
if "rain" in c or "drizzle" in c or "shower" in c:
|
|
62
|
+
return "rain"
|
|
63
|
+
if "fog" in c or "mist" in c:
|
|
64
|
+
return "fog"
|
|
65
|
+
if "partly" in c:
|
|
66
|
+
return "partly_cloudy"
|
|
67
|
+
if "cloud" in c or "overcast" in c:
|
|
68
|
+
return "cloud"
|
|
69
|
+
# Sunny, Clear, or anything else
|
|
70
|
+
return "sun"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@attrs.define
|
|
74
|
+
class WeatherWidget(FrameAwareBase):
|
|
75
|
+
"""Current weather display widget."""
|
|
76
|
+
|
|
77
|
+
session: aiohttp.ClientSession
|
|
78
|
+
location: str # query string: "New York", "10001", "40.71,-74.01"
|
|
79
|
+
text: str
|
|
80
|
+
units: str = "imperial"
|
|
81
|
+
font: Font = attrs.Factory(lambda: FONT_DEFAULT)
|
|
82
|
+
font_color: Color | ColorProvider = attrs.Factory(lambda: _colors.DEFAULT_COLOR)
|
|
83
|
+
# WeatherWidget keeps two color knobs: `font_color` for the label
|
|
84
|
+
# (e.g. "Brooklyn:") and `font_color_temp` for the temperature
|
|
85
|
+
# value (e.g. "64°F"). They're separate so a config can color the
|
|
86
|
+
# label with an effect (`font_color = "rainbow"`) while keeping
|
|
87
|
+
# the temp value in a steady high-contrast color (default white).
|
|
88
|
+
# If you want the temp to also use the effect, set them both:
|
|
89
|
+
# font_color = "rainbow"
|
|
90
|
+
# font_color_temp = "rainbow"
|
|
91
|
+
font_color_temp: Color | ColorProvider = attrs.Factory(lambda: _colors.RGB_WHITE)
|
|
92
|
+
bg_color: Color | None = attrs.field(default=None, kw_only=True)
|
|
93
|
+
center: bool = True
|
|
94
|
+
padding: int = 6
|
|
95
|
+
hold_time: float = 0.0
|
|
96
|
+
show_icon: bool = True
|
|
97
|
+
unit_symbol: str = attrs.field(init=False, default="")
|
|
98
|
+
current_temp: int = attrs.field(init=False, default=0)
|
|
99
|
+
weather: str = attrs.field(init=False, default="")
|
|
100
|
+
|
|
101
|
+
def __attrs_post_init__(self) -> None:
|
|
102
|
+
# Normalize both color fields to ColorProvider instances. The loader
|
|
103
|
+
# pre-coerces rich config forms ("rainbow", {style=...}) into providers
|
|
104
|
+
# before construction, so _coerce_provider is an idempotent pass-through
|
|
105
|
+
# for those; raw graphics.Color / [r, g, b] values (e.g. from defaults or
|
|
106
|
+
# direct construction) get wrapped here.
|
|
107
|
+
self.font_color = _coerce_provider(self.font_color)
|
|
108
|
+
self.font_color_temp = _coerce_provider(self.font_color_temp)
|
|
109
|
+
|
|
110
|
+
# Support dict location from TOML: {lat = 40.71, lon = -74.01}
|
|
111
|
+
if isinstance(self.location, dict):
|
|
112
|
+
lat = self.location.get("lat", 0)
|
|
113
|
+
lon = self.location.get("lon", 0)
|
|
114
|
+
self.location = f"{lat},{lon}"
|
|
115
|
+
|
|
116
|
+
if self.units == "imperial":
|
|
117
|
+
self.unit_symbol = "F"
|
|
118
|
+
elif self.units == "metric":
|
|
119
|
+
self.unit_symbol = "C"
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
async def start(
|
|
123
|
+
cls, *args: Any, update_interval: int = 10800, **kwargs: Any
|
|
124
|
+
) -> Self:
|
|
125
|
+
widget = cls(*args, **kwargs)
|
|
126
|
+
try:
|
|
127
|
+
await widget.update()
|
|
128
|
+
except Exception:
|
|
129
|
+
logging.exception(
|
|
130
|
+
"Weather initial update failed for %s, will retry in background",
|
|
131
|
+
widget.location,
|
|
132
|
+
)
|
|
133
|
+
spawn_tracked(run_monitor_loop(widget, update_interval))
|
|
134
|
+
return widget
|
|
135
|
+
|
|
136
|
+
async def update(self) -> None:
|
|
137
|
+
logging.info("Updating weather for: %s", self.location)
|
|
138
|
+
api_key = os.getenv("WEATHERAPI_KEY", "")
|
|
139
|
+
if not api_key:
|
|
140
|
+
raise ValueError("WEATHERAPI_KEY not set. Add it to your .env file.")
|
|
141
|
+
params = {
|
|
142
|
+
"key": api_key,
|
|
143
|
+
"q": self.location,
|
|
144
|
+
}
|
|
145
|
+
async with self.session.get(
|
|
146
|
+
WEATHERAPI_URL,
|
|
147
|
+
params=params,
|
|
148
|
+
) as response:
|
|
149
|
+
data = await response.json()
|
|
150
|
+
|
|
151
|
+
# WeatherAPI returns {"error": {...}} on failure
|
|
152
|
+
if "error" in data:
|
|
153
|
+
code = data["error"].get("code", "?")
|
|
154
|
+
msg = data["error"].get("message", "Unknown error")
|
|
155
|
+
raise ValueError(f"WeatherAPI error {code}: {msg}")
|
|
156
|
+
|
|
157
|
+
current = data["current"]
|
|
158
|
+
if self.units == "imperial":
|
|
159
|
+
self.current_temp = int(current["temp_f"])
|
|
160
|
+
else:
|
|
161
|
+
self.current_temp = int(current["temp_c"])
|
|
162
|
+
self.weather = current["condition"]["text"]
|
|
163
|
+
|
|
164
|
+
def draw(
|
|
165
|
+
self,
|
|
166
|
+
canvas: Canvas,
|
|
167
|
+
cursor_pos: int = 0,
|
|
168
|
+
*,
|
|
169
|
+
y_offset: int = 0,
|
|
170
|
+
font_color: Any = None,
|
|
171
|
+
) -> DrawResult:
|
|
172
|
+
temp_text = f"{self.current_temp}{self.unit_symbol}"
|
|
173
|
+
label_text = f"{self.text}: "
|
|
174
|
+
|
|
175
|
+
# Resolve the icon slug once and read its actual rendered footprint
|
|
176
|
+
# via `measure_emoji_at` — keeps layout in sync with whichever
|
|
177
|
+
# variant `draw_emoji_at` will pick (lowres on plain canvas,
|
|
178
|
+
# hires-when-available on a ScaledCanvas, falling back to lowres
|
|
179
|
+
# for slugs without a HIRES_REGISTRY entry like `partly_cloudy`).
|
|
180
|
+
# Reading the footprint dynamically scales correctly across
|
|
181
|
+
# per-section `scale` overrides — at scale=2 a hires sprite is 16
|
|
182
|
+
# logical wide, and a hardcoded `8` here would let the temperature
|
|
183
|
+
# text overlap the icon.
|
|
184
|
+
if self.show_icon:
|
|
185
|
+
content_width = (
|
|
186
|
+
get_text_width(self.font, label_text, padding=0, canvas=canvas)
|
|
187
|
+
+ measure_emoji_at(canvas, _match_condition(self.weather))
|
|
188
|
+
+ get_text_width(self.font, temp_text, padding=0, canvas=canvas)
|
|
189
|
+
)
|
|
190
|
+
else:
|
|
191
|
+
condition_text = f"{self.weather} "
|
|
192
|
+
content_width = get_text_width(
|
|
193
|
+
self.font,
|
|
194
|
+
f"{label_text}{condition_text}{temp_text}",
|
|
195
|
+
padding=0,
|
|
196
|
+
canvas=canvas,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
cursor_pos, end_padding = compute_cursor(
|
|
200
|
+
canvas.width,
|
|
201
|
+
content_width,
|
|
202
|
+
cursor_pos,
|
|
203
|
+
self.padding,
|
|
204
|
+
center=self.center,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
baseline_y = compute_baseline(self.font, canvas, valign="center") + y_offset
|
|
208
|
+
|
|
209
|
+
cursor_pos += self._draw_segment(
|
|
210
|
+
canvas,
|
|
211
|
+
cursor_pos,
|
|
212
|
+
baseline_y,
|
|
213
|
+
self.font_color,
|
|
214
|
+
label_text,
|
|
215
|
+
frame_count=self.frame_for("font_color"),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if self.show_icon:
|
|
219
|
+
# Bottom-anchor the condition icon at the text baseline (exact at
|
|
220
|
+
# any scale via draw_emoji_at's real-pixel bottom-anchor).
|
|
221
|
+
cursor_pos += draw_emoji_at(
|
|
222
|
+
canvas,
|
|
223
|
+
_match_condition(self.weather),
|
|
224
|
+
int(cursor_pos),
|
|
225
|
+
bottom_baseline=baseline_y,
|
|
226
|
+
)
|
|
227
|
+
else:
|
|
228
|
+
cursor_pos += self._draw_segment(
|
|
229
|
+
canvas,
|
|
230
|
+
cursor_pos,
|
|
231
|
+
baseline_y,
|
|
232
|
+
self.font_color,
|
|
233
|
+
f"{self.weather} ",
|
|
234
|
+
frame_count=self.frame_for("font_color"),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
cursor_pos += self._draw_segment(
|
|
238
|
+
canvas,
|
|
239
|
+
cursor_pos,
|
|
240
|
+
baseline_y,
|
|
241
|
+
self.font_color_temp,
|
|
242
|
+
temp_text,
|
|
243
|
+
frame_count=self.frame_for("font_color_temp"),
|
|
244
|
+
)
|
|
245
|
+
cursor_pos += end_padding
|
|
246
|
+
|
|
247
|
+
return canvas, cursor_pos
|
|
248
|
+
|
|
249
|
+
def _draw_segment(
|
|
250
|
+
self,
|
|
251
|
+
canvas: Canvas,
|
|
252
|
+
x: int,
|
|
253
|
+
baseline_y: int,
|
|
254
|
+
provider: ColorProvider,
|
|
255
|
+
text: str,
|
|
256
|
+
frame_count: int,
|
|
257
|
+
) -> int:
|
|
258
|
+
"""Render one weather text segment (label / condition / temp).
|
|
259
|
+
|
|
260
|
+
Per-char providers (rainbow / gradient) iterate chars via
|
|
261
|
+
`draw_text_per_char` so each char renders with its own hue.
|
|
262
|
+
Whole-string providers (constant / color_cycle / random)
|
|
263
|
+
materialize once and use `draw_text`. Mirrors the per-char
|
|
264
|
+
dispatch in `TickerCountdown.draw` and image widgets'
|
|
265
|
+
`_draw_text` — without it, `font_color = "rainbow"` on
|
|
266
|
+
weather collapsed the label / condition / temp to a single
|
|
267
|
+
sweeping hue.
|
|
268
|
+
"""
|
|
269
|
+
if provider.per_char:
|
|
270
|
+
return draw_text_per_char(
|
|
271
|
+
canvas,
|
|
272
|
+
self.font,
|
|
273
|
+
x,
|
|
274
|
+
baseline_y,
|
|
275
|
+
text,
|
|
276
|
+
lambda idx, total: provider.color_for(frame_count, idx, total),
|
|
277
|
+
)
|
|
278
|
+
color = provider.color_for(frame_count, 0, len(text) if text else 1)
|
|
279
|
+
# plugin draw_text signature: (canvas, font, text, x, y, color) → absolute x.
|
|
280
|
+
# Subtract starting x to return relative advance width.
|
|
281
|
+
return draw_text(canvas, self.font, text, x, baseline_y, color) - x
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Shared test fixtures for the led-ticker-weather plugin test suite.
|
|
2
|
+
|
|
3
|
+
The rgbmatrix stub is on the pytest path via ``pythonpath`` in
|
|
4
|
+
``pyproject.toml`` (``../led-ticker/tests/stubs``). The plugin doesn't ship
|
|
5
|
+
core's conftest, so re-provide the small fixtures the ported tests use.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import unittest.mock as mock
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def canvas():
|
|
15
|
+
"""Mock LED canvas with standard width and height."""
|
|
16
|
+
c = mock.Mock()
|
|
17
|
+
c.width = 160
|
|
18
|
+
c.height = 16
|
|
19
|
+
return c
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def make_widget():
|
|
24
|
+
"""Factory for mock widgets with configurable draw width."""
|
|
25
|
+
|
|
26
|
+
def _factory(content_width=40):
|
|
27
|
+
widget = mock.Mock()
|
|
28
|
+
widget.hold_time = 0.0
|
|
29
|
+
widget.draw.side_effect = lambda c, cursor_pos=0, **kw: (
|
|
30
|
+
c,
|
|
31
|
+
cursor_pos + content_width,
|
|
32
|
+
)
|
|
33
|
+
return widget
|
|
34
|
+
|
|
35
|
+
return _factory
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import pathlib
|
|
3
|
+
|
|
4
|
+
SRC = pathlib.Path(__file__).resolve().parents[1] / "src" / "led_ticker_weather"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _led_ticker_imports(path):
|
|
8
|
+
tree = ast.parse(path.read_text(), filename=str(path))
|
|
9
|
+
names = []
|
|
10
|
+
for node in ast.walk(tree):
|
|
11
|
+
if isinstance(node, ast.ImportFrom) and node.module:
|
|
12
|
+
if node.module.split(".")[0] == "led_ticker":
|
|
13
|
+
names.append(node.module)
|
|
14
|
+
elif isinstance(node, ast.Import):
|
|
15
|
+
for alias in node.names:
|
|
16
|
+
if alias.name.split(".")[0] == "led_ticker":
|
|
17
|
+
names.append(alias.name)
|
|
18
|
+
return names
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_plugin_imports_only_public_surface():
|
|
22
|
+
offenders = {}
|
|
23
|
+
for py in SRC.rglob("*.py"):
|
|
24
|
+
bad = [m for m in _led_ticker_imports(py) if m != "led_ticker.plugin"]
|
|
25
|
+
if bad:
|
|
26
|
+
offenders[py.name] = bad
|
|
27
|
+
assert not offenders, (
|
|
28
|
+
f"modules import led_ticker internals instead of led_ticker.plugin: {offenders}"
|
|
29
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from led_ticker import _plugin_loader as L
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_entry_point_registers_weather_namespace():
|
|
5
|
+
L.reset_plugins()
|
|
6
|
+
try:
|
|
7
|
+
result = L.load_plugins(None, entry_points_enabled=True)
|
|
8
|
+
loaded = {info.namespace for info in result.loaded}
|
|
9
|
+
assert "weather" in loaded, f"weather plugin not discovered: {result}"
|
|
10
|
+
|
|
11
|
+
from led_ticker.widgets import get_widget_class
|
|
12
|
+
|
|
13
|
+
assert get_widget_class("weather.current") is not None
|
|
14
|
+
finally:
|
|
15
|
+
L.reset_plugins()
|