led-ticker-crypto 0.2.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,7 @@
1
+ """led-ticker-crypto: crypto-price widgets for led-ticker (CoinGecko)."""
2
+
3
+ from led_ticker_crypto.coingecko import CoinGeckoMonitor
4
+
5
+
6
+ def register(api):
7
+ api.widget("coingecko")(CoinGeckoMonitor)
@@ -0,0 +1,26 @@
1
+ """Trend colors for crypto widgets (ported from led-ticker core crypto/_colors.py).
2
+
3
+ Positive / negative / neutral price movement. Constructed lazily via PEP 562
4
+ `__getattr__` (same pattern as core), so importing this module is a no-op
5
+ against the rgbmatrix graphics library.
6
+ """
7
+
8
+ from typing import TYPE_CHECKING
9
+
10
+ from led_ticker.plugin import colors
11
+
12
+ if TYPE_CHECKING:
13
+ from led_ticker.plugin import Color
14
+
15
+
16
+ _trend_palette = colors.lazy_palette(
17
+ {
18
+ "UP_TREND_COLOR": (46, 200, 46),
19
+ "DOWN_TREND_COLOR": (194, 24, 7),
20
+ "NEUTRAL_TREND_COLOR": (180, 180, 180),
21
+ }
22
+ )
23
+
24
+
25
+ def __getattr__(name: str) -> Color:
26
+ return _trend_palette(name)
@@ -0,0 +1,142 @@
1
+ """Shared price-ticker renderer for crypto widgets.
2
+
3
+ Ported from led-ticker core's `widgets/crypto/coinbase._draw_price_ticker`
4
+ (coinbase was removed from core; the renderer travels with the plugin).
5
+ Lives as a shared module so a future `crypto.coinbase` could reuse it.
6
+
7
+ Adapted to the public `led_ticker.plugin` surface: the public `draw_text`
8
+ returns the ABSOLUTE next-x (and routes inline emoji), so core's
9
+ `cursor_pos += draw_text(canvas, font, x, y, color, text)` became
10
+ `cursor_pos = draw_text(canvas, font, text, x, y, color)` — pixel-identical
11
+ for plain text (proven by the led-ticker-baseball migration).
12
+ """
13
+
14
+ import math
15
+
16
+ from led_ticker.plugin import (
17
+ FONT_DEFAULT,
18
+ FONT_SMALL,
19
+ Canvas,
20
+ Color,
21
+ ColorProvider,
22
+ ColorProviderBase,
23
+ DrawResult,
24
+ Font,
25
+ compute_baseline,
26
+ compute_cursor,
27
+ draw_text,
28
+ get_text_width,
29
+ make_color,
30
+ resolve_font,
31
+ )
32
+
33
+ from led_ticker_crypto._colors import (
34
+ DOWN_TREND_COLOR,
35
+ NEUTRAL_TREND_COLOR,
36
+ UP_TREND_COLOR,
37
+ )
38
+
39
+ # Core FONT_VALUE/FONT_VALUE_SMALL == 6x12/5x8 (FONT_DEFAULT/FONT_SMALL);
40
+ # FONT_LABEL/FONT_DELTA are the general 7x13/6x10 BDF faces.
41
+ FONT_LABEL: Font = resolve_font("7x13")
42
+ FONT_VALUE: Font = FONT_DEFAULT
43
+ FONT_VALUE_SMALL: Font = FONT_SMALL
44
+ FONT_DELTA: Font = resolve_font("6x10")
45
+
46
+
47
+ class _ConstantColor(ColorProviderBase):
48
+ """Wraps a single Color so a plain `font_color = [r,g,b]` routes through
49
+ the same `color_for` interface as effects. (Core's _ConstantColor is private.)"""
50
+
51
+ per_char: bool = False
52
+ frame_invariant: bool = True
53
+
54
+ def __init__(self, color: Color) -> None:
55
+ self._color = color
56
+
57
+ def color_for(self, frame: int, char_index: int, total_chars: int) -> Color:
58
+ return self._color
59
+
60
+
61
+ def make_default_font_color() -> ColorProvider:
62
+ """Core's default font_color: DEFAULT_COLOR == (255, 255, 0) (yellow)."""
63
+ return _ConstantColor(make_color(255, 255, 0))
64
+
65
+
66
+ def _get_change_color(change_str: str) -> Color:
67
+ try:
68
+ value = float(change_str.rstrip("%"))
69
+ except ValueError, AttributeError:
70
+ return NEUTRAL_TREND_COLOR
71
+ if value < 0:
72
+ return DOWN_TREND_COLOR
73
+ if value > 0:
74
+ return UP_TREND_COLOR
75
+ return NEUTRAL_TREND_COLOR
76
+
77
+
78
+ def _get_price_font(price_str: str) -> Font:
79
+ if len(price_str) > 10:
80
+ return FONT_VALUE_SMALL
81
+ return FONT_VALUE
82
+
83
+
84
+ def _format_price(value: float) -> str:
85
+ """Format a price with adaptive precision.
86
+
87
+ Normal coins (>= 1) keep the historical 4-decimal, thousands-separated form.
88
+ Sub-dollar coins get extra decimals so cheap tokens (e.g. SHIB ~4.6e-06)
89
+ don't collapse to "0.0000".
90
+ """
91
+ if value >= 1 or value == 0:
92
+ return f"{value:,.4f}"
93
+ decimals = min(12, max(4, 3 - int(math.floor(math.log10(abs(value))))))
94
+ return f"{value:.{decimals}f}"
95
+
96
+
97
+ def draw_price_ticker(
98
+ canvas: Canvas,
99
+ symbol: str,
100
+ price_str: str,
101
+ change_str: str,
102
+ cursor_pos: int = 0,
103
+ center: bool = True,
104
+ padding: int = 6,
105
+ end_padding: int = 6,
106
+ y_offset: int = 0,
107
+ font_color: ColorProvider | None = None,
108
+ frame_count: int = 0,
109
+ ) -> DrawResult:
110
+ change_color = _get_change_color(change_str)
111
+ font_price = _get_price_font(price_str)
112
+ label_color = (
113
+ font_color.color_for(frame_count, 0, 1)
114
+ if font_color is not None
115
+ else make_color(255, 255, 0)
116
+ )
117
+
118
+ content_width = (
119
+ get_text_width(FONT_LABEL, symbol, padding=6, canvas=canvas)
120
+ + get_text_width(font_price, price_str, padding=6, canvas=canvas)
121
+ + get_text_width(FONT_DELTA, change_str, padding=0, canvas=canvas)
122
+ )
123
+
124
+ cursor_pos, end_padding = compute_cursor(
125
+ canvas.width, content_width, cursor_pos, end_padding, center=center
126
+ )
127
+
128
+ baseline_y = compute_baseline(FONT_LABEL, canvas, valign="center") + y_offset
129
+ cursor_pos = draw_text(
130
+ canvas, FONT_LABEL, symbol, cursor_pos, baseline_y, label_color
131
+ )
132
+ cursor_pos += padding
133
+ cursor_pos = draw_text(
134
+ canvas, font_price, price_str, cursor_pos, baseline_y, label_color
135
+ )
136
+ cursor_pos += padding
137
+ cursor_pos = draw_text(
138
+ canvas, FONT_DELTA, change_str, cursor_pos, baseline_y, change_color
139
+ )
140
+ cursor_pos += end_padding
141
+
142
+ return canvas, cursor_pos
@@ -0,0 +1,338 @@
1
+ """CoinGecko price monitor widget (crypto.coingecko).
2
+
3
+ `CoinGeckoMonitor` is a Container: it cycles one `_CoinTicker` "story" per
4
+ configured coin (each reusing the pixel-validated `draw_price_ticker`). The
5
+ container does ONE batched `/simple/price` fetch per update and routes each
6
+ coin's price into its story. Symbols can be auto-resolved to CoinGecko ids
7
+ (unique-or-error), and an optional demo API key raises the free-tier limit.
8
+ """
9
+
10
+ import logging
11
+ import os
12
+ from typing import Any, Self
13
+
14
+ import aiohttp
15
+ import attrs
16
+ from led_ticker.plugin import (
17
+ Canvas,
18
+ Color,
19
+ ColorProvider,
20
+ DrawResult,
21
+ FrameAwareBase,
22
+ run_monitor_loop,
23
+ spawn_tracked,
24
+ )
25
+
26
+ from led_ticker_crypto._ticker_render import (
27
+ _ConstantColor,
28
+ _format_price,
29
+ draw_price_ticker,
30
+ make_default_font_color,
31
+ )
32
+
33
+ COINGECKO_API: str = "https://api.coingecko.com/api/v3"
34
+ COINGECKO_COIN_LIST: str = f"{COINGECKO_API}/coins/list"
35
+ COINGECKO_PRICE_API: str = f"{COINGECKO_API}/simple/price"
36
+
37
+
38
+ @attrs.define
39
+ class _CoinTicker(FrameAwareBase):
40
+ """One coin's price line — a Container "story" drawn via draw_price_ticker."""
41
+
42
+ symbol: str
43
+ center: bool = True
44
+ padding: int = 6
45
+ hold_time: float = 0.0
46
+ bg_color: Color | None = attrs.field(default=None, kw_only=True)
47
+ font_color: Color | ColorProvider = attrs.field(default=None, kw_only=True)
48
+ price_data: dict[str, str] = attrs.field(
49
+ init=False,
50
+ factory=lambda: {"price": "0.0000", "change_24h": "0.00%"},
51
+ )
52
+
53
+ def __attrs_post_init__(self) -> None:
54
+ if self.font_color is None:
55
+ self.font_color = make_default_font_color()
56
+ elif not hasattr(self.font_color, "color_for"):
57
+ self.font_color = _ConstantColor(self.font_color)
58
+
59
+ def draw(
60
+ self,
61
+ canvas: Canvas,
62
+ cursor_pos: int = 0,
63
+ *,
64
+ y_offset: int = 0,
65
+ font_color: Any = None,
66
+ ) -> DrawResult:
67
+ return draw_price_ticker(
68
+ canvas,
69
+ self.symbol,
70
+ self.price_data["price"],
71
+ self.price_data["change_24h"],
72
+ cursor_pos=cursor_pos,
73
+ center=self.center,
74
+ padding=self.padding,
75
+ end_padding=self.padding,
76
+ y_offset=y_offset,
77
+ font_color=self.font_color,
78
+ frame_count=self.frame_for("font_color"),
79
+ )
80
+
81
+
82
+ def _resolve_symbols(
83
+ symbols: list[str], coin_list: list[dict[str, Any]]
84
+ ) -> list[tuple[str, str]]:
85
+ """Resolve display symbols to CoinGecko ids (unique-or-error).
86
+
87
+ For each symbol (case-insensitive on `coin_meta["symbol"]`):
88
+ exactly one match → `(symbol.upper(), id)`; zero → ValueError;
89
+ multiple → ValueError listing all candidate ids and telling the user
90
+ to set `symbol_id`/`symbol_ids` explicitly. Input order preserved.
91
+ """
92
+ resolved: list[tuple[str, str]] = []
93
+ for symbol in symbols:
94
+ matches = [
95
+ coin_meta["id"]
96
+ for coin_meta in coin_list
97
+ if coin_meta["symbol"].lower() == symbol.lower()
98
+ ]
99
+ if len(matches) == 1:
100
+ resolved.append((symbol.upper(), matches[0]))
101
+ elif not matches:
102
+ raise ValueError(f"symbol {symbol!r} not found on CoinGecko")
103
+ else:
104
+ candidates = ", ".join(matches)
105
+ raise ValueError(
106
+ f"symbol {symbol!r} is ambiguous on CoinGecko "
107
+ f"(candidate ids: {candidates}); set symbol_id or symbol_ids "
108
+ f"explicitly to disambiguate"
109
+ )
110
+ return resolved
111
+
112
+
113
+ def _build_coins(
114
+ symbol: str | None,
115
+ symbol_id: str | None,
116
+ symbols: list[str] | None,
117
+ symbol_ids: list[str] | None,
118
+ coin_list: list[dict[str, Any]] | None,
119
+ ) -> list[tuple[str, str]]:
120
+ """Assemble an ordered, de-duplicated `(display_symbol, coin_id)` list.
121
+
122
+ Order: legacy `symbol`+`symbol_id` → each `symbol_ids` entry →
123
+ `symbols` (auto-resolved). De-duplicate by coin_id (keep first).
124
+ Raise if the result is empty.
125
+ """
126
+ coins: list[tuple[str, str]] = []
127
+ if symbol and symbol_id:
128
+ coins.append((symbol.upper(), symbol_id))
129
+ for cid in symbol_ids or []:
130
+ coins.append((cid.upper(), cid))
131
+ if symbols:
132
+ coins.extend(_resolve_symbols(symbols, coin_list or []))
133
+
134
+ seen: set[str] = set()
135
+ deduped: list[tuple[str, str]] = []
136
+ for display, cid in coins:
137
+ if cid in seen:
138
+ continue
139
+ seen.add(cid)
140
+ deduped.append((display, cid))
141
+
142
+ if not deduped:
143
+ raise ValueError(
144
+ "crypto.coingecko: specify at least one of symbol/symbol_id, "
145
+ "symbol_ids, or symbols"
146
+ )
147
+ return deduped
148
+
149
+
150
+ @attrs.define
151
+ class CoinGeckoMonitor:
152
+ """Crypto price Container cycling one _CoinTicker per coin (CoinGecko API)."""
153
+
154
+ coins: list[tuple[str, str]]
155
+ currency: str
156
+ session: aiohttp.ClientSession
157
+ center: bool = True
158
+ padding: int = 6
159
+ hold_time: float = 0.0
160
+ bg_color: Color | None = attrs.field(default=None, kw_only=True)
161
+ font_color: Color | ColorProvider = attrs.field(default=None, kw_only=True)
162
+ api_key: str = attrs.field(
163
+ factory=lambda: os.getenv("COINGECKO_API_KEY", ""), kw_only=True
164
+ )
165
+ feed_title: None = attrs.field(init=False, default=None)
166
+ feed_stories: list[_CoinTicker] = attrs.field(init=False, factory=list)
167
+ _story_by_id: dict = attrs.field(init=False, factory=dict)
168
+
169
+ def __attrs_post_init__(self) -> None:
170
+ self.feed_stories = [
171
+ _CoinTicker(
172
+ symbol=display,
173
+ center=self.center,
174
+ padding=self.padding,
175
+ hold_time=self.hold_time,
176
+ bg_color=self.bg_color,
177
+ font_color=self.font_color,
178
+ )
179
+ for display, _ in self.coins
180
+ ]
181
+ self._story_by_id = {
182
+ cid: story
183
+ for (_, cid), story in zip(self.coins, self.feed_stories, strict=True)
184
+ }
185
+
186
+ @classmethod
187
+ def validate_config(cls, cfg: dict[str, Any]) -> list[str]:
188
+ """Pre-coercion config check, run by the engine via validate_widget_cfg.
189
+
190
+ Returns message strings (does NOT raise); the engine turns any returned
191
+ messages into a pre-flight ValueError.
192
+ """
193
+ msgs: list[str] = []
194
+
195
+ has_legacy = bool(cfg.get("symbol") or cfg.get("symbol_id"))
196
+ has_symbols = bool(cfg.get("symbols"))
197
+ has_symbol_ids = bool(cfg.get("symbol_ids"))
198
+
199
+ if not (has_legacy or has_symbols or has_symbol_ids):
200
+ msgs.append(
201
+ "crypto.coingecko: specify at least one coin via "
202
+ "symbol+symbol_id, symbol_ids, or symbols"
203
+ )
204
+ return msgs
205
+
206
+ if has_symbols:
207
+ symbols = cfg["symbols"]
208
+ if not (
209
+ isinstance(symbols, list)
210
+ and all(isinstance(s, str) and s for s in symbols)
211
+ ):
212
+ msgs.append(
213
+ "crypto.coingecko: symbols must be a non-empty list of strings"
214
+ )
215
+
216
+ if has_symbol_ids:
217
+ symbol_ids = cfg["symbol_ids"]
218
+ if not (
219
+ isinstance(symbol_ids, list)
220
+ and all(isinstance(s, str) and s for s in symbol_ids)
221
+ ):
222
+ msgs.append(
223
+ "crypto.coingecko: symbol_ids must be a non-empty list of strings"
224
+ )
225
+
226
+ return msgs
227
+
228
+ def _headers(self) -> dict[str, str]:
229
+ return {"x-cg-demo-api-key": self.api_key} if self.api_key else {}
230
+
231
+ @classmethod
232
+ async def start(
233
+ cls,
234
+ *,
235
+ currency: str = "USD",
236
+ symbol: str | None = None,
237
+ symbol_id: str | None = None,
238
+ symbols: list[str] | None = None,
239
+ symbol_ids: list[str] | None = None,
240
+ session: aiohttp.ClientSession,
241
+ update_interval: int = 300,
242
+ **kwargs: Any,
243
+ ) -> Self:
244
+ api_key = os.getenv("COINGECKO_API_KEY", "")
245
+ headers = {"x-cg-demo-api-key": api_key} if api_key else {}
246
+ coin_list = (
247
+ await _get_coingecko_coin_list(session, headers=headers)
248
+ if symbols
249
+ else None
250
+ )
251
+ coins = _build_coins(symbol, symbol_id, symbols, symbol_ids, coin_list)
252
+
253
+ valid = {f.name for f in attrs.fields(cls)}
254
+ widget = cls(
255
+ coins=coins,
256
+ currency=currency,
257
+ session=session,
258
+ # api_key never comes from config — env (COINGECKO_API_KEY) only
259
+ **{k: v for k, v in kwargs.items() if k in valid and k != "api_key"},
260
+ )
261
+ # Tolerate a failed INITIAL price fetch (e.g. a CoinGecko 429 at boot)
262
+ # so the widget still constructs and the monitor loop can recover, rather
263
+ # than the whole widget being skipped for the session. The broad except
264
+ # also swallows a coding bug in update() — that surfaces only as a
265
+ # repeating warning log + frozen placeholder data, which is the
266
+ # acceptable tradeoff for "a data fetch must never crash startup".
267
+ try:
268
+ await widget.update()
269
+ except Exception as e:
270
+ logging.warning(
271
+ "crypto.coingecko initial fetch failed (%s); "
272
+ "starting with placeholder data, will retry",
273
+ e,
274
+ )
275
+ spawn_tracked(run_monitor_loop(widget, update_interval))
276
+ return widget
277
+
278
+ async def update(self) -> None:
279
+ # MULTIPLE ids MUST be comma-joined in ONE string — passing a list
280
+ # makes aiohttp emit ids=a&ids=b, which CoinGecko rejects for >1 id.
281
+ ids = ",".join(coin_id for _, coin_id in self.coins)
282
+ params: dict[str, Any] = {
283
+ "ids": ids,
284
+ "vs_currencies": self.currency,
285
+ "include_24hr_change": "true",
286
+ }
287
+ async with self.session.get(
288
+ COINGECKO_PRICE_API, params=params, headers=self._headers()
289
+ ) as response:
290
+ if response.status != 200:
291
+ retry = response.headers.get("retry-after")
292
+ suffix = f" (retry-after {retry})" if retry else ""
293
+ logging.warning(
294
+ "CoinGecko price fetch failed: HTTP %s%s", response.status, suffix
295
+ )
296
+ # Raise so run_monitor_loop's backoff engages; never parse an
297
+ # error body as prices.
298
+ response.raise_for_status()
299
+
300
+ data = await response.json()
301
+
302
+ cur = self.currency.lower()
303
+ cur_change = f"{cur}_24h_change"
304
+
305
+ updated = 0
306
+ for coin_id, story in self._story_by_id.items():
307
+ entry = data.get(coin_id)
308
+ if not entry or cur not in entry or cur_change not in entry:
309
+ logging.warning(
310
+ "CoinGecko: no price for %s (keeping prior value): %s",
311
+ coin_id,
312
+ entry,
313
+ )
314
+ continue
315
+ story.price_data = {
316
+ "price": _format_price(entry[cur]),
317
+ "change_24h": f"{entry[cur_change]:.2f}%",
318
+ }
319
+ updated += 1
320
+
321
+ # One INFO line per successful update (Container contract): a silent
322
+ # log stream after startup signals the background task died.
323
+ logging.info(
324
+ "CoinGecko updated: %s/%s coins (%s)",
325
+ updated,
326
+ len(self.coins),
327
+ ", ".join(coin_id for _, coin_id in self.coins),
328
+ )
329
+
330
+
331
+ async def _get_coingecko_coin_list(
332
+ session: aiohttp.ClientSession,
333
+ headers: dict[str, str] | None = None,
334
+ ) -> list[dict[str, Any]]:
335
+ logging.info("Fetching CoinGecko coin list...")
336
+ req_headers = {"Accept": "application/json", **(headers or {})}
337
+ async with session.get(COINGECKO_COIN_LIST, headers=req_headers) as response:
338
+ return await response.json()
@@ -0,0 +1,167 @@
1
+ Metadata-Version: 2.4
2
+ Name: led-ticker-crypto
3
+ Version: 0.2.0
4
+ Summary: Crypto-price widgets for led-ticker (CoinGecko).
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-crypto
29
+
30
+ A cryptocurrency price ticker **plugin** for [led-ticker](https://github.com/JamesAwesome/led-ticker), backed by the free CoinGecko v3 API. It contributes a `crypto.coingecko` **Container widget** that cycles one scrolling price line per configured coin — configure one coin for a single ticker, or a list to cycle through several.
31
+
32
+ Each line shows the coin symbol, current price (adaptive precision — sub-dollar tokens never collapse to `0.0000`), and 24-hour percent change. The change value is trend-colored: green for positive, red for negative, gray for neutral — readable at a glance on any panel.
33
+
34
+ ![crypto.coingecko cycling BTC, ETH, and SHIB (note the sub-cent SHIB price)](docs/crypto-coingecko.gif)
35
+
36
+ ## Prerequisites
37
+
38
+ - A working [led-ticker](https://github.com/JamesAwesome/led-ticker) install.
39
+ - Internet access on the Pi (the widget calls the CoinGecko public API).
40
+
41
+ ## Install
42
+
43
+ The widget auto-registers via the `led_ticker.plugins` entry point — once the package is installed, no `[plugins]` config change is needed.
44
+
45
+ **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:
46
+
47
+ ```bash
48
+ # in your led-ticker checkout
49
+ cp config/requirements-plugins.example.txt config/requirements-plugins.txt
50
+ docker compose up -d --build
51
+ ```
52
+
53
+ That example file lists every first-party plugin — trim the live copy to just the ones you want. The crypto line is:
54
+
55
+ ```text
56
+ git+https://github.com/JamesAwesome/led-ticker-crypto.git@main
57
+ ```
58
+
59
+ **Standalone (a venv that already has led-ticker):**
60
+
61
+ ```bash
62
+ pip install "git+https://github.com/JamesAwesome/led-ticker-crypto.git@main"
63
+ ```
64
+
65
+ 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.
66
+
67
+ ## Configuration
68
+
69
+ Reference the widget in a playlist section by `type = "crypto.coingecko"`. Three coin-spec styles are supported — choose the one that fits your workflow:
70
+
71
+ ### Single coin (legacy style)
72
+
73
+ ```toml
74
+ [[playlist.section.widget]]
75
+ type = "crypto.coingecko"
76
+ symbol = "BTC"
77
+ symbol_id = "bitcoin"
78
+ currency = "USD"
79
+ ```
80
+
81
+ ### Multiple coins by explicit CoinGecko id
82
+
83
+ ```toml
84
+ [[playlist.section.widget]]
85
+ type = "crypto.coingecko"
86
+ symbol_ids = ["bitcoin", "ethereum", "dogecoin"]
87
+ currency = "USD"
88
+ ```
89
+
90
+ Each id in `symbol_ids` is also used as the on-panel label (uppercased). Use `symbol_ids` when you know the exact CoinGecko ids and want to skip the startup lookup.
91
+
92
+ ### Multiple coins by ticker symbol (auto-resolved)
93
+
94
+ ```toml
95
+ [[playlist.section.widget]]
96
+ type = "crypto.coingecko"
97
+ symbols = ["BTC", "ETH", "SOL"]
98
+ currency = "USD"
99
+ ```
100
+
101
+ `symbols` are resolved at startup against CoinGecko's `/coins/list` endpoint. Resolution is **unique-or-error**: if a symbol matches more than one CoinGecko id, the widget fails at startup and lists the candidate ids — use `symbol_id` or `symbol_ids` to disambiguate.
102
+
103
+ You can combine all three styles in one widget; duplicates (by coin id) are silently dropped, keeping the first occurrence.
104
+
105
+ New to led-ticker configs? The [first-config tutorial](https://docs.ledticker.dev/tutorial/02-first-config/) walks through the overall structure — the blocks above show just the crypto-specific keys.
106
+
107
+ ### Finding `symbol_id`
108
+
109
+ `symbol_id` is CoinGecko's internal coin identifier (e.g. `"bitcoin"`, `"ethereum"`, `"solana"`, `"dogecoin"`). It's the `id` field in CoinGecko's `/coins/list` endpoint and appears in a coin's page URL: `coingecko.com/en/coins/<id>`.
110
+
111
+ Use `symbol_id` (or `symbol_ids`) when:
112
+ - You want to skip the startup `/coins/list` lookup that `symbols` triggers.
113
+ - A symbol is ambiguous — the startup error lists the candidates and tells you which ids to use.
114
+
115
+ ### Options
116
+
117
+ | Option | Type | Default | Description |
118
+ |--------|------|---------|-------------|
119
+ | `symbol` | string | — | Single-coin label shown on the panel (e.g. `"BTC"`). Requires `symbol_id`. |
120
+ | `symbol_id` | string | — | Single-coin CoinGecko id (e.g. `"bitcoin"`). Requires `symbol`. |
121
+ | `symbols` | list of strings | — | Ticker symbols auto-resolved to CoinGecko ids at startup (e.g. `["BTC", "ETH"]`). Unique-or-error. |
122
+ | `symbol_ids` | list of strings | — | CoinGecko ids used directly, uppercased as panel labels (e.g. `["bitcoin", "ethereum"]`). |
123
+ | `currency` | string | `"USD"` | Fiat currency code (e.g. `"USD"`, `"EUR"`). |
124
+ | `update_interval` | int | `300` | Seconds between CoinGecko fetches (5 min default). |
125
+ | `center` | bool | `true` | Center the ticker on the canvas when it fits; scroll when it overflows. |
126
+ | `padding` | int | `6` | Horizontal spacing (logical px) between the symbol, price, and change segments. |
127
+ | `hold_time` | float | `0.0` | Seconds to hold each coin's ticker before the engine cycles to the next. |
128
+ | `bg_color` | `[r,g,b]` | none | Background fill behind the ticker. |
129
+ | `font_color` | `[r,g,b]` / string / table | yellow `(255,255,0)` | Color for the symbol and price. Accepts any led-ticker color provider (e.g. `"rainbow"`, `{style="shimmer", ...}`). The 24h change color is always trend-colored and ignores this field. |
130
+
131
+ At least one of `symbol`+`symbol_id`, `symbols`, or `symbol_ids` must be specified — the widget fails at config validation otherwise.
132
+
133
+ ### Rate limits & API key
134
+
135
+ CoinGecko's keyless free tier allows roughly **5 requests per minute**. For a single low-frequency widget at the default 5-minute interval this is plenty; if you add more coins or shorten `update_interval`, you may hit HTTP 429. When that happens the widget logs the rate-limit clearly and lets its monitor loop back off — it will not show stale garbage.
136
+
137
+ To raise the limit, create a free account at [coingecko.com](https://www.coingecko.com) and get a **Demo API key** (no credit card required). Supply it via the `COINGECKO_API_KEY` environment variable (the only supported path — config-file secrets are intentionally not accepted):
138
+
139
+ ```bash
140
+ export COINGECKO_API_KEY="CG-xxxxxxxxxxxxxxxxxxxx"
141
+ ```
142
+
143
+ The key is sent as the `x-cg-demo-api-key` request header.
144
+
145
+ ## Development
146
+
147
+ led-ticker isn't on PyPI, so this plugin resolves it from a sibling checkout. Clone both side by side:
148
+
149
+ ```
150
+ ~/projects/.../led-ticker
151
+ ~/projects/.../led-ticker-crypto
152
+ ```
153
+
154
+ ```bash
155
+ uv sync --extra dev # resolves led-ticker from ../led-ticker
156
+ uv run pytest -q
157
+ uv run ruff check src tests
158
+ ```
159
+
160
+ > **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"]`.
161
+
162
+ The plugin imports only the public `led_ticker.plugin` surface — `tests/test_import_purity.py` enforces it.
163
+
164
+ ## Links
165
+
166
+ - [led-ticker](https://github.com/JamesAwesome/led-ticker) — the core project
167
+ - [Docs site](https://docs.ledticker.dev) · [Plugin system](https://docs.ledticker.dev/plugins/)
@@ -0,0 +1,9 @@
1
+ led_ticker_crypto/__init__.py,sha256=zd9J-B80Bdjna32x0WturDdGRGvzSPxp0HlO23vqkZE,199
2
+ led_ticker_crypto/_colors.py,sha256=Z-72XdJXaQNsnNcIoM-2kavFDEal7K1nWUDK8QJaPfA,667
3
+ led_ticker_crypto/_ticker_render.py,sha256=9zL2uUznLssJajmraM3Z-Cr3_HPS2cfUc-Efmr8AYZw,4278
4
+ led_ticker_crypto/coingecko.py,sha256=M0TXp-npsJnAMf3LGj4lLD4oH6utlMli5AWFKiJyylE,11851
5
+ led_ticker_crypto-0.2.0.dist-info/METADATA,sha256=ahvHkwH4nXcuNvDHJlElyFhgQ8Ke7a_w5K88YKUB4aQ,8195
6
+ led_ticker_crypto-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
7
+ led_ticker_crypto-0.2.0.dist-info/entry_points.txt,sha256=QtJHpnvsFUV4aZVTnvwvlT88uoug1be1bKe8CP4AJGE,57
8
+ led_ticker_crypto-0.2.0.dist-info/licenses/LICENSE,sha256=kFECdLBfsSUUTLoH4nlAu-gQPyXoHbWMZDg8p2DGAP4,1070
9
+ led_ticker_crypto-0.2.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
+ crypto = led_ticker_crypto: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.