led-ticker 0.3.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/__init__.py +83 -0
- led_ticker/auth.py +41 -0
- led_ticker/cli.py +451 -0
- led_ticker/client.py +261 -0
- led_ticker/errors.py +38 -0
- led_ticker/protocol.py +210 -0
- led_ticker-0.3.0.dist-info/METADATA +136 -0
- led_ticker-0.3.0.dist-info/RECORD +11 -0
- led_ticker-0.3.0.dist-info/WHEEL +4 -0
- led_ticker-0.3.0.dist-info/entry_points.txt +2 -0
- led_ticker-0.3.0.dist-info/licenses/LICENSE +202 -0
led_ticker/__init__.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""led_ticker — Python client for the LED-Ticker BLE device."""
|
|
2
|
+
from .client import (
|
|
3
|
+
LedTicker,
|
|
4
|
+
cancel_timer,
|
|
5
|
+
scan,
|
|
6
|
+
clear_status,
|
|
7
|
+
get_apikey,
|
|
8
|
+
get_display,
|
|
9
|
+
get_locations,
|
|
10
|
+
get_mode,
|
|
11
|
+
get_power,
|
|
12
|
+
get_status,
|
|
13
|
+
get_tickers,
|
|
14
|
+
get_timezone,
|
|
15
|
+
get_version,
|
|
16
|
+
get_wifi,
|
|
17
|
+
reload,
|
|
18
|
+
reset,
|
|
19
|
+
set_apikey,
|
|
20
|
+
set_brightness,
|
|
21
|
+
set_display,
|
|
22
|
+
set_locations,
|
|
23
|
+
set_mode,
|
|
24
|
+
set_pin_enforce,
|
|
25
|
+
set_power,
|
|
26
|
+
set_scroll_speed,
|
|
27
|
+
set_status,
|
|
28
|
+
set_tickers,
|
|
29
|
+
set_timer,
|
|
30
|
+
set_timezone,
|
|
31
|
+
set_wifi,
|
|
32
|
+
)
|
|
33
|
+
from .errors import (
|
|
34
|
+
AmbiguousDeviceError,
|
|
35
|
+
AuthError,
|
|
36
|
+
DeviceNotFoundError,
|
|
37
|
+
LedTickerError,
|
|
38
|
+
ProtocolError,
|
|
39
|
+
ValidationError,
|
|
40
|
+
)
|
|
41
|
+
from .protocol import DeviceInfo, Display, Status
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"AmbiguousDeviceError",
|
|
45
|
+
"AuthError",
|
|
46
|
+
"DeviceInfo",
|
|
47
|
+
"DeviceNotFoundError",
|
|
48
|
+
"Display",
|
|
49
|
+
"LedTicker",
|
|
50
|
+
"LedTickerError",
|
|
51
|
+
"ProtocolError",
|
|
52
|
+
"scan",
|
|
53
|
+
"Status",
|
|
54
|
+
"ValidationError",
|
|
55
|
+
# one-shot helpers
|
|
56
|
+
"cancel_timer",
|
|
57
|
+
"clear_status",
|
|
58
|
+
"get_apikey",
|
|
59
|
+
"get_display",
|
|
60
|
+
"get_locations",
|
|
61
|
+
"get_mode",
|
|
62
|
+
"get_power",
|
|
63
|
+
"get_status",
|
|
64
|
+
"get_tickers",
|
|
65
|
+
"get_timezone",
|
|
66
|
+
"get_version",
|
|
67
|
+
"get_wifi",
|
|
68
|
+
"reload",
|
|
69
|
+
"reset",
|
|
70
|
+
"set_apikey",
|
|
71
|
+
"set_brightness",
|
|
72
|
+
"set_display",
|
|
73
|
+
"set_locations",
|
|
74
|
+
"set_mode",
|
|
75
|
+
"set_pin_enforce",
|
|
76
|
+
"set_power",
|
|
77
|
+
"set_scroll_speed",
|
|
78
|
+
"set_status",
|
|
79
|
+
"set_tickers",
|
|
80
|
+
"set_timer",
|
|
81
|
+
"set_timezone",
|
|
82
|
+
"set_wifi",
|
|
83
|
+
]
|
led_ticker/auth.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""PIN resolution and the local PIN cache.
|
|
2
|
+
|
|
3
|
+
Resolution order: explicit override -> LED_TICKER_PIN env var -> cache file.
|
|
4
|
+
The cache path is injectable so tests never touch a real home directory.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import pathlib
|
|
10
|
+
|
|
11
|
+
DEFAULT_PIN_PATH = pathlib.Path.home() / ".config" / "led-ticker" / "pin"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def resolve_pin(override: str | None = None, *, path: pathlib.Path = DEFAULT_PIN_PATH) -> str | None:
|
|
15
|
+
if override:
|
|
16
|
+
return override.strip()
|
|
17
|
+
env = os.environ.get("LED_TICKER_PIN")
|
|
18
|
+
if env:
|
|
19
|
+
return env.strip()
|
|
20
|
+
if path.exists():
|
|
21
|
+
return path.read_text().strip() or None
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def saved_pin(*, path: pathlib.Path = DEFAULT_PIN_PATH) -> str | None:
|
|
26
|
+
if path.exists():
|
|
27
|
+
return path.read_text().strip() or None
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def save_pin(pin: str, *, path: pathlib.Path = DEFAULT_PIN_PATH) -> None:
|
|
32
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
path.write_text(pin + "\n")
|
|
34
|
+
path.chmod(0o600)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def clear_pin(*, path: pathlib.Path = DEFAULT_PIN_PATH) -> bool:
|
|
38
|
+
if path.exists():
|
|
39
|
+
path.unlink()
|
|
40
|
+
return True
|
|
41
|
+
return False
|
led_ticker/cli.py
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""Command-line front end. Parses argv, calls the led_ticker library, and
|
|
2
|
+
translates library exceptions into the historical messages and exit codes.
|
|
3
|
+
This module is the ONLY place that prints or sets exit status."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import auth
|
|
9
|
+
from . import protocol as P
|
|
10
|
+
from .client import LedTicker, scan
|
|
11
|
+
from .errors import AmbiguousDeviceError, AuthError, DeviceNotFoundError, ProtocolError, ValidationError
|
|
12
|
+
|
|
13
|
+
GET_KEYS = (
|
|
14
|
+
"wifi", "apikey", "tickers", "status", "locations",
|
|
15
|
+
"mode", "version", "power", "display", "timezone",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# -- display formatters (presentation only) --------------------------------
|
|
20
|
+
def _fmt_status(s) -> str:
|
|
21
|
+
if s is None:
|
|
22
|
+
return "(no active status)"
|
|
23
|
+
if s.indefinite:
|
|
24
|
+
return f"{s.text} (indefinite)"
|
|
25
|
+
m, sec = divmod(s.seconds, 60)
|
|
26
|
+
return f"{s.text} ({m}m {sec}s remaining)"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _fmt_display(d) -> str:
|
|
30
|
+
if d is None:
|
|
31
|
+
return "(unknown — pre-Display firmware?)"
|
|
32
|
+
return f"brightness {d.brightness}/15, scroll {d.scroll_ms} ms/step"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _format_get(key: str, d: LedTicker) -> str:
|
|
36
|
+
if key == "status":
|
|
37
|
+
return _fmt_status(d.get_status())
|
|
38
|
+
if key == "display":
|
|
39
|
+
return _fmt_display(d.get_display())
|
|
40
|
+
if key == "tickers":
|
|
41
|
+
return ",".join(d.get_tickers()) or "(none)"
|
|
42
|
+
if key == "locations":
|
|
43
|
+
locs = d.get_locations()
|
|
44
|
+
return "\n".join(f" {i + 1}. {loc}" for i, loc in enumerate(locs)) or "(none)"
|
|
45
|
+
if key == "wifi":
|
|
46
|
+
return d.get_wifi() or "(not set)"
|
|
47
|
+
if key == "apikey":
|
|
48
|
+
return d.get_apikey() or "(not set)"
|
|
49
|
+
if key == "mode":
|
|
50
|
+
return d.get_mode() or "(unknown)"
|
|
51
|
+
if key == "power":
|
|
52
|
+
return d.get_power() or "(unknown)"
|
|
53
|
+
if key == "version":
|
|
54
|
+
return d.get_version() or "(unknown — pre-0.1.0 firmware?)"
|
|
55
|
+
if key == "timezone":
|
|
56
|
+
return d.get_timezone() or "(unknown — pre-Timezone firmware?)"
|
|
57
|
+
raise AssertionError(key) # guarded by caller
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# -- subcommands -----------------------------------------------------------
|
|
61
|
+
# Each returns an int exit code (0 = success) and may print to stdout/stderr.
|
|
62
|
+
def cmd_tickers(args, pin, device):
|
|
63
|
+
if not args:
|
|
64
|
+
print("Usage: led.py tickers TICKER [TICKER ...]")
|
|
65
|
+
return 1
|
|
66
|
+
# validate before connecting
|
|
67
|
+
payload = P.encode_tickers(args)
|
|
68
|
+
with LedTicker(pin=pin, select=device) as d:
|
|
69
|
+
d.set_tickers(args)
|
|
70
|
+
print("Sent: " + payload.decode())
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cmd_status(args, pin, device):
|
|
75
|
+
if not args:
|
|
76
|
+
with LedTicker(pin=pin, select=device) as d:
|
|
77
|
+
print(_fmt_status(d.get_status()))
|
|
78
|
+
return 0
|
|
79
|
+
if args[0] == "clear":
|
|
80
|
+
with LedTicker(pin=pin, select=device) as d:
|
|
81
|
+
d.clear_status()
|
|
82
|
+
print("Sent: (clear)")
|
|
83
|
+
return 0
|
|
84
|
+
text = args[0]
|
|
85
|
+
minutes = 0
|
|
86
|
+
if len(args) >= 2:
|
|
87
|
+
try:
|
|
88
|
+
minutes = int(args[1])
|
|
89
|
+
except ValueError:
|
|
90
|
+
print(f"ERROR: duration must be an integer number of minutes, got '{args[1]}'")
|
|
91
|
+
return 1
|
|
92
|
+
# validate before connecting
|
|
93
|
+
payload = P.encode_status(text, minutes)
|
|
94
|
+
with LedTicker(pin=pin, select=device) as d:
|
|
95
|
+
d.set_status(text, minutes)
|
|
96
|
+
print("Sent: " + payload.decode())
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def cmd_timer(args, pin, device):
|
|
101
|
+
if not args:
|
|
102
|
+
print("Usage: led.py timer <minutes 1-99 | cancel>")
|
|
103
|
+
return 1
|
|
104
|
+
arg = args[0].strip().lower()
|
|
105
|
+
if arg == "cancel":
|
|
106
|
+
with LedTicker(pin=pin, select=device) as d:
|
|
107
|
+
d.cancel_timer()
|
|
108
|
+
print("Sent: timer cancel")
|
|
109
|
+
return 0
|
|
110
|
+
try:
|
|
111
|
+
mins = int(arg)
|
|
112
|
+
except ValueError:
|
|
113
|
+
print("ERROR: minutes must be an integer 1-99 (or 'cancel')")
|
|
114
|
+
return 1
|
|
115
|
+
# validate before connecting
|
|
116
|
+
P.validate_timer_minutes(mins)
|
|
117
|
+
with LedTicker(pin=pin, select=device) as d:
|
|
118
|
+
d.set_timer(mins)
|
|
119
|
+
print(f"Sent: timer {mins}")
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def cmd_locations(args, pin, device):
|
|
124
|
+
if not args:
|
|
125
|
+
print('Usage: led.py locations "LAT,LON,LABEL" ...')
|
|
126
|
+
print(' e.g. led.py locations "47.61,-122.33,Seattle"')
|
|
127
|
+
print(" (look up coordinates at e.g. latlong.net)")
|
|
128
|
+
return 1
|
|
129
|
+
# validate before connecting
|
|
130
|
+
payload = P.encode_locations(args)
|
|
131
|
+
with LedTicker(pin=pin, select=device) as d:
|
|
132
|
+
d.set_locations(args)
|
|
133
|
+
print("Sent: " + payload.decode())
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cmd_mode(args, pin, device):
|
|
138
|
+
if not args:
|
|
139
|
+
print("Usage: led.py mode all | none | <category> [<category> ...]")
|
|
140
|
+
print(" where <category> is one of: stocks, weather, clock")
|
|
141
|
+
print(" 'none' = sign-only (idle pixel between signs)")
|
|
142
|
+
return 1
|
|
143
|
+
# validate before connecting
|
|
144
|
+
payload = P.encode_mode(args)
|
|
145
|
+
with LedTicker(pin=pin, select=device) as d:
|
|
146
|
+
d.set_mode(args)
|
|
147
|
+
print("Sent: " + payload.decode())
|
|
148
|
+
return 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def cmd_power(args, pin, device):
|
|
152
|
+
if not args or args[0] not in ("on", "off"):
|
|
153
|
+
print("Usage: led.py power on | off")
|
|
154
|
+
return 1
|
|
155
|
+
with LedTicker(pin=pin, select=device) as d:
|
|
156
|
+
d.set_power(args[0] == "on")
|
|
157
|
+
print(f"Sent: {args[0]}")
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def cmd_display(args, pin, device):
|
|
162
|
+
if not args:
|
|
163
|
+
with LedTicker(pin=pin, select=device) as d:
|
|
164
|
+
print(_fmt_display(d.get_display()))
|
|
165
|
+
return 0
|
|
166
|
+
try:
|
|
167
|
+
if args[0] == "brightness" and len(args) == 2:
|
|
168
|
+
b = int(args[1])
|
|
169
|
+
# validate before connecting
|
|
170
|
+
if b not in P.BRIGHTNESS_RANGE:
|
|
171
|
+
raise ValidationError(f"brightness must be {P.BRIGHTNESS_RANGE.start}-{P.BRIGHTNESS_RANGE.stop - 1}, got {b}")
|
|
172
|
+
with LedTicker(pin=pin, select=device) as d:
|
|
173
|
+
d.set_brightness(b)
|
|
174
|
+
elif args[0] == "speed" and len(args) == 2:
|
|
175
|
+
s = int(args[1])
|
|
176
|
+
# validate before connecting
|
|
177
|
+
if s not in P.SCROLL_MS_RANGE:
|
|
178
|
+
raise ValidationError(f"scroll speed must be {P.SCROLL_MS_RANGE.start}-{P.SCROLL_MS_RANGE.stop - 1}, got {s}")
|
|
179
|
+
with LedTicker(pin=pin, select=device) as d:
|
|
180
|
+
d.set_scroll_speed(s)
|
|
181
|
+
elif len(args) == 2:
|
|
182
|
+
b, s = int(args[0]), int(args[1])
|
|
183
|
+
# validate before connecting
|
|
184
|
+
P.encode_display(b, s)
|
|
185
|
+
with LedTicker(pin=pin, select=device) as d:
|
|
186
|
+
d.set_display(b, s)
|
|
187
|
+
else:
|
|
188
|
+
print("Usage: led.py display show current settings")
|
|
189
|
+
print(" led.py display brightness <0-15> set brightness")
|
|
190
|
+
print(" led.py display speed <20-500> set scroll ms/step (lower = faster)")
|
|
191
|
+
print(" led.py display <0-15> <20-500> set both")
|
|
192
|
+
return 1
|
|
193
|
+
except ValueError:
|
|
194
|
+
print("ERROR: display values must be integers")
|
|
195
|
+
return 1
|
|
196
|
+
print("Sent.")
|
|
197
|
+
return 0
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def cmd_timezone(args, pin, device):
|
|
201
|
+
if not args:
|
|
202
|
+
with LedTicker(pin=pin, select=device) as d:
|
|
203
|
+
print(d.get_timezone() or "(unknown — pre-Timezone firmware?)")
|
|
204
|
+
return 0
|
|
205
|
+
# validate before connecting
|
|
206
|
+
P.validate_timezone(args[0])
|
|
207
|
+
with LedTicker(pin=pin, select=device) as d:
|
|
208
|
+
d.set_timezone(args[0])
|
|
209
|
+
print(f"Sent: {args[0].strip()}")
|
|
210
|
+
return 0
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def cmd_apikey(args, pin, device):
|
|
214
|
+
if not args:
|
|
215
|
+
print("Usage: led.py apikey KEY")
|
|
216
|
+
return 1
|
|
217
|
+
with LedTicker(pin=pin, select=device) as d:
|
|
218
|
+
d.set_apikey(args[0])
|
|
219
|
+
print("Sent.")
|
|
220
|
+
return 0
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def cmd_wifi(args, pin, device):
|
|
224
|
+
if len(args) < 2:
|
|
225
|
+
print("Usage: led.py wifi SSID PASSWORD")
|
|
226
|
+
return 1
|
|
227
|
+
ssid = " ".join(args[:-1])
|
|
228
|
+
password = args[-1]
|
|
229
|
+
# validate before connecting
|
|
230
|
+
P.encode_wifi(ssid, password)
|
|
231
|
+
with LedTicker(pin=pin, select=device) as d:
|
|
232
|
+
d.set_wifi(ssid, password)
|
|
233
|
+
print("Sent.")
|
|
234
|
+
return 0
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def cmd_get(args, pin, device):
|
|
238
|
+
if not args or args[0] not in GET_KEYS:
|
|
239
|
+
print(f"Usage: led.py get {'|'.join(GET_KEYS)}")
|
|
240
|
+
return 1
|
|
241
|
+
with LedTicker(pin=pin, select=device) as d:
|
|
242
|
+
print(_format_get(args[0], d))
|
|
243
|
+
return 0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def cmd_reload(args, pin, device):
|
|
247
|
+
with LedTicker(pin=pin, select=device) as d:
|
|
248
|
+
d.reload()
|
|
249
|
+
print("Sent: reload")
|
|
250
|
+
return 0
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def cmd_reset(args, pin, device):
|
|
254
|
+
with LedTicker(pin=pin, select=device) as d:
|
|
255
|
+
confirm = input("Reset all NVS data to config.h defaults (also rotates PIN)? [y/N] ")
|
|
256
|
+
if confirm.strip().lower() != "y":
|
|
257
|
+
print("Aborted.")
|
|
258
|
+
return 0
|
|
259
|
+
d.reset()
|
|
260
|
+
print("Sent: reset")
|
|
261
|
+
return 0
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def cmd_pin(args, pin, device):
|
|
265
|
+
# Local-only PIN cache management; never touches the device.
|
|
266
|
+
if not args:
|
|
267
|
+
saved = auth.saved_pin(path=auth.DEFAULT_PIN_PATH)
|
|
268
|
+
if saved:
|
|
269
|
+
print(f"Saved PIN: {saved} (path: {auth.DEFAULT_PIN_PATH})")
|
|
270
|
+
else:
|
|
271
|
+
print(f"No PIN saved at {auth.DEFAULT_PIN_PATH}")
|
|
272
|
+
return 0
|
|
273
|
+
if args[0] == "clear":
|
|
274
|
+
if auth.clear_pin(path=auth.DEFAULT_PIN_PATH):
|
|
275
|
+
print(f"Cleared saved PIN ({auth.DEFAULT_PIN_PATH})")
|
|
276
|
+
else:
|
|
277
|
+
print("No PIN was saved.")
|
|
278
|
+
return 0
|
|
279
|
+
code = P.validate_pin(args[0])
|
|
280
|
+
auth.save_pin(code, path=auth.DEFAULT_PIN_PATH)
|
|
281
|
+
print(f"Saved PIN to {auth.DEFAULT_PIN_PATH} (future calls will include it automatically)")
|
|
282
|
+
return 0
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def cmd_pin_enforce(args, pin, device):
|
|
286
|
+
if not args or args[0] not in ("on", "off"):
|
|
287
|
+
print("Usage: led.py pin-enforce on | off")
|
|
288
|
+
print(" on — device requires PIN auth for every write")
|
|
289
|
+
print(" off — device accepts writes from anyone (default)")
|
|
290
|
+
return 1
|
|
291
|
+
with LedTicker(pin=pin, select=device) as d:
|
|
292
|
+
d.set_pin_enforce(args[0] == "on")
|
|
293
|
+
print(f"Sent: pin-enforce {args[0]}")
|
|
294
|
+
return 0
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def cmd_devices(args, pin, device):
|
|
298
|
+
infos = scan()
|
|
299
|
+
if not infos:
|
|
300
|
+
print("No LED-Ticker devices found.")
|
|
301
|
+
return 0
|
|
302
|
+
print(f"Found {len(infos)} LED-Ticker device(s):")
|
|
303
|
+
for i, info in enumerate(infos, 1):
|
|
304
|
+
rssi = f"{info.rssi} dBm" if info.rssi is not None else "?"
|
|
305
|
+
print(f" {i}. {info.name} {info.address} {rssi}")
|
|
306
|
+
return 0
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
COMMANDS = {
|
|
310
|
+
"devices": cmd_devices,
|
|
311
|
+
"tickers": cmd_tickers,
|
|
312
|
+
"status": cmd_status,
|
|
313
|
+
"timer": cmd_timer,
|
|
314
|
+
"locations": cmd_locations,
|
|
315
|
+
"mode": cmd_mode,
|
|
316
|
+
"power": cmd_power,
|
|
317
|
+
"display": cmd_display,
|
|
318
|
+
"timezone": cmd_timezone,
|
|
319
|
+
"apikey": cmd_apikey,
|
|
320
|
+
"wifi": cmd_wifi,
|
|
321
|
+
"get": cmd_get,
|
|
322
|
+
"reload": cmd_reload,
|
|
323
|
+
"reset": cmd_reset,
|
|
324
|
+
"pin": cmd_pin,
|
|
325
|
+
"pin-enforce": cmd_pin_enforce,
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _print_help():
|
|
330
|
+
print("Usage: led.py [--pin XXXXXX] [--device XXXX] <command> [args...]")
|
|
331
|
+
print()
|
|
332
|
+
print(" devices list LED-Tickers in range (name, address, signal)")
|
|
333
|
+
print(" tickers AAPL MSFT GOOGL set stock symbols and reload quotes")
|
|
334
|
+
print(" status [TEXT [MINUTES] | clear] set / clear the active sign (0 min = indefinite)")
|
|
335
|
+
print(" timer <minutes 1-99 | cancel> start/cancel a countdown timer on the LED")
|
|
336
|
+
print(" locations 'LAT,LON,LABEL' ... set weather locations (look up lat/lon online)")
|
|
337
|
+
print(" mode all | <cat> [<cat> ...] switch display mode (cat: stocks|weather|clock)")
|
|
338
|
+
print(" power on | off turn display on or off (volatile)")
|
|
339
|
+
print(" display [brightness 0-15 | speed 20-500 | B MS] show / set brightness & scroll speed")
|
|
340
|
+
print(" timezone [POSIX_TZ] show / set clock timezone")
|
|
341
|
+
print(" apikey KEY set Finnhub API key")
|
|
342
|
+
print(" wifi SSID PASSWORD update WiFi credentials and reconnect")
|
|
343
|
+
print(f" get {'|'.join(GET_KEYS)} read a setting")
|
|
344
|
+
print(" reload force immediate stock refresh")
|
|
345
|
+
print(" reset clear NVS and revert to defaults (rotates PIN)")
|
|
346
|
+
print(" pin [DIGITS | clear] save / show / clear local PIN cache")
|
|
347
|
+
print(" pin-enforce on | off toggle device-side PIN enforcement")
|
|
348
|
+
print()
|
|
349
|
+
print(" --device XXXX targets one unit by name suffix, full name, or address (see 'devices')")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _print_auth_error(e: AuthError) -> None:
|
|
353
|
+
if e.pin_present:
|
|
354
|
+
print(
|
|
355
|
+
f"ERROR: device rejected saved PIN ({auth.DEFAULT_PIN_PATH}). The PIN was likely\n"
|
|
356
|
+
" rotated by a factory reset. Read the new PIN off the LED in\n"
|
|
357
|
+
" setup mode (or from the serial monitor) and run:\n"
|
|
358
|
+
" led.py pin <new-6-digits>",
|
|
359
|
+
file=sys.stderr,
|
|
360
|
+
)
|
|
361
|
+
else:
|
|
362
|
+
print(
|
|
363
|
+
"ERROR: device has PIN enforcement on and no PIN is configured\n"
|
|
364
|
+
" client-side. Run: led.py pin <6-digits> (PIN scrolls on the\n"
|
|
365
|
+
" LED in setup mode, or appears on the serial monitor at boot).",
|
|
366
|
+
file=sys.stderr,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _interactive() -> bool:
|
|
371
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _print_candidates(candidates) -> None:
|
|
375
|
+
print(
|
|
376
|
+
"ERROR: multiple LED-Tickers in range; re-run with --device <suffix|address>:",
|
|
377
|
+
file=sys.stderr,
|
|
378
|
+
)
|
|
379
|
+
for i, c in enumerate(candidates, 1):
|
|
380
|
+
rssi = f"{c.rssi} dBm" if c.rssi is not None else "?"
|
|
381
|
+
print(f" {i}. {c.name} {c.address} {rssi}", file=sys.stderr)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _prompt_device(candidates):
|
|
385
|
+
print("Multiple LED-Tickers found:")
|
|
386
|
+
for i, c in enumerate(candidates, 1):
|
|
387
|
+
rssi = f"{c.rssi} dBm" if c.rssi is not None else "?"
|
|
388
|
+
print(f" {i}. {c.name} {rssi}")
|
|
389
|
+
try:
|
|
390
|
+
raw = input(f"Which device? [1-{len(candidates)}]: ").strip()
|
|
391
|
+
except EOFError:
|
|
392
|
+
print("ERROR: no selection made")
|
|
393
|
+
return None
|
|
394
|
+
if not raw.isdigit() or not (1 <= int(raw) <= len(candidates)):
|
|
395
|
+
print(f"ERROR: invalid selection '{raw}'")
|
|
396
|
+
return None
|
|
397
|
+
return candidates[int(raw) - 1]
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _run_handler(handler, args, pin, device) -> int:
|
|
401
|
+
"""Run a handler, mapping library errors (except ambiguity) to messages + codes."""
|
|
402
|
+
try:
|
|
403
|
+
return handler(args, pin, device)
|
|
404
|
+
except ValidationError as e:
|
|
405
|
+
print(f"ERROR: {e}")
|
|
406
|
+
return 1
|
|
407
|
+
except AuthError as e:
|
|
408
|
+
_print_auth_error(e)
|
|
409
|
+
return 2
|
|
410
|
+
except DeviceNotFoundError as e:
|
|
411
|
+
print(f"ERROR: {e}. Is it powered on and in range?")
|
|
412
|
+
return 1
|
|
413
|
+
except ProtocolError as e:
|
|
414
|
+
print(f"ERROR: {e}")
|
|
415
|
+
return 1
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def main(argv: list[str] | None = None) -> int:
|
|
419
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
420
|
+
pin = None
|
|
421
|
+
device = None
|
|
422
|
+
while argv and argv[0] in ("--pin", "--device"):
|
|
423
|
+
flag = argv[0]
|
|
424
|
+
if len(argv) < 2:
|
|
425
|
+
print(f"ERROR: {flag} requires a value")
|
|
426
|
+
return 1
|
|
427
|
+
value = argv[1].strip()
|
|
428
|
+
if flag == "--pin":
|
|
429
|
+
pin = value
|
|
430
|
+
else:
|
|
431
|
+
device = value
|
|
432
|
+
argv = argv[2:]
|
|
433
|
+
if not argv or argv[0] not in COMMANDS:
|
|
434
|
+
_print_help()
|
|
435
|
+
return 1
|
|
436
|
+
handler = COMMANDS[argv[0]]
|
|
437
|
+
args = argv[1:]
|
|
438
|
+
try:
|
|
439
|
+
return _run_handler(handler, args, pin, device)
|
|
440
|
+
except AmbiguousDeviceError as e:
|
|
441
|
+
if _interactive():
|
|
442
|
+
chosen = _prompt_device(e.candidates)
|
|
443
|
+
if chosen is None:
|
|
444
|
+
return 1
|
|
445
|
+
return _run_handler(handler, args, pin, chosen.address)
|
|
446
|
+
_print_candidates(e.candidates)
|
|
447
|
+
return 1
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
if __name__ == "__main__":
|
|
451
|
+
sys.exit(main())
|