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.
- led_ticker_crypto/__init__.py +7 -0
- led_ticker_crypto/_colors.py +26 -0
- led_ticker_crypto/_ticker_render.py +142 -0
- led_ticker_crypto/coingecko.py +338 -0
- led_ticker_crypto-0.2.0.dist-info/METADATA +167 -0
- led_ticker_crypto-0.2.0.dist-info/RECORD +9 -0
- led_ticker_crypto-0.2.0.dist-info/WHEEL +4 -0
- led_ticker_crypto-0.2.0.dist-info/entry_points.txt +2 -0
- led_ticker_crypto-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+

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