Skip to content

Commit f481d84

Browse files
author
lee
committed
Add Wi-Fi indicator dot; sync dot now tracks sync freshness
The upper-left indicator dot shows Wi-Fi link status (PICLOCK_WIFI_DOT, default on; PICLOCK_WIFI_IFACE=wlan0), read every tick straight from /sys/class/net/<iface>/operstate — one in-process file read (~140us on a Zero 2 W), no subprocess. A missing interface (e.g. a Wi-Fi firmware crash took wlan0 away) reads as down. The sync dot no longer keys on NTPSynchronized alone: on current systemd that is the kernel's maxerror estimate and lags an NTP outage by ~9 hours. The dot now also requires timesyncd's stamp file (/run/systemd/timesync/synchronized, touched on every successful sync) to be fresher than PICLOCK_SYNC_FRESH (default 4500s, ~2x timesyncd's max poll interval), so it darkens within about an hour of syncs stopping — including the case where Wi-Fi is fine but the NTP server is down. Dashes-until-first-sync and free-running behavior still key on NTPSynchronized; without the stamp file (dev machine, non-timesyncd) the dot falls back to the old behavior. Verified logic-only (pytest + a null-display loop run); sysfs read cost and stamp-file touch behavior measured on real Zero 2 W hardware.
1 parent 48c4321 commit f481d84

11 files changed

Lines changed: 277 additions & 38 deletions

File tree

‎AGENTS.md‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,19 +60,32 @@ hardware-touching part is isolated:
6060
a bedside clock — no movement at night); an optional 1 Hz blink is config-gated and derived
6161
from the wall clock, not a tick counter, so it can't drift.
6262
- [piclock/display.py](piclock/display.py) — the HT16K33 `smbus2` driver, plus a pure
63-
`build_buffer()` (font/colon/sync-dot → 16-byte RAM; the NTP sync indicator is the
64-
backpack's lower-left dot, sharing the colon's RAM byte) and a `NullDisplay` for off-Pi
63+
`build_buffer()` (font/colon/indicator dots → 16-byte RAM; the NTP sync indicator is the
64+
backpack's lower-left dot and the Wi-Fi indicator the upper-left dot, both sharing the
65+
colon's RAM byte) and a `NullDisplay` for off-Pi
6566
runs. The
6667
driver skips redundant I2C writes when nothing changed. The hardware is wrapped in
6768
`ResilientDisplay`: if the display is absent or a connector is bumped, `show()` never
6869
raises — it logs once and retries the connection every ~10 s, so the service keeps running
6970
(no crash-loop) and the correct time appears the instant the display reconnects.
7071
- [piclock/timesync.py](piclock/timesync.py) — `is_ntp_synced()` via `timedatectl show -p
7172
NTPSynchronized`. Any error → treated as not-synced (pre-first-sync that means dashes —
72-
never a bad time; post-sync it just darkens the sync dot). The loop polls it every 5 s
73-
until the first sync, then every 60 s to track sync loss.
73+
never a bad time). The loop polls it every 5 s until the first sync, then every 60 s.
74+
Because NTPSynchronized is the kernel's maxerror estimate (it lags an NTP outage by
75+
~9 h), the *sync dot* additionally requires freshness: `last_sync_age()` stats
76+
`/run/systemd/timesync/synchronized` (touched by timesyncd on every successful sync) and
77+
the dot goes dark once the last success is older than `sync_fresh_seconds` (default
78+
4500 s ≈ 2× timesyncd's max poll interval). No stamp file (dev machine, non-timesyncd
79+
system) → freshness is assumed, falling back to NTPSynchronized alone. Sync loss never
80+
brings the dashes back — that stays keyed on `is_ntp_synced()`/`ever_synced`.
81+
- [piclock/wifi.py](piclock/wifi.py) — `is_wifi_up()` reads
82+
`/sys/class/net/<iface>/operstate` (an in-process ~140 µs file read, no subprocess, so
83+
the loop polls it every tick). It feeds the **upper-left** indicator dot: lit while the
84+
link is up, dark when the link drops or the interface disappears (e.g. firmware crash).
85+
Any read error → down. `PICLOCK_WIFI_DOT` / `PICLOCK_WIFI_IFACE` configure it.
7486
- [piclock/config.py](piclock/config.py) — `Config` dataclass with `PICLOCK_*` env overrides
75-
(brightness, schedule on/off + times + wake/night brightness, 24h, blink, sync dot, I2C
87+
(brightness, schedule on/off + times + wake/night brightness, 24h, blink, sync dot +
88+
freshness window, wifi dot + interface, I2C
7689
bus/address, tick, and `PICLOCK_DISPLAY=null` for dev). Validates its inputs at construction, including
7790
that the schedule edges are in daily order.
7891
- [piclock/schedule.py](piclock/schedule.py) — **pure** day/night schedule logic: wall-clock time →

‎README.md‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,13 @@ The Pi has no battery-backed clock, so correctness comes from two things:
2424
Python's `zoneinfo` applies DST rules automatically. No offset math, ever.
2525

2626
Before the clock has synced after a cold boot, the display shows `----` rather than a
27-
confidently wrong time. Once it has synced, the backpack's lower-left indicator dot stays
28-
lit as a sync indicator; if NTP is later lost the time keeps showing (the system clock
29-
free-runs and stays approximately right) and only the dot goes dark. (Sync-indicator idea
30-
borrowed from [tenox7/rpiclock](https://github.com/tenox7/rpiclock).)
27+
confidently wrong time. After that, the backpack's two left-edge indicator dots report
28+
health at a glance: the **upper-left dot** is Wi-Fi (lit while the link is up, checked
29+
every tick straight from sysfs) and the **lower-left dot** is NTP (lit while syncs keep
30+
arriving — dark within about an hour if they stop, e.g. the NTP server is unreachable).
31+
If sync is lost the time keeps showing (the system clock free-runs and stays
32+
approximately right); only the dots go dark. (Sync-indicator idea borrowed from
33+
[tenox7/rpiclock](https://github.com/tenox7/rpiclock).)
3134

3235
## Hardware
3336

@@ -123,7 +126,10 @@ Set via environment variables (typically in the systemd unit):
123126
| `PICLOCK_WAKE_BRIGHTNESS` | `15` | Level the schedule's evening wake applies, `0`–`15` |
124127
| `PICLOCK_NIGHT_BRIGHTNESS` | `0` | Level the schedule's night dim applies, `0`–`15` (`0` is still lit) |
125128
| `PICLOCK_BLINK` | `0` | Blink the colon at 1 Hz (default: steady, no movement at night) |
126-
| `PICLOCK_SYNC_DOT` | `1` | Lower-left dot lit while NTP-synced, dark when free-running; `0` disables the dot |
129+
| `PICLOCK_SYNC_DOT` | `1` | Lower-left dot lit while NTP syncs keep arriving, dark when free-running; `0` disables the dot |
130+
| `PICLOCK_SYNC_FRESH` | `4500` | Seconds since the last successful sync before the sync dot goes dark (~2× timesyncd's max poll interval) |
131+
| `PICLOCK_WIFI_DOT` | `1` | Upper-left dot lit while the Wi-Fi link is up, dark when it drops; `0` disables the dot |
132+
| `PICLOCK_WIFI_IFACE` | `wlan0` | Interface the Wi-Fi dot watches |
127133
| `PICLOCK_I2C_BUS` | `1` | I2C bus number |
128134
| `PICLOCK_I2C_ADDRESS` | `0x70` | HT16K33 address |
129135
| `PICLOCK_TICK` | `0.2` | Loop period, seconds |

‎piclock/app.py‎

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import time
1313
from datetime import datetime
1414

15-
from . import clock, ctl, schedule, timesync
15+
from . import clock, ctl, schedule, timesync, wifi
1616
from .config import Config
1717
from .control import DisplayControl
1818
from .display import make_display
@@ -57,6 +57,12 @@ def _handle_signal(signum, _frame):
5757
# keeps the (free-running, still approximately right) time on the display
5858
# and only the sync dot goes dark.
5959
ever_synced = False
60+
# Whether syncs are still *arriving* (timesyncd's stamp file is recent).
61+
# Distinct from `synced`, which is the kernel's is-the-clock-trustworthy
62+
# estimate and lags an outage by ~9 hours: `synced` gates the dashes and
63+
# the free-run warning, freshness only gates the sync dot. Starts True so
64+
# the dot can't flicker before the first check.
65+
sync_fresh = True
6066

6167
# Display on/off + brightness state, shared by everything that controls the
6268
# display. The optional day/night schedule (evaluated below, in-process)
@@ -103,8 +109,12 @@ def _handle_signal(signum, _frame):
103109
try:
104110
while not stop.is_set():
105111
if time.monotonic() >= next_sync_check:
106-
was_synced = synced
112+
was_synced, was_fresh = synced, sync_fresh
107113
synced = timesync.is_ntp_synced()
114+
age = timesync.last_sync_age()
115+
# No stamp file (no timesyncd, e.g. dev machine) -> fall back
116+
# to NTPSynchronized alone rather than darkening the dot.
117+
sync_fresh = age is None or age <= config.sync_fresh_seconds
108118
next_sync_check = time.monotonic() + (
109119
_SYNCED_POLL_SECONDS if synced else _SYNC_POLL_SECONDS
110120
)
@@ -113,6 +123,12 @@ def _handle_signal(signum, _frame):
113123
log.info("system clock synchronized")
114124
elif was_synced and not synced:
115125
log.warning("NTP sync lost; free-running until it returns")
126+
if was_fresh and not sync_fresh:
127+
log.warning(
128+
"no successful NTP sync for %.0fs; sync dot dark", age
129+
)
130+
elif sync_fresh and not was_fresh:
131+
log.info("NTP syncs arriving again")
116132

117133
now = datetime.now()
118134

@@ -171,15 +187,21 @@ def _handle_signal(signum, _frame):
171187
blink=config.blink_colon,
172188
)
173189
text, colon = state.text, state.colon
174-
sync_dot = state.sync_dot and config.sync_dot
190+
sync_dot = state.sync_dot and config.sync_dot and sync_fresh
191+
# Wi-Fi status is not a function of the time, so it joins here
192+
# rather than passing through render(). Polled per tick: it is
193+
# one sysfs read (~140 us), and per-second freshness is the
194+
# point of the indicator.
195+
wifi_dot = config.wifi_dot and wifi.is_wifi_up(config.wifi_iface)
175196
else:
176-
text, colon, sync_dot = _BLANK_TEXT, False, False # display off
197+
# display off
198+
text, colon, sync_dot, wifi_dot = _BLANK_TEXT, False, False, False
177199

178200
# The display backend is fault-tolerant (see make_display): show()
179201
# never raises on I/O errors, it logs and reconnects on its own. So a
180202
# missing or bumped display can't crash the loop.
181203
display.set_brightness(control.brightness) # deduped; cheap per-tick
182-
display.show(text, colon, sync_dot)
204+
display.show(text, colon, sync_dot, wifi_dot)
183205

184206
stop.wait(config.tick_seconds)
185207
finally:

‎piclock/config.py‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,23 @@ class Config:
8080
hour24: bool = True
8181
blink_colon: bool = False # steady colon by default — no movement for a dark bedroom
8282
# The backpack's lower-left indicator dot shows NTP sync status: lit while
83-
# the system clock is synced, dark while it free-runs. It dims with the
84-
# rest of the display; switch it off if even that is too much at night.
83+
# the system clock is synced *and* syncs are still arriving, dark while it
84+
# free-runs. It dims with the rest of the display; switch it off if even
85+
# that is too much at night.
8586
sync_dot: bool = True
87+
# How recently (seconds) timesyncd must have completed a successful sync
88+
# for the sync dot to stay lit. Default is ~2x timesyncd's max poll
89+
# interval (34 min) plus margin, so one dropped poll can't flicker the
90+
# dot. Checked via a stat() of timesyncd's stamp file — see
91+
# piclock/timesync.py; on systems without the stamp the dot falls back to
92+
# NTPSynchronized alone.
93+
sync_fresh_seconds: float = 4500.0
94+
# The backpack's upper-left indicator dot shows Wi-Fi link status: lit
95+
# while the interface is up and associated, dark when the link drops (or
96+
# the interface disappears entirely). Read from sysfs every tick — an
97+
# in-process file read, no subprocess.
98+
wifi_dot: bool = True
99+
wifi_iface: str = "wlan0"
86100
tick_seconds: float = 0.2 # loop granularity; colon blink derives from wall clock
87101
backend: str = "ht16k33" # "ht16k33" for hardware, "null" for off-Pi dev/testing
88102

@@ -119,6 +133,15 @@ def __post_init__(self) -> None:
119133
level = getattr(self, name)
120134
if not 0 <= level <= 15:
121135
raise ValueError(f"{name} must be 0-15, got {level!r}")
136+
# A non-positive freshness window would permanently darken the sync
137+
# dot, and an empty interface name would permanently darken the wifi
138+
# dot — both look like outages, so reject them instead.
139+
if self.sync_fresh_seconds <= 0:
140+
raise ValueError(
141+
f"sync_fresh_seconds must be > 0, got {self.sync_fresh_seconds!r}"
142+
)
143+
if self.wifi_dot and not self.wifi_iface:
144+
raise ValueError("wifi_iface must be non-empty when wifi_dot is on")
122145
# The schedule logic (piclock/schedule.py) assumes the edges are in daily
123146
# order; a misordered schedule would silently pick wrong phases.
124147
if not (self.off_at < self.on_at < self.dim_at):
@@ -142,6 +165,11 @@ def from_env(cls) -> "Config":
142165
hour24=_env_bool("PICLOCK_24H", cls.hour24),
143166
blink_colon=_env_bool("PICLOCK_BLINK", cls.blink_colon),
144167
sync_dot=_env_bool("PICLOCK_SYNC_DOT", cls.sync_dot),
168+
sync_fresh_seconds=_env_float(
169+
"PICLOCK_SYNC_FRESH", cls.sync_fresh_seconds
170+
),
171+
wifi_dot=_env_bool("PICLOCK_WIFI_DOT", cls.wifi_dot),
172+
wifi_iface=os.environ.get("PICLOCK_WIFI_IFACE", cls.wifi_iface).strip(),
145173
tick_seconds=_env_float("PICLOCK_TICK", cls.tick_seconds),
146174
backend=os.environ.get("PICLOCK_DISPLAY", cls.backend).strip().lower(),
147175
ctl_socket=os.environ.get("PICLOCK_CTL_SOCKET", cls.ctl_socket),

‎piclock/display.py‎

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414
0x06 digit 2
1515
0x08 digit 3 (rightmost)
1616
17-
The lower-left indicator dot doubles as the NTP-sync indicator: lit while the
18-
system clock is synced, dark while it free-runs.
17+
Two of the indicator dots double as status lights: the lower-left dot is the
18+
NTP-sync indicator (lit while syncs are arriving, dark while the clock
19+
free-runs) and the upper-left dot is the Wi-Fi indicator (lit while the link
20+
is up, dark when it drops).
1921
"""
2022

2123
from __future__ import annotations
@@ -46,15 +48,18 @@
4648
_COLON_ADDR = 4
4749
_COLON_BIT = 0x02
4850
_SYNC_DOT_BIT = 0x08 # lower-left indicator dot, shares the colon's RAM byte
51+
_WIFI_DOT_BIT = 0x04 # upper-left indicator dot, same byte
4952

5053
# HT16K33 command bytes.
5154
_CMD_OSCILLATOR_ON = 0x21 # system setup: oscillator on
5255
_CMD_DISPLAY_ON = 0x81 # display setup: on, no blink
5356
_CMD_DIMMING = 0xE0 # | level (0-15)
5457

5558

56-
def build_buffer(text: str, colon: bool, sync_dot: bool = False) -> list[int]:
57-
"""Encode four characters + colon + sync dot into the 16-byte HT16K33 RAM buffer.
59+
def build_buffer(
60+
text: str, colon: bool, sync_dot: bool = False, wifi_dot: bool = False
61+
) -> list[int]:
62+
"""Encode four characters + colon + indicator dots into the 16-byte HT16K33 RAM buffer.
5863
5964
Pure and hardware-free so it can be unit-tested. Raises ValueError on a
6065
wrong-length string or an unsupported character.
@@ -67,14 +72,20 @@ def build_buffer(text: str, colon: bool, sync_dot: bool = False) -> list[int]:
6772
buf[_DIGIT_ADDR[i]] = _FONT[char]
6873
except KeyError:
6974
raise ValueError(f"unsupported character {char!r} in {text!r}") from None
70-
buf[_COLON_ADDR] = (_COLON_BIT if colon else 0) | (_SYNC_DOT_BIT if sync_dot else 0)
75+
buf[_COLON_ADDR] = (
76+
(_COLON_BIT if colon else 0)
77+
| (_SYNC_DOT_BIT if sync_dot else 0)
78+
| (_WIFI_DOT_BIT if wifi_dot else 0)
79+
)
7180
return buf
7281

7382

7483
class Display(Protocol):
7584
"""Minimal display interface the app loop depends on."""
7685

77-
def show(self, text: str, colon: bool, sync_dot: bool = False) -> None: ...
86+
def show(
87+
self, text: str, colon: bool, sync_dot: bool = False, wifi_dot: bool = False
88+
) -> None: ...
7889
def set_brightness(self, level: int) -> None: ...
7990
def clear(self) -> None: ...
8091
def close(self) -> None: ...
@@ -83,7 +94,9 @@ def close(self) -> None: ...
8394
class NullDisplay:
8495
"""No-op display for development and tests off the Pi."""
8596

86-
def show(self, text: str, colon: bool, sync_dot: bool = False) -> None:
97+
def show(
98+
self, text: str, colon: bool, sync_dot: bool = False, wifi_dot: bool = False
99+
) -> None:
87100
pass
88101

89102
def set_brightness(self, level: int) -> None:
@@ -104,7 +117,7 @@ def __init__(self, bus_num: int = 1, address: int = 0x70, brightness: int = 3) -
104117

105118
self._address = address
106119
self._bus = SMBus(bus_num)
107-
self._last: tuple[str, bool, bool] | None = None
120+
self._last: tuple[str, bool, bool, bool] | None = None
108121
self._last_brightness: int | None = None
109122

110123
try:
@@ -122,12 +135,14 @@ def set_brightness(self, level: int) -> None:
122135
self._bus.write_byte(self._address, _CMD_DIMMING | level)
123136
self._last_brightness = level
124137

125-
def show(self, text: str, colon: bool, sync_dot: bool = False) -> None:
126-
key = (text, colon, sync_dot)
138+
def show(
139+
self, text: str, colon: bool, sync_dot: bool = False, wifi_dot: bool = False
140+
) -> None:
141+
key = (text, colon, sync_dot, wifi_dot)
127142
if key == self._last:
128143
return # avoid redundant I2C traffic (e.g. unchanged minute)
129144
self._bus.write_i2c_block_data(
130-
self._address, 0x00, build_buffer(text, colon, sync_dot)
145+
self._address, 0x00, build_buffer(text, colon, sync_dot, wifi_dot)
131146
)
132147
self._last = key
133148

@@ -201,12 +216,14 @@ def _drop(self, exc: OSError) -> None:
201216
except Exception: # best effort after an I/O failure
202217
pass
203218

204-
def show(self, text: str, colon: bool, sync_dot: bool = False) -> None:
219+
def show(
220+
self, text: str, colon: bool, sync_dot: bool = False, wifi_dot: bool = False
221+
) -> None:
205222
dev = self._connect()
206223
if dev is None:
207224
return
208225
try:
209-
dev.show(text, colon, sync_dot)
226+
dev.show(text, colon, sync_dot, wifi_dot)
210227
except OSError as exc:
211228
self._drop(exc)
212229

‎piclock/timesync.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@
77
from __future__ import annotations
88

99
import subprocess
10+
import time
11+
from pathlib import Path
12+
from typing import Callable
13+
14+
#: Touched by systemd-timesyncd on every successful NTP exchange. Lives on
15+
#: tmpfs, so it also does not exist before the first sync of the current boot.
16+
SYNC_STAMP = Path("/run/systemd/timesync/synchronized")
1017

1118

1219
def is_ntp_synced() -> bool:
@@ -25,3 +32,23 @@ def is_ntp_synced() -> bool:
2532
except (OSError, subprocess.SubprocessError):
2633
return False
2734
return result.stdout.strip() == "yes"
35+
36+
37+
def last_sync_age(
38+
stamp: Path = SYNC_STAMP, *, now: Callable[[], float] = time.time
39+
) -> float | None:
40+
"""Seconds since timesyncd's last *successful* NTP sync, or None if unknown.
41+
42+
NTPSynchronized answers "is the clock still trustworthy" with hours of
43+
hysteresis (the kernel's maxerror estimate takes ~9 h to expire), which is
44+
right for the dashes but useless for noticing that syncs have stopped. The
45+
stamp file's mtime tracks NTP reachability directly: it stops advancing the
46+
moment sync responses stop arriving. A single stat(), so it is cheap enough
47+
to check on every sync poll. None (no timesyncd, e.g. a dev machine) tells
48+
the caller to fall back to is_ntp_synced() alone.
49+
"""
50+
try:
51+
mtime = stamp.stat().st_mtime
52+
except OSError:
53+
return None
54+
return max(0.0, now() - mtime)

‎piclock/wifi.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Wi-Fi link status for the wifi indicator dot.
2+
3+
Reads the kernel's operstate file for the interface — a single in-process file
4+
read (~140 us on a Pi Zero 2 W), cheap enough to poll every tick with no
5+
subprocess. "up" means the link is administratively up and associated; a
6+
missing interface (e.g. a firmware crash took wlan0 away entirely) reads as
7+
down, which is exactly what the indicator should show.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from pathlib import Path
13+
14+
#: Where the kernel exposes per-interface link state.
15+
SYS_CLASS_NET = Path("/sys/class/net")
16+
17+
18+
def is_wifi_up(iface: str, *, sys_class_net: Path = SYS_CLASS_NET) -> bool:
19+
"""Return True while ``iface`` exists and its link is up."""
20+
try:
21+
state = (sys_class_net / iface / "operstate").read_text()
22+
except OSError:
23+
return False
24+
return state.strip() == "up"

0 commit comments

Comments
 (0)