Skip to content

Commit d8d9309

Browse files
author
lee
committed
Add NTP sync-indicator dot and free-running behavior on sync loss
The backpack's lower-left indicator dot now lights while the system clock is NTP-synced (PICLOCK_SYNC_DOT, default on). The loop keeps polling sync status after the first sync (60s interval; 5s pre-sync), and losing NTP no longer reverts to ----: the free-running time stays up with the dot dark. Dashes remain the pre-first-sync state only. Sync-indicator concept borrowed from tenox7/rpiclock (idea only, no code).
1 parent 78df8e3 commit d8d9309

8 files changed

Lines changed: 122 additions & 42 deletions

File tree

‎AGENTS.md‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,21 +53,27 @@ story — keep it intact.
5353
Default to 24-hour time. The code is split so the logic is testable off-Pi and the only
5454
hardware-touching part is isolated:
5555

56-
- [piclock/clock.py](piclock/clock.py) — **pure** time → `DisplayState` (4-char text + colon). No
57-
I/O. Shows `----` when the clock isn't synced. The colon is **steady by default** (this is
56+
- [piclock/clock.py](piclock/clock.py) — **pure** time → `DisplayState` (4-char text + colon +
57+
sync dot). No I/O. Shows `----` until the clock's **first-ever** sync; after that, losing NTP
58+
keeps the (free-running, still approximately right) time on the display and only the sync
59+
dot goes dark — the dashes never come back mid-run. The colon is **steady by default** (this is
5860
a bedside clock — no movement at night); an optional 1 Hz blink is config-gated and derived
5961
from the wall clock, not a tick counter, so it can't drift.
6062
- [piclock/display.py](piclock/display.py) — the HT16K33 `smbus2` driver, plus a pure
61-
`build_buffer()` (font/colon → 16-byte RAM) and a `NullDisplay` for off-Pi runs. The
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
65+
runs. The
6266
driver skips redundant I2C writes when nothing changed. The hardware is wrapped in
6367
`ResilientDisplay`: if the display is absent or a connector is bumped, `show()` never
6468
raises — it logs once and retries the connection every ~10 s, so the service keeps running
6569
(no crash-loop) and the correct time appears the instant the display reconnects.
6670
- [piclock/timesync.py](piclock/timesync.py) — `is_ntp_synced()` via `timedatectl show -p
67-
NTPSynchronized`. Any error → treated as not-synced (so we show dashes, never a bad time).
71+
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.
6874
- [piclock/config.py](piclock/config.py) — `Config` dataclass with `PICLOCK_*` env overrides
69-
(brightness, schedule on/off + times + wake/night brightness, 24h, blink, I2C bus/address,
70-
tick, and `PICLOCK_DISPLAY=null` for dev). Validates its inputs at construction, including
75+
(brightness, schedule on/off + times + wake/night brightness, 24h, blink, sync dot, I2C
76+
bus/address, tick, and `PICLOCK_DISPLAY=null` for dev). Validates its inputs at construction, including
7177
that the schedule edges are in daily order.
7278
- [piclock/schedule.py](piclock/schedule.py) — **pure** day/night schedule logic: wall-clock time →
7379
phase (`off` / `wake` / `night`). No I/O, unit-tested like `clock.py`.
@@ -109,7 +115,9 @@ hardware-touching part is isolated:
109115

110116
Guarantees worth preserving: the display layer must **fail soft** on I2C errors (a missing or
111117
flaky display logs + reconnects, never crashes the service); the clock must keep showing
112-
`----` until `is_ntp_synced()` is true; and **HomeKit and control-socket startup are
118+
`----` until `is_ntp_synced()` is first true — but once it has synced, a later sync loss
119+
must **not** bring the dashes back (show the free-running time, sync dot dark); and
120+
**HomeKit and control-socket startup are
113121
non-fatal** — if either can't start (missing deps, unwritable state dir, missing socket
114122
dir, …) the app logs and runs the clock without it, since the 7-seg's job must never depend
115123
on an optional feature. `Config` validates its inputs

‎README.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ 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.
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).)
2831

2932
## Hardware
3033

@@ -120,6 +123,7 @@ Set via environment variables (typically in the systemd unit):
120123
| `PICLOCK_WAKE_BRIGHTNESS` | `15` | Level the schedule's evening wake applies, `0`–`15` |
121124
| `PICLOCK_NIGHT_BRIGHTNESS` | `0` | Level the schedule's night dim applies, `0`–`15` (`0` is still lit) |
122125
| `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 |
123127
| `PICLOCK_I2C_BUS` | `1` | I2C bus number |
124128
| `PICLOCK_I2C_ADDRESS` | `0x70` | HT16K33 address |
125129
| `PICLOCK_TICK` | `0.2` | Loop period, seconds |

‎piclock/app.py‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,13 @@
1919

2020
log = logging.getLogger("piclock")
2121

22-
# How often to poll for NTP sync while still unsynced. Decoupled from the (much
23-
# faster) display tick so a no-network boot doesn't spawn `timedatectl` 5x/sec
24-
# forever; the display shows `----` meanwhile, so a few seconds' lag is invisible.
22+
# How often to poll for NTP sync. Decoupled from the (much faster) display tick
23+
# so a no-network boot doesn't spawn `timedatectl` 5x/sec forever; the display
24+
# shows `----` meanwhile, so a few seconds' lag is invisible. Once synced we
25+
# keep polling (the sync dot tracks losing sync too) but relax the interval —
26+
# sync loss is rare and a minute's lag on the indicator is invisible.
2527
_SYNC_POLL_SECONDS = 5.0
28+
_SYNCED_POLL_SECONDS = 60.0
2629

2730
# Shown when the display is switched off (by the day/night schedule or HomeKit):
2831
# all segments dark, so no LEDs are lit — this is the point, sparing the 7-seg's
@@ -49,6 +52,11 @@ def _handle_signal(signum, _frame):
4952
log.info("starting %s", config)
5053
display = make_display(config)
5154
synced = False
55+
# Latches True at the first sync and stays True: `----` is only for the
56+
# boot window where the clock has never been right. After that, losing NTP
57+
# keeps the (free-running, still approximately right) time on the display
58+
# and only the sync dot goes dark.
59+
ever_synced = False
5260

5361
# Display on/off + brightness state, shared by everything that controls the
5462
# display. The optional day/night schedule (evaluated below, in-process)
@@ -94,11 +102,17 @@ def _handle_signal(signum, _frame):
94102

95103
try:
96104
while not stop.is_set():
97-
if not synced and time.monotonic() >= next_sync_check:
105+
if time.monotonic() >= next_sync_check:
106+
was_synced = synced
98107
synced = timesync.is_ntp_synced()
99-
next_sync_check = time.monotonic() + _SYNC_POLL_SECONDS
100-
if synced:
108+
next_sync_check = time.monotonic() + (
109+
_SYNCED_POLL_SECONDS if synced else _SYNC_POLL_SECONDS
110+
)
111+
if synced and not was_synced:
112+
ever_synced = True
101113
log.info("system clock synchronized")
114+
elif was_synced and not synced:
115+
log.warning("NTP sync lost; free-running until it returns")
102116

103117
now = datetime.now()
104118

@@ -152,18 +166,20 @@ def _handle_signal(signum, _frame):
152166
state = clock.render(
153167
now,
154168
synced,
169+
ever_synced=ever_synced,
155170
hour24=config.hour24,
156171
blink=config.blink_colon,
157172
)
158173
text, colon = state.text, state.colon
174+
sync_dot = state.sync_dot and config.sync_dot
159175
else:
160-
text, colon = _BLANK_TEXT, False # display switched off
176+
text, colon, sync_dot = _BLANK_TEXT, False, False # display off
161177

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

168184
stop.wait(config.tick_seconds)
169185
finally:

‎piclock/clock.py‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,27 +16,32 @@
1616

1717
@dataclass(frozen=True)
1818
class DisplayState:
19-
"""Desired display contents: four characters plus colon on/off."""
19+
"""Desired display contents: four characters, colon, and sync-indicator dot."""
2020

2121
text: str # exactly 4 chars; space means a blank digit
2222
colon: bool
23+
sync_dot: bool = False
2324

2425

2526
def render(
2627
now: datetime,
2728
synced: bool,
2829
*,
30+
ever_synced: bool = False,
2931
hour24: bool = True,
3032
blink: bool = False,
3133
) -> DisplayState:
3234
"""Render the current local time into a DisplayState.
3335
34-
Before the clock is synced, returns dashes with the colon off. Once synced,
35-
formats HH:MM; in 12-hour mode the leading zero of the hour is blanked. The
36-
colon is steady by default; when ``blink`` is set it is lit only for the
37-
first half of each second.
36+
Until the clock has synced for the first time, returns dashes with the
37+
colon off — never a confidently-wrong time. Once synced, formats HH:MM
38+
with the sync dot lit; in 12-hour mode the leading zero of the hour is
39+
blanked. If sync is later lost (``synced`` False, ``ever_synced`` True)
40+
the time stays up — the system clock free-runs and remains approximately
41+
right — and only the sync dot goes dark. The colon is steady by default;
42+
when ``blink`` is set it is lit only for the first half of each second.
3843
"""
39-
if not synced:
44+
if not synced and not ever_synced:
4045
return DisplayState(UNSYNCED_TEXT, colon=False)
4146

4247
if hour24:
@@ -46,4 +51,4 @@ def render(
4651
text = f"{hour12:2d}{now.minute:02d}" # space-pads a single-digit hour
4752

4853
colon = (now.microsecond < 500_000) if blink else True
49-
return DisplayState(text, colon)
54+
return DisplayState(text, colon, sync_dot=synced)

‎piclock/config.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ class Config:
7979
dim_at: time = time(20, 0) # drop to night_brightness (night)
8080
hour24: bool = True
8181
blink_colon: bool = False # steady colon by default — no movement for a dark bedroom
82+
# 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.
85+
sync_dot: bool = True
8286
tick_seconds: float = 0.2 # loop granularity; colon blink derives from wall clock
8387
backend: str = "ht16k33" # "ht16k33" for hardware, "null" for off-Pi dev/testing
8488

@@ -137,6 +141,7 @@ def from_env(cls) -> "Config":
137141
dim_at=_env_time("PICLOCK_DIM_AT", cls.dim_at),
138142
hour24=_env_bool("PICLOCK_24H", cls.hour24),
139143
blink_colon=_env_bool("PICLOCK_BLINK", cls.blink_colon),
144+
sync_dot=_env_bool("PICLOCK_SYNC_DOT", cls.sync_dot),
140145
tick_seconds=_env_float("PICLOCK_TICK", cls.tick_seconds),
141146
backend=os.environ.get("PICLOCK_DISPLAY", cls.backend).strip().lower(),
142147
ctl_socket=os.environ.get("PICLOCK_CTL_SOCKET", cls.ctl_socket),

‎piclock/display.py‎

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@
99
1010
0x00 digit 0 (leftmost)
1111
0x02 digit 1
12-
0x04 colon (bit 1 = centre colon)
12+
0x04 colon + indicator dots (bit 1 = centre colon, bit 2 = upper-left
13+
dot, bit 3 = lower-left dot, bit 4 = upper-right dot)
1314
0x06 digit 2
1415
0x08 digit 3 (rightmost)
16+
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.
1519
"""
1620

1721
from __future__ import annotations
@@ -41,15 +45,16 @@
4145
_DIGIT_ADDR = (0, 2, 6, 8) # RAM offset of each of the four digit positions
4246
_COLON_ADDR = 4
4347
_COLON_BIT = 0x02
48+
_SYNC_DOT_BIT = 0x08 # lower-left indicator dot, shares the colon's RAM byte
4449

4550
# HT16K33 command bytes.
4651
_CMD_OSCILLATOR_ON = 0x21 # system setup: oscillator on
4752
_CMD_DISPLAY_ON = 0x81 # display setup: on, no blink
4853
_CMD_DIMMING = 0xE0 # | level (0-15)
4954

5055

51-
def build_buffer(text: str, colon: bool) -> list[int]:
52-
"""Encode four characters + colon into the 16-byte HT16K33 RAM buffer.
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.
5358
5459
Pure and hardware-free so it can be unit-tested. Raises ValueError on a
5560
wrong-length string or an unsupported character.
@@ -62,14 +67,14 @@ def build_buffer(text: str, colon: bool) -> list[int]:
6267
buf[_DIGIT_ADDR[i]] = _FONT[char]
6368
except KeyError:
6469
raise ValueError(f"unsupported character {char!r} in {text!r}") from None
65-
buf[_COLON_ADDR] = _COLON_BIT if colon else 0
70+
buf[_COLON_ADDR] = (_COLON_BIT if colon else 0) | (_SYNC_DOT_BIT if sync_dot else 0)
6671
return buf
6772

6873

6974
class Display(Protocol):
7075
"""Minimal display interface the app loop depends on."""
7176

72-
def show(self, text: str, colon: bool) -> None: ...
77+
def show(self, text: str, colon: bool, sync_dot: bool = False) -> None: ...
7378
def set_brightness(self, level: int) -> None: ...
7479
def clear(self) -> None: ...
7580
def close(self) -> None: ...
@@ -78,7 +83,7 @@ def close(self) -> None: ...
7883
class NullDisplay:
7984
"""No-op display for development and tests off the Pi."""
8085

81-
def show(self, text: str, colon: bool) -> None:
86+
def show(self, text: str, colon: bool, sync_dot: bool = False) -> None:
8287
pass
8388

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

100105
self._address = address
101106
self._bus = SMBus(bus_num)
102-
self._last: tuple[str, bool] | None = None
107+
self._last: tuple[str, bool, bool] | None = None
103108
self._last_brightness: int | None = None
104109

105110
try:
@@ -117,11 +122,13 @@ def set_brightness(self, level: int) -> None:
117122
self._bus.write_byte(self._address, _CMD_DIMMING | level)
118123
self._last_brightness = level
119124

120-
def show(self, text: str, colon: bool) -> None:
121-
key = (text, colon)
125+
def show(self, text: str, colon: bool, sync_dot: bool = False) -> None:
126+
key = (text, colon, sync_dot)
122127
if key == self._last:
123128
return # avoid redundant I2C traffic (e.g. unchanged minute)
124-
self._bus.write_i2c_block_data(self._address, 0x00, build_buffer(text, colon))
129+
self._bus.write_i2c_block_data(
130+
self._address, 0x00, build_buffer(text, colon, sync_dot)
131+
)
125132
self._last = key
126133

127134
def clear(self) -> None:
@@ -194,12 +201,12 @@ def _drop(self, exc: OSError) -> None:
194201
except Exception: # best effort after an I/O failure
195202
pass
196203

197-
def show(self, text: str, colon: bool) -> None:
204+
def show(self, text: str, colon: bool, sync_dot: bool = False) -> None:
198205
dev = self._connect()
199206
if dev is None:
200207
return
201208
try:
202-
dev.show(text, colon)
209+
dev.show(text, colon, sync_dot)
203210
except OSError as exc:
204211
self._drop(exc)
205212

‎tests/test_clock.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,26 @@ def test_unsynced_shows_dashes_and_no_colon():
1111
state = clock.render(_dt(20, 30), synced=False)
1212
assert state.text == clock.UNSYNCED_TEXT
1313
assert state.colon is False
14+
assert state.sync_dot is False
15+
16+
17+
def test_sync_dot_lit_while_synced():
18+
assert clock.render(_dt(20, 30), synced=True).sync_dot is True
19+
20+
21+
def test_sync_loss_keeps_time_with_dot_dark():
22+
# Once the clock has been right at least once, losing NTP must not bring
23+
# the dashes back — the free-running system clock is still approximately
24+
# right. Only the sync dot goes dark.
25+
state = clock.render(_dt(20, 30), synced=False, ever_synced=True)
26+
assert state.text == "2030"
27+
assert state.colon is True
28+
assert state.sync_dot is False
29+
30+
31+
def test_never_synced_shows_dashes_even_with_ever_synced_false():
32+
state = clock.render(_dt(20, 30), synced=False, ever_synced=False)
33+
assert state.text == clock.UNSYNCED_TEXT
1434

1535

1636
def test_24h_formats_with_leading_zero():

0 commit comments

Comments
 (0)