Use My T-Watch-S3 for Gtd
Fleetingwhat the watch is for
- what
- a spoken thought reaches my gtd inbox, and the phone rings the watch.
- why: it’s my gtd tool to remember doing things.
- scope
- the note stops where the sound reaches the phone.
- rule
- the watch runs a FOSS OS that talks to my android phone.
- why: I already have a python runtime on android, so I owe nothing to the annoying
standards, like the ones gadgetbridge wants.
- why: I can do whatever I want.
- why: I already have a python runtime on android, so I owe nothing to the annoying
standards, like the ones gadgetbridge wants.
- rule
- the work iterates fast and comfortably, on something that reproduces the watch faithfully.
- rule
- the watch and the phone reach eventual consistency: what one gives the other is kept until taken, and never dropped without saying so.
the stories
- what
- when I wake up, I start the watch, it connects to my phone, I think about something to be
done and speak it into the watch, and it lands in my gtd inbox via the phone.
- rule: what I speak into the watch reaches my phone as a sound recording.
- how: once the screen says the phone is linked, the story taps it above its button to start, and
again to end; the fake microphone hears a short tone, and the phone must save those very bytes,
while the watch’s screen says « saved on the phone ».
import asyncio import companion from air import spoken_thought, the_air, start_watch, stop_watch, tap, press, written PROMISE = "what I speak into the watch reaches my phone as a sound recording" async def story(work): thought = work / "thought.wav" screen = work / "screen.log" taps = work / "taps.log" spoken_thought(thought) async with the_air(work) as (watch_pty, phone_pty): phone = asyncio.create_task( companion.receive_one(f"serial:{phone_pty}", work / "inbox")) watch = await start_watch(watch_pty, mic=thought, screen=screen, taps=taps, speaker=work / "speaker.log", motor=work / "motor.log", wheel=work / "wheel.log") await written(screen, "phone linked") press(work / "wheel.log") tap(taps, 120, 100) await written(screen, "rec 0:00 / 2:00, tap to end") tap(taps, 120, 100) recording = await asyncio.wait_for(phone, 60) await stop_watch(watch, once=(screen, "saved on the phone")) shown = screen.read_text().splitlines() print(f" screen: {' / '.join(shown)}") return recording.read_bytes() == thought.read_bytes() and "saved on the phone" in shown
- how: once the screen says the phone is linked, the story taps it above its button to start, and
again to end; the fake microphone hears a short tone, and the phone must save those very bytes,
while the watch’s screen says « saved on the phone ».
- rule: what I speak while the phone is away reaches it once linked, one recording at most.
- how: before the phone links, the story taps twice and the watch keeps the recording; a third tap must be refused; then the phone links and must save those very bytes.
- rule: what I speak into the watch reaches my phone as a sound recording.
import asyncio
import companion
from air import spoken_thought, the_air, start_watch, stop_watch, tap, press, written
PROMISE = "what I speak while the phone is away reaches it once linked, one recording at most"
async def story(work):
thought = work / "thought.wav"
screen = work / "screen.log"
taps = work / "taps.log"
spoken_thought(thought)
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=thought, screen=screen, taps=taps,
speaker=work / "speaker.log", motor=work / "motor.log",
wheel=work / "wheel.log")
await written(screen, "waiting for the phone")
press(work / "wheel.log")
tap(taps, 120, 100)
await written(screen, "rec 0:00 / 2:00, tap to end")
tap(taps, 120, 100)
await written(screen, "kept until the phone links")
tap(taps, 120, 100)
await written(screen, "one already waiting")
recording = await asyncio.wait_for(
companion.receive_one(f"serial:{phone_pty}", work / "inbox"), 60)
await stop_watch(watch, once=(screen, "saved on the phone"))
shown = screen.read_text().splitlines()
print(f" screen: {' / '.join(shown)}")
return recording.read_bytes() == thought.read_bytes() and "saved on the phone" in shown
- rule
- a recording whose send breaks reaches the phone at the next link.
- how: the watch records 20 s of tone; the phone leaves once the screen counts 5 % sent, then links again and must save those very bytes, the screen having said the send stalled.
import asyncio
import contextlib
import wave
import companion
from air import the_air, start_watch, stop_watch, tap, press, written
PROMISE = "a recording whose send breaks reaches the phone at the next link"
def long_thought(path):
with wave.open(str(path), "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(8000)
wav.writeframes(bytes(range(256)) * 1250)
async def story(work):
thought = work / "thought.wav"
screen = work / "screen.log"
taps = work / "taps.log"
long_thought(thought)
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=thought, screen=screen, taps=taps,
speaker=work / "speaker.log", motor=work / "motor.log",
wheel=work / "wheel.log")
async with companion.linked(f"serial:{phone_pty}") as peer:
receiving = asyncio.create_task(companion.receive_recording(peer))
await written(screen, "phone linked")
press(work / "wheel.log")
tap(taps, 120, 100)
await written(screen, "rec 0:00 / 2:00, tap to end")
tap(taps, 120, 100)
await written(screen, "sending 5 %")
receiving.cancel()
with contextlib.suppress(asyncio.CancelledError):
await receiving
await written(screen, "send stalled, kept")
recording = await asyncio.wait_for(
companion.receive_one(f"serial:{phone_pty}", work / "inbox"), 120)
await stop_watch(watch, once=(screen, "saved on the phone"))
shown = screen.read_text().splitlines()
print(f" screen: {' / '.join(shown)}")
return recording.read_bytes() == thought.read_bytes()
- what
- later in the day, I check the hour.
- rule: a glance at the watch shows the right hour.
- how: once linked, the phone gives its time, a thursday at 07:42; the watch’s screen must then show
it, with the date.
import companion from air import the_air, start_watch, stop_watch PROMISE = "a glance at the watch shows the right hour" GLANCE = "07:42 Thu 22/10" async def story(work): screen = work / "screen.log" async with the_air(work) as (watch_pty, phone_pty): watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen, taps=work / "taps.log", speaker=work / "speaker.log", motor=work / "motor.log") async with companion.linked(f"serial:{phone_pty}") as peer: await companion.send_time(peer, (2026, 10, 22, 3, 7, 42, 0)) await stop_watch(watch, once=(screen, GLANCE)) shown = screen.read_text().splitlines() print(f" screen: {' / '.join(shown)}") return GLANCE in shown
- how: once linked, the phone gives its time, a thursday at 07:42; the watch’s screen must then show
it, with the date.
- rule: a glance at the watch shows the right hour.
- what
- when the watch runs low, I notice it before it dies.
- rule: a watch running low catches my eye.
- how: the watch starts at 14 %; its screen must say so, its fake motor buzz and its fake speaker ring, once each.
- rule: a watch running low catches my eye.
from air import the_air, start_watch, stop_watch
PROMISE = "a watch running low catches my eye"
WARNING = "battery low: 14 %"
async def story(work):
screen = work / "screen.log"
speaker = work / "speaker.log"
motor = work / "motor.log"
async with the_air(work) as (watch_pty, _):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen,
taps=work / "taps.log", speaker=speaker, motor=motor, battery=14)
await stop_watch(watch, once=(speaker, "ring"))
shown = screen.read_text().splitlines()
print(f" screen: {' / '.join(shown)}")
rung = speaker.read_text().splitlines()
buzzed = motor.read_text().splitlines() if motor.exists() else []
return WARNING in shown and rung == ["ring"] and buzzed == ["buzz"]
- what
- later, I get a recall; it comes from my phone and rings in the watch.
- rule: a recall my phone sends rings the watch.
- how: once the voice is sent, the phone writes a recall’s text, priority 4, sent on the 22/10 at
07:40; the watch, which knows no date, must show that text after that date and hour, its fake
speaker must have rung once and its fake motor buzzed once.
import asyncio import companion from air import spoken_thought, the_air, start_watch, stop_watch, tap, press, written PROMISE = "a recall my phone sends rings the watch" RECALL = "call the dentist" SENT = (2026, 10, 22, 7, 40) SHOWN = "22/10 07:40 call the dentist" async def story(work): thought = work / "thought.wav" screen = work / "screen.log" taps = work / "taps.log" speaker = work / "speaker.log" motor = work / "motor.log" spoken_thought(thought) async with the_air(work) as (watch_pty, phone_pty): watch = await start_watch(watch_pty, mic=thought, screen=screen, taps=taps, speaker=speaker, motor=motor, wheel=work / "wheel.log") async with companion.linked(f"serial:{phone_pty}") as peer: recording = asyncio.create_task(companion.receive_recording(peer)) await written(screen, "phone linked") press(work / "wheel.log") tap(taps, 120, 100) await written(screen, "rec 0:00 / 2:00, tap to end") tap(taps, 120, 100) await asyncio.wait_for(recording, 60) await companion.send_recall(peer, RECALL, priority=4, when=SENT) await stop_watch(watch, once=(speaker, "ring")) shown = screen.read_text().splitlines() print(f" screen: {' / '.join(shown)}") rung = speaker.read_text().splitlines() if speaker.exists() else [] buzzed = motor.read_text().splitlines() if motor.exists() else [] # the press and the two taps tick; only the buzz is the recall's return SHOWN in shown and rung == ["ring"] and [b for b in buzzed if b != "tick"] == ["buzz"]
- how: once the voice is sent, the phone writes a recall’s text, priority 4, sent on the 22/10 at
07:40; the watch, which knows no date, must show that text after that date and hour, its fake
speaker must have rung once and its fake motor buzzed once.
- rule: a recall from a noisy sender shows without a sound.
- how: once linked, the phone writes a recall, priority 2, then another, priority 4; the watch’s screen must show both, its fake motor have buzzed and its fake speaker rung only once, for the second.
- rule: a recall my phone sends rings the watch.
import companion
from air import the_air, start_watch, stop_watch
PROMISE = "a recall from a noisy sender shows without a sound"
QUIET = "a new follower"
LOUD = "call the dentist"
SENT = (2026, 10, 22, 7, 40)
async def story(work):
screen = work / "screen.log"
speaker = work / "speaker.log"
motor = work / "motor.log"
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen,
taps=work / "taps.log", speaker=speaker, motor=motor)
async with companion.linked(f"serial:{phone_pty}") as peer:
await companion.send_recall(peer, QUIET, priority=2, when=SENT)
await companion.send_recall(peer, LOUD, priority=4, when=SENT)
await stop_watch(watch, once=(speaker, "ring"))
shown = screen.read_text().splitlines()
print(f" screen: {' / '.join(shown)}")
rung = speaker.read_text().splitlines()
buzzed = motor.read_text().splitlines()
both = "22/10 07:40 " + QUIET in shown and "22/10 07:40 " + LOUD in shown
return both and rung == ["ring"] and buzzed == ["buzz"]
- rule
- recalls I have not looked at yet stand out, until my next tap or press.
- why: « new notif watching phone highlighted, like bold or something »
- how: once linked, the phone writes two recalls; both must stand out, then a tap must make them plain; then a third, which a press of the wheel must make plain.
import asyncio
import companion
from air import the_air, start_watch, stop_watch, tap, press, written
PROMISE = "recalls I have not looked at yet stand out, until my next tap or press"
SENT = (2026, 10, 22, 7, 40)
MARKS = ["fresh 22/10 07:40 one", "fresh 22/10 07:40 two", "seen", "fresh 22/10 07:40 three", "seen"]
async def marked(fresh, count):
for _ in range(200):
if len(fresh.read_text().splitlines()) >= count:
return
await asyncio.sleep(0.05)
async def story(work):
screen = work / "screen.log"
fresh = work / "screen.log.fresh"
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen,
taps=work / "taps.log", speaker=work / "speaker.log",
motor=work / "motor.log", wheel=work / "wheel.log")
async with companion.linked(f"serial:{phone_pty}") as peer:
await companion.send_recall(peer, "one", priority=2, when=SENT)
await companion.send_recall(peer, "two", priority=2, when=SENT)
await written(screen, "22/10 07:40 two")
tap(work / "taps.log", 120, 100)
await written(fresh, "seen")
await companion.send_recall(peer, "three", priority=2, when=SENT)
await written(screen, "22/10 07:40 three")
press(work / "wheel.log")
await marked(fresh, len(MARKS))
await stop_watch(watch, once=(fresh, "seen"))
marks = fresh.read_text().splitlines()
print(f" fresh: {' / '.join(marks)}")
return marks == MARKS
- rule
- a recall’s color tells its priority.
- how: once linked, the phone writes three recalls, priority 2, 3 and 4; the watch must draw them cyan, yellow and red.
import companion
from air import the_air, start_watch, stop_watch
PROMISE = "a recall's color tells its priority"
SENT = (2026, 10, 22, 7, 40)
async def story(work):
screen = work / "screen.log"
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen,
taps=work / "taps.log", speaker=work / "speaker.log",
motor=work / "motor.log")
async with companion.linked(f"serial:{phone_pty}") as peer:
for priority in (2, 3, 4):
await companion.send_recall(peer, f"priority {priority}", priority=priority, when=SENT)
await stop_watch(watch, once=(screen, "22/10 07:40 priority 4"))
tints = (work / "screen.log.tints").read_text().splitlines()
print(f" tints: {' / '.join(tints)}")
return tints == ["cyan", "yellow", "red"]
- rule
- a phone that leaves halfway can link again.
- how: the phone links and leaves before saying it is ready; it must then find the watch again and give it a recall the screen shows.
import asyncio
import companion
from air import the_air, start_watch, stop_watch
PROMISE = "a phone that leaves halfway can link again"
SHOWN = "22/10 07:40 linked again"
async def story(work):
screen = work / "screen.log"
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen,
taps=work / "taps.log", speaker=work / "speaker.log",
motor=work / "motor.log")
async with companion.linked(f"serial:{phone_pty}"):
pass
async with asyncio.timeout(20):
async with companion.linked(f"serial:{phone_pty}") as peer:
await companion.send_recall(peer, "linked again", priority=2,
when=(2026, 10, 22, 7, 40))
await stop_watch(watch, once=(screen, SHOWN))
shown = screen.read_text().splitlines()
print(f" screen: {' / '.join(shown)}")
return SHOWN in shown
- what
- a notification comes to my phone; I read it on the watch, without taking the phone out.
- rule: a notification posted on the linked phone shows whole on the watch.
- how: once linked, the phone gets a notification from Signal, titled « Alice », saying « on my way, I will be there in ten minutes », on the 22/10 at 07:40; the watch’s screen must show it whole, cut at the spaces into rows that fit its 30 columns, those rows alone in the recall color.
- how: then « Bob » says « see you »; each recall opens on its own rule, so the two come apart.
- rule: a notification posted on the linked phone shows whole on the watch.
import companion
from air import the_air, start_watch, stop_watch
PROMISE = "a notification posted on the linked phone shows whole on the watch"
ROWS = ["22/10 07:40 Signal: Alice: on", "my way, I will be there in ten", "minutes"]
NEXT = ["22/10 07:41 Signal: Bob: see", "you"]
RULE = "-" * 30
async def story(work):
screen = work / "screen.log"
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen,
taps=work / "taps.log", speaker=work / "speaker.log",
motor=work / "motor.log")
async with companion.linked(f"serial:{phone_pty}") as peer:
await companion.notify(peer, "Signal", "Alice", "on my way, I will be there in ten minutes",
when=(2026, 10, 22, 7, 40))
await companion.notify(peer, "Signal", "Bob", "see you", when=(2026, 10, 22, 7, 41))
await stop_watch(watch, once=(screen, NEXT[-1]))
shown = screen.read_text().splitlines()
print(f" screen: {' / '.join(shown)}")
recalled = (work / "screen.log.recalls").read_text().splitlines()
return (any(shown[at:at + len(ROWS)] == ROWS for at in range(len(shown)))
and recalled == [RULE] + ROWS + [RULE] + NEXT)
- rule
- an ntfy post shows short: its topic and message, once, links as hosts, times as hh:mm.
- how: once linked, the phone gets an ntfy notification titled « 192.168.2.14:9705/n », saying « A@memories (12s) », on the 22/10 at 07:40; the watch’s screen must show ntfy’s icon, then « n: A@memories (12s) ».
- how: its twin, « 07:40:A@memories (12s) », the same minute, must not show.
- how: then « 🚨📷 oxenhead » says « seen http://192.168.2.14:9917/explore?x=1 at 2026-10-22 05:46:59.123 »; it must show as « oxenhead: seen 192.168.2.14:9917 at 05:46 ».
import companion
from air import the_air, start_watch, stop_watch
PROMISE = "an ntfy post shows short: its topic and message, once, links as hosts, times as hh:mm"
RULE = "-" * 30
ROWS = [RULE, "\x0e\x0f22/10 07:40 n: A@memories", " (12s)",
RULE, "\x0e\x0f22/10 07:41 oxenhead: seen", " 192.168.2.14:9917 at 05:46"]
async def story(work):
screen = work / "screen.log"
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen,
taps=work / "taps.log", speaker=work / "speaker.log",
motor=work / "motor.log")
async with companion.linked(f"serial:{phone_pty}") as peer:
await companion.notify(peer, "ntfy", "192.168.2.14:9705/n", "A@memories (12s)",
when=(2026, 10, 22, 7, 40))
await companion.notify(peer, "ntfy", "192.168.2.14:9705/n", "07:40:A@memories (12s)",
when=(2026, 10, 22, 7, 40))
await companion.notify(peer, "ntfy", "🚨📷 oxenhead",
"seen http://192.168.2.14:9917/explore?x=1 at 2026-10-22 05:46:59.123",
when=(2026, 10, 22, 7, 41))
await stop_watch(watch, once=(screen, ROWS[-1]))
shown = screen.read_text().splitlines()
print(f" screen: {' / '.join(shown)}")
return (work / "screen.log.recalls").read_text().splitlines() == ROWS
- rule
- a notification in french shows its accents.
- how: once linked, the phone gets an ntfy notification titled « 192.168.2.14:9705/n », saying « été, à, ç, œ », on the 22/10 at 07:40; the watch’s screen must show « n: été, à, ç, oe », beside ntfy’s icon.
- scope: the fake screen writes the text as it comes; the glyphs the real screen draws are not checked here.
import companion
from air import the_air, start_watch, stop_watch
PROMISE = "a notification in french shows its accents"
SHOWN = ["\x0e\x0f22/10 07:40 n: été, à, ç,", " oe"]
async def story(work):
screen = work / "screen.log"
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen,
taps=work / "taps.log", speaker=work / "speaker.log",
motor=work / "motor.log")
async with companion.linked(f"serial:{phone_pty}") as peer:
await companion.notify(peer, "ntfy", "192.168.2.14:9705/n", "été, à, ç, œ",
when=(2026, 10, 22, 7, 40))
await stop_watch(watch, once=(screen, SHOWN[-1]))
shown = screen.read_text().splitlines()
print(f" screen: {' / '.join(shown)}")
return any(shown[at:at + len(SHOWN)] == SHOWN for at in range(len(shown)))
- rule
- a known app’s icon stands doubled at the left of its line, over two rows, the stamp and text
in one column beside it.
- why: « icons shluld be bigger », « taller », « also alçgned w_th text »
- how: once linked, the phone notifies « Alice: on my way » from a call, an SMS app, WhatsApp, a mail, a calendar and ntfy, on the 22/10 at 07:40; each must show as its icon, then « 22/10 07:40 Alice: on my », and below, beside the icon, « way ».
- scope: the fake screen writes the codes as they come;
icons-preview.pydraws what the real screen draws, for an eye to check.
import asyncio
import companion
from air import the_air, start_watch, stop_watch
PROMISE = "a known app's icon stands doubled at the left of its line, over two rows, the stamp and text in one column beside it"
ICONS = ("\x01\x02", "\x03\x04", "\x05\x06", "\x07\x08", "\x10\x11", "\x0e\x0f")
# each app as the android listener names it: its name, package and category
APPS = (("Phone", "com.google.android.dialer", "call"),
("Messages", "com.google.android.apps.messaging", "msg"),
("WhatsApp", "com.whatsapp", "msg"),
("K-9 Mail", "com.fsck.k9", "email"),
("Calendar", "org.fossify.calendar", "event"),
("ntfy", "io.heckel.ntfy", None))
FIRST = "22/10 07:40 Alice: on my"
NEXT = " way"
async def story(work):
screen = work / "screen.log"
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen,
taps=work / "taps.log", speaker=work / "speaker.log",
motor=work / "motor.log")
async with companion.linked(f"serial:{phone_pty}") as peer:
for app, package, category in APPS:
await companion.notify(peer, app, "Alice", "on my way", when=(2026, 10, 22, 7, 40),
package=package, category=category)
await stop_watch(watch, once=(screen, ICONS[-1] + FIRST))
await asyncio.sleep(1)
shown = screen.read_text().splitlines()
print(f" screen: {' / '.join(shown)}")
return all(any(shown[at:at + 2] == [icon + FIRST, NEXT] for at in range(len(shown))) for icon in ICONS)
- what
- at night, I need light to find my way, and the watch is on my wrist.
- rule: holding the wheel, then a tap on the pad’s left, lights the watch as a torch, and a tap puts
it out, read-only.
- how: the watch starts dark; the story holds the wheel, and the actions pad must open; it taps its left, and the screen must say « torch on »; then it taps once, and the screen must say « torch off », after the torch came on; a tap a second later must record nothing.
- rule: holding the wheel, then a tap on the pad’s left, lights the watch as a torch, and a tap puts
it out, read-only.
import asyncio
from air import the_air, start_watch, stop_watch, tap, hold, written
PROMISE = "holding the wheel, then a tap on the pad's left, lights the watch as a torch, and a tap puts it out, read-only"
async def story(work):
screen = work / "screen.log"
taps = work / "taps.log"
wheel = work / "wheel.log"
async with the_air(work) as (watch_pty, _):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen, taps=taps,
speaker=work / "speaker.log", motor=work / "motor.log",
dark=True, wheel=wheel)
hold(wheel)
await written(work / "screen.log.media", "pad actions")
tap(taps, 40, 100)
await written(screen, "torch on")
tap(taps, 120, 100)
await written(screen, "torch off")
tap(taps, 120, 100)
await asyncio.sleep(1)
await stop_watch(watch, once=(screen, "torch off"))
shown = screen.read_text().splitlines()
print(f" screen: {' / '.join(shown)}")
recorded = any(line.startswith("rec ") for line in shown)
return shown.index("torch on") < shown.index("torch off") and not recorded
- what
- reading or showing something on the watch, 5 s of light are too short.
- rule: a tap on the actions pad’s top row turns it to its second page, and a tap on « stay lit »
there keeps the screen lit a minute after each wake, for ten minutes; a second one ends it.
- why: « add an option to temporarily change the screen wake time »
- how: the story holds the wheel, and the actions pad must open; it taps the top row, and the second page must open; it taps its left, and the screen must say « lit 1 min for 10 min » and stay lit 7 s; it does it all again, and the screen must say « dark after 5 s again » and darken; no tap may record.
- scope: it checks the screen outlasts the 5 s, not the full minute nor the ten.
- rule: a tap on the actions pad’s top row turns it to its second page, and a tap on « stay lit »
there keeps the screen lit a minute after each wake, for ten minutes; a second one ends it.
import asyncio
from air import the_air, start_watch, stop_watch, tap, hold, written
PROMISE = "a tap on the actions pad's top row turns it to its second page, and a tap on « stay lit » there keeps the screen lit past the 5 s; a second one ends it"
async def nth(path, line, count):
for _ in range(200):
if path.exists() and path.read_text().splitlines().count(line) >= count:
return
await asyncio.sleep(0.05)
raise TimeoutError(f"{path.name} never said {line} {count} times")
async def story(work):
screen = work / "screen.log"
pads = work / "screen.log.media"
taps = work / "taps.log"
wheel = work / "wheel.log"
dark = work / "screen.log.dark"
def darks():
return dark.read_text().splitlines().count("dark") if dark.exists() else 0
async def turn(count, said):
hold(wheel)
await nth(pads, "pad actions", count)
tap(taps, 120, 5)
await nth(pads, "pad more", count)
tap(taps, 60, 150)
await written(screen, said)
await nth(pads, "pad off", count)
async with the_air(work) as (watch_pty, _):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen, taps=taps,
speaker=work / "speaker.log", motor=work / "motor.log",
dark=True, wheel=wheel)
await turn(1, "lit 1 min for 10 min")
before = darks()
await asyncio.sleep(7)
stayed = darks() == before
await turn(2, "dark after 5 s again")
await nth(dark, "dark", before + 1)
await stop_watch(watch, once=(screen, "dark after 5 s again"))
shown = screen.read_text().splitlines()
print(f" screen: {' / '.join(shown)}, stayed lit: {stayed}")
recorded = any(line.startswith("rec ") for line in shown)
ordered = shown.index("lit 1 min for 10 min") < shown.index("dark after 5 s again")
return ordered and stayed and not recorded
- what
- at a party, the watch plays an animation for it, a scene, until I stop it.
- rule: a tap on « scene », right of « stay lit », plays the Christmas scene: a fir under a star that
glows, its garland blinking, snow falling beside it and piling at its foot, gifts under it, stars
twinkling, a sleigh crossing the sky and « Joyeux Noël » on the snow, lit, until a wheel press ends
it; taps meanwhile do nothing.
- why: « create a christmas one that will take its place in the watch », « animations for parties »
- why: « make a fancier christmas animation »
- why: « i like the name “scene” instead of “party” »
- how: the story opens the second page and taps its right, and the screen must say « scene on »; 8 s later, with a tap among them, it must not have darkened and must have drawn the scene, naming its parts and words, and frames, every rectangle inside the screen; a press must make it say « scene off »; no tap may record.
- rule: a tap on « scene », right of « stay lit », plays the Christmas scene: a fir under a star that
glows, its garland blinking, snow falling beside it and piling at its foot, gifts under it, stars
twinkling, a sleigh crossing the sky and « Joyeux Noël » on the snow, lit, until a wheel press ends
it; taps meanwhile do nothing.
import asyncio
from air import the_air, start_watch, stop_watch, tap, hold, press, written
PROMISE = "a tap on « scene », right of « stay lit », plays the Christmas scene, lit, until a wheel press ends it"
def count(path):
return len(path.read_text().splitlines()) if path.exists() else 0
async def story(work):
screen = work / "screen.log"
pads = work / "screen.log.media"
taps = work / "taps.log"
wheel = work / "wheel.log"
dark = work / "screen.log.dark"
frames = work / "screen.log.scene"
async with the_air(work) as (watch_pty, _):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen, taps=taps,
speaker=work / "speaker.log", motor=work / "motor.log",
dark=True, wheel=wheel)
hold(wheel)
await written(pads, "pad actions")
tap(taps, 120, 5)
await written(pads, "pad more")
tap(taps, 180, 150)
await written(screen, "scene on")
before = count(dark)
await asyncio.sleep(4)
tap(taps, 120, 150)
await asyncio.sleep(4)
stayed = count(dark) == before
drawn = frames.read_text().splitlines() if frames.exists() else []
press(wheel)
await written(screen, "scene off")
await stop_watch(watch, once=(screen, "scene off"))
shown = screen.read_text().splitlines()
played = sum(line.startswith("frame ") for line in drawn)
print(f" screen: {' / '.join(shown)}, stayed lit: {stayed}, frames: {played}")
print(f" drawn: {drawn[0] if drawn else 'nothing'}")
recorded = any(line.startswith("rec ") for line in shown)
opened = drawn[0] if drawn else ""
parts = ("fir", "star", "garland", "snow", "pile", "gifts", "sky", "sleigh")
whole = (opened.startswith("christmas ") and opened.endswith("Joyeux Noël")
and all(f" {part} " in opened for part in parts))
inside = all(" 0 outside" in line for line in drawn)
return stayed and whole and inside and played >= 10 and not recorded
- rule
- halloween, the scene played before Christmas: a pumpkin whose eyes blink and a bat flying
across; kept here, played no more.
- why: « keep halloween code, but don’t put on the watch anymore »
- why: « add a button to run a halloween animation », « in continuous, until I press the wheel »
- how: its story, as Christmas’s, wants the scene named halloween; it is left out of the suite and
tangled nowhere, as its scene is; pulling
screen-halloweeninstead ofscreen-christmasputs it back.
import asyncio
from air import the_air, start_watch, stop_watch, tap, hold, press, written
PROMISE = "a tap on « halloween », right of « stay lit », plays a pumpkin and a bat, lit, until a wheel press ends it"
def count(path):
return len(path.read_text().splitlines()) if path.exists() else 0
async def story(work):
screen = work / "screen.log"
pads = work / "screen.log.media"
taps = work / "taps.log"
wheel = work / "wheel.log"
dark = work / "screen.log.dark"
frames = work / "screen.log.scene"
async with the_air(work) as (watch_pty, _):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen, taps=taps,
speaker=work / "speaker.log", motor=work / "motor.log",
dark=True, wheel=wheel)
hold(wheel)
await written(pads, "pad actions")
tap(taps, 120, 5)
await written(pads, "pad more")
tap(taps, 180, 150)
await written(screen, "scene on")
before = count(dark)
await asyncio.sleep(4)
tap(taps, 120, 150)
await asyncio.sleep(4)
stayed = count(dark) == before
drawn = frames.read_text().splitlines() if frames.exists() else []
press(wheel)
await written(screen, "scene off")
await stop_watch(watch, once=(screen, "scene off"))
shown = screen.read_text().splitlines()
played = sum(line.startswith("frame ") for line in drawn)
print(f" screen: {' / '.join(shown)}, stayed lit: {stayed}, frames: {played}")
print(f" drawn: {drawn[0] if drawn else 'nothing'}")
recorded = any(line.startswith("rec ") for line in shown)
whole = bool(drawn) and drawn[0].startswith("halloween ")
inside = all(line.endswith(" 0 outside") for line in drawn)
return stayed and whole and inside and played >= 10 and not recorded
- rule
- while a scene plays, a line coming from the phone never scrolls the screen under it.
- why: « bug: the pumpkin scrolls »
- how: the story opens the scene as above, then the phone writes three recalls; the screen’s
.scrolltwin must gain no line before the wheel press, and the line must still be kept.
import asyncio
import companion
from air import the_air, start_watch, stop_watch, tap, hold, press, written
PROMISE = "while a scene plays, a line coming from the phone never scrolls the screen under it"
LINES = ("Scene one", "Scene two", "Scene three")
SENT = (2026, 10, 22, 7, 40)
def count(path):
return len(path.read_text().splitlines()) if path.exists() else 0
async def story(work):
screen = work / "screen.log"
pads = work / "screen.log.media"
taps = work / "taps.log"
wheel = work / "wheel.log"
scrolls = work / "screen.log.scroll"
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen, taps=taps,
speaker=work / "speaker.log", motor=work / "motor.log",
wheel=wheel)
async with companion.linked(f"serial:{phone_pty}") as peer:
hold(wheel)
await written(pads, "pad actions")
tap(taps, 120, 5)
await written(pads, "pad more")
tap(taps, 180, 150)
await written(screen, "scene on")
before = count(scrolls)
for line in LINES:
await companion.send_recall(peer, line, priority=3, when=SENT)
await written(screen, "22/10 07:40 " + LINES[-1])
await asyncio.sleep(1)
moved = count(scrolls) - before
press(wheel)
await written(screen, "scene off")
await stop_watch(watch, once=(screen, "scene off"))
kept = all(line in screen.read_text() for line in LINES)
print(f" scrolls under the scene: {moved}; lines kept: {kept}")
return moved == 0 and kept
- rule
- while a pad is open, a line coming from the phone never scrolls the screen under it, so
the light bar stays on top.
- why: « in the menu, the horizontal slider is not always on the top »
- how: the story opens the actions pad, then the phone writes a recall, which closes the pad; the
.scrolltwin must gain no line, the screen being redrawn whole once the pad is gone, and the line must still be kept.
import asyncio
import companion
from air import the_air, start_watch, stop_watch, hold, written
PROMISE = "while a pad is open, a line coming from the phone never scrolls the screen under it"
LINES = ("Pad one",)
SENT = (2026, 10, 22, 7, 40)
def count(path):
return len(path.read_text().splitlines()) if path.exists() else 0
async def story(work):
screen = work / "screen.log"
pads = work / "screen.log.media"
taps = work / "taps.log"
wheel = work / "wheel.log"
scrolls = work / "screen.log.scroll"
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen, taps=taps,
speaker=work / "speaker.log", motor=work / "motor.log",
wheel=wheel)
async with companion.linked(f"serial:{phone_pty}") as peer:
hold(wheel)
await written(pads, "pad actions")
before = count(scrolls)
for line in LINES:
await companion.send_recall(peer, line, priority=3, when=SENT)
await written(screen, "22/10 07:40 " + LINES[-1])
await asyncio.sleep(1)
moved = count(scrolls) - before
await written(pads, "pad off")
await stop_watch(watch, once=(pads, "pad off"))
kept = all(line in screen.read_text() for line in LINES)
print(f" scrolls under the pad: {moved}; lines kept: {kept}")
return moved == 0 and kept
- what
- a picture shared on the phone, I want to see on my wrist.
- rule: a picture the phone writes on
PICTUREfills the band between the hour and the bar, each half-row where it belongs, stays lit past the 5 s, and the wheel puts the lines back.- why: « it must also show between the hour and the bar at the bottom; those are supposed static »
- why: « it must prevent going to sleep until I press the wheel »
- how: the story links as the phone, writes a 240x208 picture whose every pixel tells its place, and the screen must say « picture on » and draw all 416 half-rows, each starting with its own pixel; it waits 6 s, and the screen must not have darkened; then it presses the wheel, and the screen must say « picture off ».
- rule: a picture the phone writes on
import asyncio
import struct
import companion
from air import the_air, start_watch, stop_watch, press, written
PROMISE = "a picture the phone writes on PICTURE fills the band between the hour and the bar, each half-row where it belongs, stays lit past the 5 s, and the wheel puts the lines back"
def pixels():
return b"".join(struct.pack(">H", y * 256 + x) for y in range(208) for x in range(240))
async def story(work):
screen = work / "screen.log"
taps = work / "taps.log"
wheel = work / "wheel.log"
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen, taps=taps,
speaker=work / "speaker.log", motor=work / "motor.log", wheel=wheel)
async with companion.linked(f"serial:{phone_pty}") as peer:
await companion.send_picture(peer, pixels())
await written(screen, "picture on")
await written(work / "screen.log.picture", "207,1,cf78")
await asyncio.sleep(6)
darkened = (work / "screen.log.dark").exists()
press(wheel)
await stop_watch(watch, once=(screen, "picture off"))
drawn = (work / "screen.log.picture").read_text().splitlines()
wanted = ["%d,%d,%04x" % (y, half, y * 256 + half * 120) for y in range(208) for half in (0, 1)]
print(f" half-rows drawn: {len(drawn)}, misplaced: {len(set(wanted) - set(drawn))}, dark: {darkened}")
return sorted(drawn) == sorted(wanted) and not darkened
- what
- when the watch starts, it lights, and a stray touch must not record.
- rule: the watch starts read-only, and the wheel makes it touchable.
- how: the watch starts lit; the story taps once it waits for the phone, and nothing must record.
- rule: the watch starts read-only, and the wheel makes it touchable.
import asyncio
from air import the_air, start_watch, stop_watch, tap, written
PROMISE = "the watch starts read-only, and the wheel makes it touchable"
async def story(work):
screen = work / "screen.log"
taps = work / "taps.log"
async with the_air(work) as (watch_pty, _):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen, taps=taps,
speaker=work / "speaker.log", motor=work / "motor.log")
await written(screen, "waiting for the phone")
tap(taps, 120, 100)
await asyncio.sleep(1)
await stop_watch(watch, once=(screen, "waiting for the phone"))
shown = screen.read_text().splitlines()
print(f" screen: {' / '.join(shown)}")
return not any(line.startswith("rec ") for line in shown)
- rule
- a tap on the read-only screen shows what changed, as a charger plugged in.
- how: the watch starts lit read-only, on battery; the story plugs the fake charger and taps once; within 10 s, long before the bar’s minute comes, the bar must say it charges, and nothing record.
from air import the_air, start_watch, stop_watch, tap, written
PROMISE = "a tap on the read-only screen shows what changed, as a charger plugged in"
async def story(work):
screen = work / "screen.log"
bar = work / "screen.log.bar"
charger = work / "charger"
taps = work / "taps.log"
async with the_air(work) as (watch_pty, _):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen, taps=taps,
speaker=work / "speaker.log", motor=work / "motor.log",
charger=charger)
await written(bar, "on battery")
charger.touch()
tap(taps, 120, 100)
await written(bar, "charging")
await stop_watch(watch, once=(bar, "charging"))
print(f" bar: {' / '.join(bar.read_text().splitlines())}")
return not any(line.startswith("rec ") for line in screen.read_text().splitlines())
- rule
- on a charger, the chip stays awake, so its usb port stays up for an update; off it, the chip
sleeps again.
- why: « will it be clever enough to do that dynamically ? plug and unplug while watch is running »
- how: the watch starts on battery; the story plugs the fake charger, then unplugs it; the chip’s light sleep must go on, off, then on again.
import asyncio
from air import the_air, start_watch, stop_watch, written
PROMISE = "on a charger the chip stays awake, off it the chip sleeps again"
async def story(work):
screen = work / "screen.log"
sleeps = work / "screen.log.sleep"
charger = work / "charger"
async with the_air(work) as (watch_pty, _):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen, taps=work / "taps.log",
speaker=work / "speaker.log", motor=work / "motor.log",
charger=charger)
await written(sleeps, "light sleep on")
charger.touch()
await written(sleeps, "light sleep off")
charger.unlink()
for _ in range(100):
if sleeps.read_text().splitlines()[-1:] == ["light sleep on"]:
break
await asyncio.sleep(0.05)
await stop_watch(watch, once=(screen, "waiting for the phone"))
said = sleeps.read_text().splitlines() if sleeps.exists() else []
print(f" sleep: {' / '.join(said)}")
return said == ["light sleep on", "light sleep off", "light sleep on"]
- what
- before i use the wifi, i glance at the bar to know whether the watch joined it.
- rule: the bar’s « W » tells the wifi by its color.
- how: the watch starts with the wifi off, its « W » grey; the story makes the screen touchable and taps the bar, and the « W » must turn yellow while joining, then green once joined.
- rule: the bar’s « W » tells the wifi by its color.
from air import the_air, start_watch, stop_watch, tap, press, written
PROMISE = "the bar's « W » tells the wifi by its color"
async def story(work):
bar = work / "screen.log.bar"
async with the_air(work) as (watch_pty, _):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=work / "screen.log",
taps=work / "taps.log", speaker=work / "speaker.log",
motor=work / "motor.log", wheel=work / "wheel.log")
await written(bar, "W grey")
press(work / "wheel.log")
tap(work / "taps.log", 120, 230)
await written(bar, "W green")
await stop_watch(watch, once=(bar, "W green"))
shown = [line for line in bar.read_text().splitlines() if line.startswith("W ")]
print(f" bar: {' / '.join(shown)}")
return shown == ["W grey", "W yellow", "W green"]
- rule
- the bar’s « W » turns red when no wifi is found.
- how: the harness names no network, so the fake wifi never joins; the story makes the screen touchable and taps the bar, and the « W » must turn yellow, then red once the watch gives up joining.
from air import the_air, start_watch, stop_watch, tap, press, written
PROMISE = "the bar's « W » turns red when no wifi is found"
async def story(work):
bar = work / "screen.log.bar"
async with the_air(work) as (watch_pty, _):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=work / "screen.log",
taps=work / "taps.log", speaker=work / "speaker.log",
motor=work / "motor.log", wheel=work / "wheel.log", network=False)
await written(bar, "W grey")
press(work / "wheel.log")
tap(work / "taps.log", 120, 230)
await written(bar, "W red", seconds=40)
await stop_watch(watch, once=(bar, "W red"))
shown = [line for line in bar.read_text().splitlines() if line.startswith("W ")]
print(f" bar: {' / '.join(shown)}")
return shown == ["W grey", "W yellow", "W red"]
- rule
- a script sent to a watch out of reach says so in one line, naming the bar’s « W » to tap.
- how: the story sends a script toward a port nobody listens on; it must end failed, its one line naming the « W ».
import asyncio
import os
import socket
from air import ROOT
PROMISE = "a script sent to a watch out of reach says so in one line, naming the bar's « W » to tap"
async def story(work):
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
sent = await asyncio.create_subprocess_exec(
str(ROOT / "over-wifi.sh"), "true",
env={**os.environ, "TWATCH_HOST": "127.0.0.1", "TWATCH_PORT": str(port)},
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT)
said = (await sent.communicate())[0].decode().splitlines()
print(f" said: {' / '.join(said)}")
return sent.returncode != 0 and len(said) == 1 and "the watch's bar so « W » joins" in said[0]
- what
- before i speak into the watch, i glance at the bar to know whether the phone is linked.
- rule: the bar’s « B » tells by its color whether the phone is linked.
- how: the watch starts unlinked, its « B » yellow; once the phone links and says it is ready, the « B » must turn green, and yellow again once the phone leaves.
- rule: the bar’s « B » tells by its color whether the phone is linked.
import asyncio
import companion
from air import the_air, start_watch, stop_watch, written
PROMISE = "the bar's « B » tells by its color whether the phone is linked"
def tints(bar):
return [line for line in bar.read_text().splitlines() if line.startswith("B ")]
async def left(bar):
for _ in range(200):
if tints(bar)[-1:] == ["B yellow"]:
return
await asyncio.sleep(0.05)
async def story(work):
bar = work / "screen.log.bar"
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=work / "screen.log",
taps=work / "taps.log", speaker=work / "speaker.log",
motor=work / "motor.log")
await written(bar, "B yellow")
async with companion.linked(f"serial:{phone_pty}") as peer:
ready = peer.get_characteristics_by_uuid(companion.READY, companion.SERVICE)[0]
await peer.write_value(ready, b"phone", with_response=True)
await written(bar, "B green")
await left(bar)
await stop_watch(watch, once=(bar, "B green"))
shown = tints(bar)
print(f" bar: {' / '.join(shown)}")
return shown == ["B yellow", "B green", "B yellow"]
- what
- indoors the screen needs little light, outdoors all of it.
- rule: a tap along the actions pad’s light bar sets the light there, from 2 % at its left to 100 % at
its right, and the pad stays open.
- why: « a way to cuange brightness »
- why: « control with q slider instead in the 1.5s :enu? »
- how: the watch starts at 25 %; the story holds the wheel, opening the pad, and taps the bar at 60 % of its width, at its left edge, then at its right; the light must go 60 %, 2 %, 100 %.
- rule: a tap along the actions pad’s light bar sets the light there, from 2 % at its left to 100 % at
its right, and the pad stays open.
from air import the_air, start_watch, stop_watch, tap, hold, written
PROMISE = "a tap along the actions pad's light bar sets the light there, from 2 % at its left to 100 % at its right, and the pad stays open"
async def story(work):
light = work / "screen.log.light"
taps = work / "taps.log"
async with the_air(work) as (watch_pty, _):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=work / "screen.log",
taps=taps, speaker=work / "speaker.log",
motor=work / "motor.log", wheel=work / "wheel.log")
await written(light, "light 25%")
hold(work / "wheel.log")
await written(work / "motor.log", "tick")
for x, level in ((143, "60%"), (0, "2%")):
tap(taps, x, 30)
await written(light, "light " + level)
tap(taps, 239, 30)
await stop_watch(watch, once=(light, "light 100%"))
shown = light.read_text().splitlines()
print(f" light: {' / '.join(shown)}")
return shown == ["light 25%", "light 60%", "light 2%", "light 100%"]
- what
- when the watch crashes, I want it back, and to learn why from the phone.
- rule: a crash restarts the watch, and its traceback reaches the phone at the next link, then is gone
from the watch.
- why: « log excep(ions somewhere boot resistant and at next lçnk send to phone and purge »
- how: the story feeds the fake touch a tap it cannot read, which raises; the watch must end, its traceback kept; started again, once the phone links, the phone must hear the ValueError and the watch’s file must be gone.
- rule: a crash restarts the watch, and its traceback reaches the phone at the next link, then is gone
from the watch.
import asyncio
import companion
from air import the_air, start_watch, stop_watch, written
PROMISE = "a crash restarts the watch, and its traceback reaches the phone at the next link, then is gone from the watch"
async def story(work):
screen = work / "screen.log"
taps = work / "taps.log"
errors = work / "errors.log"
fakes = dict(mic=work / "none.wav", screen=screen, taps=taps, speaker=work / "speaker.log",
motor=work / "motor.log")
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, **fakes)
await written(screen, "waiting for the phone")
taps.write_text("boom,\n")
ended = await asyncio.wait_for(watch.wait(), 10)
kept = errors.read_text() if errors.exists() else ""
taps.write_text("")
watch = await start_watch(watch_pty, **fakes)
async with companion.linked(f"serial:{phone_pty}") as peer:
heard = await companion.receive_errors(peer, "ValueError")
await written(screen, "errors sent to the phone")
await stop_watch(watch, once=(screen, "errors sent to the phone"))
print(f" ended: {ended}, kept: {len(kept)} bytes, heard: {heard[-1]}, left: {errors.exists()}")
return ended != 0 and "ValueError" in kept and not errors.exists()
- what
- I read the watch’s charge from afar without touching it.
- rule: once linked, and every hour while linked, the watch tells the phone its charge, as « charge
80 % 3900 mV ».
- why: « this must be captured in the poc app itself, not via the desktop »; a read over the REPL
halts the watch’s
main.py. - how: the story links a phone to a watch at 80 %; the phone must hear a « charge 80 % » line on
CHARGE, the watch’s errors staying onERRORS.- why: « 2 »: a charge is no error, so it has its own channel and the phone logs it as « watch charge ».
- why: « this must be captured in the poc app itself, not via the desktop »; a read over the REPL
halts the watch’s
- rule: once linked, and every hour while linked, the watch tells the phone its charge, as « charge
80 % 3900 mV ».
import companion
from air import the_air, start_watch, stop_watch, written
PROMISE = "once linked, and every hour while linked, the watch tells the phone its charge"
async def story(work):
screen = work / "screen.log"
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen, taps=work / "taps.log",
speaker=work / "speaker.log", motor=work / "motor.log", battery=80)
await written(screen, "waiting for the phone")
async with companion.linked(f"serial:{phone_pty}") as peer:
heard = await companion.receive_charge(peer, "charge")
await stop_watch(watch, once=(screen, "waiting for the phone"))
print(f" heard: {heard[-1]}")
return heard[-1].startswith("charge 80 %")
- what
- with two phones around, I choose on the watch the one it links with.
- rule: the actions pad’s middle column steps through « any phone » and the phones seen, and a phone
other than the chosen one is dropped once it says its name.
- why: « can we in a menu of watch decide whar phàneto use ? »
- how: a phone named « phone » links and leaves; the story holds the wheel and taps the middle column, which must then read « phone »; a phone named « other » must then be refused.
- rule: the actions pad’s middle column steps through « any phone » and the phones seen, and a phone
other than the chosen one is dropped once it says its name.
import asyncio
import companion
from air import the_air, start_watch, stop_watch, tap, hold, written
PROMISE = "the actions pad's middle column steps through « any phone » and the phones seen, and a phone other than the chosen one is dropped once it says its name"
async def story(work):
screen = work / "screen.log"
motor = work / "motor.log"
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen, taps=work / "taps.log",
speaker=work / "speaker.log", motor=motor, wheel=work / "wheel.log")
async with companion.linked(f"serial:{phone_pty}") as peer:
await companion.say_ready(peer, "phone")
await written(screen, "phone linked")
hold(work / "wheel.log")
await written(motor, "tick")
tap(work / "taps.log", 120, 100)
await written(work / "screen.log.label", "actions 1 phone")
async with companion.linked(f"serial:{phone_pty}") as peer:
await companion.say_ready(peer, "other")
await asyncio.wait_for(peer.gone, 10)
await stop_watch(watch, once=(screen, "other refused, phone chosen"))
kept = (work / "phone.txt").read_text()
print(f" kept: {kept}")
return kept == "phone"
- what
- while I use the watch, it must not go dark under my finger.
- rule: every tap or press restarts the screen’s 5 s before it darkens.
- why: « every interaction with the watch must reset the 5s timer »
- how: the story presses the wheel, then taps the hour row every 2 s, four times, 8 s in all; each tap must tick the motor, the screen still lit; 7 s after the last, it must be dark.
- rule: every tap or press restarts the screen’s 5 s before it darkens.
import asyncio
from air import the_air, start_watch, stop_watch, tap, press, written
PROMISE = "every tap or press restarts the screen's 5 s before it darkens"
EVERY_S = 2
TAPS = 4
DARK_S = 7
async def story(work):
light = work / "screen.log.light"
dark = work / "screen.log.dark"
taps = work / "taps.log"
motor = work / "motor.log"
async with the_air(work) as (watch_pty, _):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=work / "screen.log",
taps=taps, speaker=work / "speaker.log",
motor=motor, wheel=work / "wheel.log")
await written(light, "light 25%")
press(work / "wheel.log")
for _ in range(TAPS):
await asyncio.sleep(EVERY_S)
tap(taps, 120, 8)
await asyncio.sleep(1)
ticked = motor.read_text().splitlines().count("tick") - 1
darkened_early = dark.exists()
await asyncio.sleep(DARK_S)
await stop_watch(watch, once=(light, "light 25%"))
darkened = dark.exists()
print(f" ticks: {ticked}, dark early: {darkened_early}, dark after: {darkened}")
return ticked == TAPS and not darkened_early and darkened
- rule
- a tap on the dark screen is answered within 0.3 s.
- why: « tap to wake is not react_ve enough »
- how: the story waits for the screen to darken, then taps; the fake motor must tick within 0.3 s.
import asyncio
import time
from air import the_air, start_watch, stop_watch, tap, written
PROMISE = "a tap on the dark screen is answered within 0.3 s"
WITHIN_S = 0.3
def ticks(motor):
return motor.read_text().splitlines().count("tick") if motor.exists() else 0
async def story(work):
dark = work / "screen.log.dark"
taps = work / "taps.log"
motor = work / "motor.log"
async with the_air(work) as (watch_pty, _):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=work / "screen.log",
taps=taps, speaker=work / "speaker.log", motor=motor)
while not dark.exists():
await asyncio.sleep(0.1)
await asyncio.sleep(1.5)
tapped = time.monotonic()
tap(taps, 120, 100)
while ticks(motor) == 0 and time.monotonic() - tapped < 3:
await asyncio.sleep(0.01)
answered = time.monotonic() - tapped
await stop_watch(watch, once=(motor, "tick"))
print(f" answered in {answered:.2f} s")
return answered < WITHIN_S
- rule
- a finger landing on the dark screen ticks and lights it at once, before it lifts.
- why: « la vibration quand le tap est pris en compte, pas quand l’action se lance »
- why: « allume l’écran au touch press, pas au touch release »
- how: the story waits for the dark, lands a finger without lifting it; the motor must tick and the light log wake by tap; then the finger lifts, and the motor must not tick again.
import asyncio
from air import the_air, start_watch, stop_watch, tap, land
PROMISE = "a finger landing on the dark screen ticks and lights it at once, before it lifts"
def ticks(motor):
return motor.read_text().splitlines().count("tick") if motor.exists() else 0
def woken(light):
return any(line.split()[1:3] == ["wake", "tap"]
for line in light.read_text().splitlines()) if light.exists() else False
async def story(work):
dark = work / "screen.log.dark"
taps = work / "taps.log"
motor = work / "motor.log"
light = work / "light.log"
async with the_air(work) as (watch_pty, _):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=work / "screen.log",
taps=taps, speaker=work / "speaker.log", motor=motor)
while not dark.exists():
await asyncio.sleep(0.1)
await asyncio.sleep(1.5)
land(taps)
await asyncio.sleep(1)
down = (ticks(motor), woken(light))
tap(taps, 120, 100)
await asyncio.sleep(1)
await stop_watch(watch, once=(dark, "dark"))
lifted = ticks(motor)
print(f" on landing: ticks {down[0]}, woken {down[1]}; after lift: ticks {lifted}")
return down == (1, True) and lifted == 1
- rule
- a tap on the dark screen wakes it even if the finger lifted before the watch looked.
- why: « a lot of taps just don’t wake the watch »
- how: the story waits for the screen to darken, then taps one the panel no longer holds;
light.logmust then say « wake tap ».
import asyncio
from air import the_air, start_watch, stop_watch, lose
PROMISE = "a tap on the dark screen wakes it even if the finger lifted before the watch looked"
def woken(trace):
kept = trace.read_text().splitlines() if trace.exists() else []
return any(line.split()[1:3] == ["wake", "tap"] for line in kept)
async def story(work):
trace = work / "light.log"
dark = work / "screen.log.dark"
taps = work / "taps.log"
async with the_air(work) as (watch_pty, _):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=work / "screen.log",
taps=taps, speaker=work / "speaker.log", motor=work / "motor.log")
while not dark.exists():
await asyncio.sleep(0.1)
lose(taps)
for _ in range(30):
if woken(trace):
break
await asyncio.sleep(0.1)
watch.terminate()
await watch.wait()
print(f" woken: {woken(trace)}")
return woken(trace)
- rule
- a wheel press on the dark screen lights it within 0.3 s.
- why: « wheel wakes after like 4s »
- how: the story waits for the screen to darken, presses the wheel mid-nap;
light.logmust say « wake wheel » within 0.3 s.
import asyncio
import time
from air import the_air, start_watch, press
PROMISE = "a wheel press on the dark screen lights it within 0.3 s"
WITHIN_S = 0.3
def woken(trace):
kept = trace.read_text().splitlines() if trace.exists() else []
return any(line.split()[1:3] == ["wake", "wheel"] for line in kept)
async def story(work):
trace = work / "light.log"
dark = work / "screen.log.dark"
wheel = work / "wheel.log"
async with the_air(work) as (watch_pty, _):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=work / "screen.log",
taps=work / "taps.log", speaker=work / "speaker.log",
motor=work / "motor.log", wheel=wheel)
while not dark.exists():
await asyncio.sleep(0.1)
await asyncio.sleep(1.5)
pressed = time.monotonic()
press(wheel)
while not woken(trace) and time.monotonic() - pressed < 3:
await asyncio.sleep(0.01)
answered = time.monotonic() - pressed
watch.terminate()
await watch.wait()
print(f" answered in {answered:.2f} s")
return answered < WITHIN_S
- rule
- a press or a tap on the dark screen is felt before the screen wakes.
- why: « shpuld vibrate when gettçnf the tap/press, not when lighton the screen »
- how: the fake motor writes into
light.logitself, so its « tick » and the screen’s « wake » stand in the order they came; a dark press, then a dark tap, must each put the tick first.
import asyncio
from air import the_air, start_watch, press, tap
PROMISE = "a press or a tap on the dark screen is felt before the screen wakes"
def lines(trace):
return trace.read_text().splitlines() if trace.exists() else []
async def dark_again(trace, after):
while not any(line.split()[1:2] == ["dark"] for line in lines(trace)[after:]):
await asyncio.sleep(0.1)
return len(lines(trace))
async def woken(trace, after, why):
for _ in range(30):
kept = lines(trace)[after:]
for at, line in enumerate(kept):
if line.split()[1:3] == ["wake", why]:
return "tick" in kept[:at]
await asyncio.sleep(0.1)
return False
async def story(work):
trace = work / "light.log"
wheel = work / "wheel.log"
taps = work / "taps.log"
async with the_air(work) as (watch_pty, _):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=work / "screen.log",
taps=taps, speaker=work / "speaker.log", motor=trace, wheel=wheel)
seen = await dark_again(trace, 0)
press(wheel)
pressed = await woken(trace, seen, "wheel")
seen = await dark_again(trace, seen)
tap(taps, 120, 100)
tapped = await woken(trace, seen, "tap")
watch.terminate()
await watch.wait()
print(f" felt first: press {pressed}, tap {tapped}")
return pressed and tapped
- rule
- each dark and wake of the screen is kept, with what caused it and the ms since the one before,
in a small file on the flash.
- why: « never work aroud, always fix. If not clear, add logs to improve knowledge »
- how: the story starts the watch dark; its first line, « waiting for the phone », lights it, so the
fake screen’s
light.logmust say « wake line ». - why: « i thçnk it happens after some time of inacrivity »: the log must say what touched the panel.
- how: a wake also keeps what it shows: lines, torch, picture or the pad’s name, how many lines are
kept and the last one, so its line must end « : lines N waiting for the phone ».
- why: the blank’s start is not remembered; a wake that showed lines tells a lost panel from an empty one.
- how: the main loop also writes « alive » there at its first turn, then every 5 min, so a hang
shows as a gap; the story sees the first one, the 5 min stay untested, no story waiting them.
- why: at 14:01 the log went silent until the RST, and whether main.py hung could not be told.
from air import the_air, start_watch, stop_watch
PROMISE = "each dark and wake of the screen is kept, with what caused it and the ms since the one before, in a small file on the flash"
async def story(work):
trace = work / "light.log"
screen = work / "screen.log"
async with the_air(work) as (watch_pty, _):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen, taps=work / "taps.log",
speaker=work / "speaker.log", motor=work / "motor.log", dark=True)
await stop_watch(watch, once=(screen, "waiting for the phone"))
kept = trace.read_text().splitlines() if trace.exists() else []
print(f" kept: {' / '.join(kept)}")
return (any(line.split()[1:3] == ["wake", "line"] and ": lines " in line
and line.endswith("waiting for the phone") for line in kept)
and any(line.split()[1:2] == ["alive"] for line in kept))
- what
- while a new firmware crawls to the watch over its wifi, I glance at the watch to know how far it is.
- rule: while a firmware is sent, the watch and the send’s log show each piece landed, in percent, and the time
left at the pace so far.
- why: « also, durin that time, can the watch show the progress as well ? », then « and an ETA ? »
- how: the story lands four small pieces on the fake watch; each but the last must show « firmware N %, T
left », the last « firmware 100 % », the same lines printed, and the pieces whole in
app.bin.
- rule: while a firmware is sent, the watch and the send’s log show each piece landed, in percent, and the time
left at the pace so far.
import asyncio
import os
import re
from air import ROOT
PROMISE = "while a firmware is sent, the watch and the send's log show each piece landed, in percent, and the time left at the pace so far"
PIECES = 4
LEFT = re.compile(r"firmware \d+ %, (\d+ h \d\d|\d+ min|\d+ s) left")
async def story(work):
script = f"import pieces\npieces.start({PIECES})\n"
for n in range(PIECES):
script += f"open('app.part', 'wb').write(b'{n}' * 10)\npieces.add()\n"
screen = work / "screen.log"
sent = await asyncio.create_subprocess_exec(
os.environ["TWATCH_MICROPYTHON"], "-c", script, cwd=work,
env={**os.environ, "MICROPYPATH": f"{ROOT / 'watch'}:{ROOT / 'fake'}",
"TWATCH_FAKE_SCREEN": str(screen), "TWATCH_FAKE_DARK": "1",
"TWATCH_FAKE_ERRORS": str(work / "errors.log"), "TWATCH_FAKE_PHONE": str(work / "phone.txt")},
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT)
said = (await sent.communicate())[0].decode().splitlines()
shown = [line for line in screen.read_text().splitlines() if line.startswith("firmware")] if screen.exists() else []
whole = (work / "app.bin").read_bytes() if (work / "app.bin").exists() else b""
print(f" shown: {' / '.join(shown)}")
print(f" said: {' / '.join(said)}")
return (len(shown) == PIECES and all(LEFT.fullmatch(line) for line in shown[:-1])
and shown[-1] == "firmware 100 %" and said == shown
and whole == b"".join(str(n).encode() * 10 for n in range(PIECES)))
- rule
- a firmware send cut short goes on from the pieces already landed, keeping them.
- why: over oxenhead the 1.7 MB take about 65 min, and a run is killed at 30: « make update-firmware resume from the pieces already on the watch » was given its go.
- how: the story lands two of four pieces, then starts again keeping those two and lands the last two;
app.binmust hold the four whole, the last line « firmware 100 % ».
import asyncio
import os
from air import ROOT
PROMISE = "a firmware send cut short goes on from the pieces already landed, keeping them"
PIECES = 4
KEPT = 2
async def land(work, script):
sent = await asyncio.create_subprocess_exec(
os.environ["TWATCH_MICROPYTHON"], "-c", script, cwd=work,
env={**os.environ, "MICROPYPATH": f"{ROOT / 'watch'}:{ROOT / 'fake'}",
"TWATCH_FAKE_SCREEN": str(work / "screen.log"), "TWATCH_FAKE_DARK": "1",
"TWATCH_FAKE_ERRORS": str(work / "errors.log"), "TWATCH_FAKE_PHONE": str(work / "phone.txt")},
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT)
return (await sent.communicate())[0].decode().splitlines()
def piece(n):
return f"open('app.part', 'wb').write(b'{n}' * 10)\npieces.add()\n"
async def story(work):
await land(work, f"import pieces\npieces.start({PIECES})\n" + "".join(piece(n) for n in range(KEPT)))
said = await land(work, f"import pieces\npieces.start({PIECES}, {KEPT})\n"
+ "".join(piece(n) for n in range(KEPT, PIECES)))
whole = (work / "app.bin").read_bytes() if (work / "app.bin").exists() else b""
print(f" said: {' / '.join(said)}")
print(f" app.bin: {whole}")
return said[-1:] == ["firmware 100 %"] and whole == b"".join(str(n).encode() * 10 for n in range(PIECES))
- what
- after a flash over usb, the watch runs again as before it.
- rule: after a flash’s erase, one script puts back every file the watch runs, main.py among them, then
restarts it.
- why: « a bad tool is fixed »: the erase took every file, and the note’s flash ended on « press RST ».
- how: the story runs the restore against an empty fake watch, a fake mpremote standing for the cable; the files must land there whole, main.py among them, and a reset come last; « every file » is main.py and boot.py and every module of the note they import, then those import, and so on.
- rule: after a flash’s erase, one script puts back every file the watch runs, main.py among them, then
restarts it.
import asyncio
import os
import re
import shutil
import sys
from air import ROOT
PROMISE = "after a flash's erase, one script puts back every file the watch runs, main.py among them, then restarts it"
MPREMOTE = """
import os, shutil, sys
os.chdir(os.environ["TWATCH_FAKE_FLASH"])
args = sys.argv[1:]
if args[:1] == ["connect"]:
args = args[2:]
if args[:1] == ["resume"]:
args = args[1:]
place = {}
commands = [[]]
for arg in args:
if arg == "+":
commands.append([])
else:
commands[-1].append(arg)
for command in commands:
if command[0] == "exec":
exec(command[1], place)
elif command[0] == "cp":
shutil.copy(command[1], command[2].lstrip(":"))
elif command[0] == "run":
exec(open(command[1]).read(), place)
with open(os.environ["TWATCH_FAKE_CABLE"], "a") as cable:
cable.write(command[0] + "\\n")
"""
async def story(work):
tree, bin, flash, lcd = work / "tree", work / "bin", work / "flash", work / "st7789"
for place in (bin, flash, lcd / "lib", lcd / "romfonts"):
place.mkdir(parents=True)
shutil.copytree(ROOT / "watch", tree / "watch")
shutil.copytree(ROOT / "device", tree / "device")
for script in ("deploy-watch.sh", "restore-watch.sh"):
if (ROOT / script).exists():
shutil.copy(ROOT / script, tree / script)
(lcd / "lib" / "st7789py.py").write_text("WIDTH = 240\n")
(lcd / "romfonts" / "vga2_8x16.py").write_text("HEIGHT = 16\n")
(bin / "nix-shell").write_text('#!/bin/sh\nexec bash "$@"\n')
(bin / "mpremote").write_text(f"#!{sys.executable}\n{MPREMOTE}")
for fake in ("nix-shell", "mpremote"):
(bin / fake).chmod(0o755)
cable = work / "cable.log"
restored = await asyncio.create_subprocess_exec(
"bash", str(tree / "restore-watch.sh"), "/dev/null",
env={**os.environ, "PATH": f"{bin}:{os.environ['PATH']}", "ST7789PY": str(lcd),
"TWATCH_FAKE_FLASH": str(flash), "TWATCH_FAKE_CABLE": str(cable)},
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT)
said = (await restored.communicate())[0].decode().splitlines()
places = [tree / "watch", tree / "device", lcd / "lib", lcd / "romfonts"]
wanted, todo = {}, ["boot", "main"]
while todo:
name = todo.pop()
file = next((place / f"{name}.py" for place in places if (place / f"{name}.py").exists()), None)
if name in wanted or file is None:
continue
wanted[name] = file
text = file.read_text()
todo += re.findall(r"^\s*from\s+(\w+)", text, re.M)
for names in re.findall(r"^\s*import\s+([\w, ]+)", text, re.M):
todo += [part.split()[0] for part in names.split(",") if part.strip()]
missing = sorted(file.name for file in wanted.values()
if not (flash / file.name).exists() or (flash / file.name).read_bytes() != file.read_bytes())
steps = cable.read_text().split() if cable.exists() else []
print(f" said: {' / '.join(said[-3:])}")
print(f" run by the watch: {len(wanted)}, missing: {missing}, last step: {steps[-1:]}")
return restored.returncode == 0 and "main" in wanted and not missing and steps[-1:] == ["reset"]
- rule
- each write the phone makes to the watch is kept in the same file, named and counted.
- why: « never work aroud, always fix. If not clear, add logs to improve knowledge »
- why: from 14:04 to 14:22 the watch left the phone’s writes unanswered, and nothing told what reached it.
- how: a phone links, says its name and sends a recall;
light.logmust then hold a « heard » line naming « ready » and one naming « recall ».
import companion
from air import the_air, start_watch, stop_watch, written
PROMISE = "each write the phone makes to the watch is kept in the same file, named and counted"
SENT = (2026, 10, 22, 7, 40)
async def story(work):
trace = work / "light.log"
screen = work / "screen.log"
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen, taps=work / "taps.log",
speaker=work / "speaker.log", motor=work / "motor.log")
async with companion.linked(f"serial:{phone_pty}") as peer:
await companion.say_ready(peer, "phone")
await written(screen, "phone linked")
await companion.send_recall(peer, "call the dentist", priority=2, when=SENT)
await written(screen, "22/10 07:40 call the dentist")
await stop_watch(watch, once=(screen, "waiting for the phone"))
kept = trace.read_text().splitlines() if trace.exists() else []
heard = [line.split()[2:] for line in kept if line.split()[1:2] == ["heard"]]
print(f" heard: {' / '.join(' '.join(words) for words in heard)}")
return any("ready" in words for words in heard) and any("recall" in words for words in heard)
- rule
- the watch keeps its charge in a file of its own, percent and mV, at its start and then each hour.
- why: « focus on preserving the battery the longer possible »: a saving’s worth is the drop per hour.
- why: in
light.log, a morning of link noise filled its 4 kB and took the night’s charge lines with it. - how: the fake battery says 80 % and 3900 mV, so
charge.logmust hold « 80 3900 » after the ticks; the hour stays untested.
from air import the_air, start_watch, stop_watch
PROMISE = "the watch keeps its charge in a file of its own, percent and mV, at its start and then each hour"
async def story(work):
trace = work / "charge.log"
screen = work / "screen.log"
async with the_air(work) as (watch_pty, _):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen, taps=work / "taps.log",
speaker=work / "speaker.log", motor=work / "motor.log")
await stop_watch(watch, once=(screen, "waiting for the phone"))
kept = trace.read_text().splitlines() if trace.exists() else []
charges = [line.split()[1:] for line in kept]
print(f" charges: {' / '.join(' '.join(words) for words in charges)}")
return any(words[:2] == ["80", "3900"] for words in charges)
- what
- on the move, I touch the watch without looking at it.
- rule: each tap and wheel press the watch takes answers with a short tick of its motor.
- why: « haptic feedback »
- how: the story presses the wheel, then taps the hour row; the fake motor must have ticked twice.
- rule: each tap and wheel press the watch takes answers with a short tick of its motor.
import asyncio
from air import the_air, start_watch, stop_watch, tap, press, written
PROMISE = "each tap and wheel press the watch takes answers with a short tick of its motor"
async def ticked(motor, count):
for _ in range(200):
if motor.exists() and len(motor.read_text().splitlines()) >= count:
return
await asyncio.sleep(0.05)
async def story(work):
motor = work / "motor.log"
light = work / "screen.log.light"
async with the_air(work) as (watch_pty, _):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=work / "screen.log",
taps=work / "taps.log", speaker=work / "speaker.log",
motor=motor, wheel=work / "wheel.log")
await written(light, "light 25%")
press(work / "wheel.log")
await ticked(motor, 1)
tap(work / "taps.log", 120, 8)
await ticked(motor, 2)
await stop_watch(watch, once=(light, "light 25%"))
shown = motor.read_text().splitlines() if motor.exists() else []
print(f" motor: {' / '.join(shown)}")
return shown == ["tick", "tick"]
- rule
- the tap that ends a recording ticks too, so I know it is over without looking.
- how: the story presses the wheel, taps to record, taps to end; the fake motor must have ticked thrice.
from air import spoken_thought, the_air, start_watch, stop_watch, tap, press, written
from tick import ticked
PROMISE = "the tap that ends a recording ticks too, so I know it is over without looking"
async def story(work):
thought = work / "thought.wav"
screen = work / "screen.log"
taps = work / "taps.log"
motor = work / "motor.log"
spoken_thought(thought)
async with the_air(work) as (watch_pty, _):
watch = await start_watch(watch_pty, mic=thought, screen=screen, taps=taps,
speaker=work / "speaker.log", motor=motor,
wheel=work / "wheel.log")
await written(screen, "waiting for the phone")
press(work / "wheel.log")
tap(taps, 120, 100)
await written(screen, "rec 0:00 / 2:00, tap to end")
tap(taps, 120, 100)
await written(screen, "kept until the phone links")
await ticked(motor, 3)
await stop_watch(watch, once=(screen, "kept until the phone links"))
shown = motor.read_text().splitlines() if motor.exists() else []
print(f" motor: {' / '.join(shown)}")
return shown == ["tick", "tick", "tick"]
- what
- my watch is somewhere in the flat; I make it ring from the phone, and silence it once found.
- rule: a « find me » from the phone beeps and buzzes the watch again and again, until a wheel press,
which tells the phone.
- how: once linked, the phone writes a recall of priority 8, waits 5 s, and the fake speaker must have rung at least three times; the story presses the wheel, and the phone must hear 11, and the speaker ring no more.
- rule: a « find me » from the phone beeps and buzzes the watch again and again, until a wheel press,
which tells the phone.
import asyncio
import companion
from air import the_air, start_watch, stop_watch, press
PROMISE = "a « find me » from the phone beeps and buzzes the watch again and again, until a wheel press, which tells the phone"
SENT = (2026, 10, 22, 7, 40)
RING_S = 5
QUIET_S = 5
def rings(speaker):
return len([r for r in speaker.read_text().splitlines() if r == "ring"]) if speaker.exists() else 0
async def story(work):
speaker = work / "speaker.log"
wheel = work / "wheel.log"
heard = []
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=work / "screen.log",
taps=work / "taps.log", speaker=speaker, motor=work / "motor.log",
wheel=wheel)
async with companion.linked(f"serial:{phone_pty}") as peer:
await companion.hear_media(peer, heard.append)
await companion.send_recall(peer, "find me", priority=8, when=SENT)
await asyncio.sleep(RING_S)
press(wheel)
for _ in range(100):
if heard:
break
await asyncio.sleep(0.1)
# a ring already started ends within its 500 ms
await asyncio.sleep(1)
rung = rings(speaker)
await asyncio.sleep(QUIET_S)
after = rings(speaker)
await stop_watch(watch, once=(speaker, "ring"))
print(f" rings: {rung} before the press, {after - rung} after; heard: {heard}")
return rung >= 3 and after == rung and heard == [11]
- what
- my phone rings in my bag; the watch keeps buzzing until I answer or miss the call.
- rule: a ringing call buzzes the watch again and again, until the phone says it stopped ringing.
- how: once linked, the phone writes a recall of priority 6, waits 7 s, then writes one of priority 0; the fake motor must have buzzed at least three times before the 0, and never after it, and the fake speaker never rung.
- rule: a ringing call buzzes the watch again and again, until the phone says it stopped ringing.
import asyncio
import companion
from air import the_air, start_watch, stop_watch
PROMISE = ("a ringing call buzzes the watch again and again, never rings it, until the phone says it"
" stopped ringing")
CALL = "Phone: Alice: incoming call"
SENT = (2026, 10, 22, 7, 40)
RING_S = 7
QUIET_S = 5
def buzzes(motor):
return len([b for b in motor.read_text().splitlines() if b == "buzz"]) if motor.exists() else 0
async def story(work):
speaker = work / "speaker.log"
motor = work / "motor.log"
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=work / "screen.log",
taps=work / "taps.log", speaker=speaker, motor=motor)
async with companion.linked(f"serial:{phone_pty}") as peer:
await companion.send_recall(peer, CALL, priority=6, when=SENT)
await asyncio.sleep(RING_S)
await companion.send_recall(peer, "", priority=0, when=SENT)
# a buzz already started ends within its 750 ms
await asyncio.sleep(1)
ringing = buzzes(motor)
await asyncio.sleep(QUIET_S)
after = buzzes(motor)
await stop_watch(watch, once=(motor, "buzz"))
rung = speaker.exists()
print(f" buzzes: {ringing} while ringing, {after - ringing} after the stop; rung: {rung}")
return ringing >= 3 and after == ringing and not rung
- what
- my phone gets several notifications at once; the watch buzzes once for them, not once each.
- rule: a notification coming within 10 s of the last buzz shows, and neither buzzes nor rings.
- why: « no need to vibrate for every notif when they arrive in batch »
- how: once linked, the phone writes three recalls of priority 4, one a second; the fake motor must have buzzed once, the fake speaker rung once, and the screen show all three.
- rule: a notification coming within 10 s of the last buzz shows, and neither buzzes nor rings.
import asyncio
import companion
from air import the_air, start_watch, stop_watch
PROMISE = "notifications coming together buzz and ring the watch once, and all show"
TEXTS = ("Mail: first", "Mail: second", "Mail: third")
SENT = (2026, 11, 2, 9, 0)
APART_S = 1
SETTLE_S = 3
def count(log, word):
return len([b for b in log.read_text().splitlines() if b == word]) if log.exists() else 0
async def story(work):
speaker = work / "speaker.log"
motor = work / "motor.log"
screen = work / "screen.log"
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen,
taps=work / "taps.log", speaker=speaker, motor=motor)
async with companion.linked(f"serial:{phone_pty}") as peer:
for text in TEXTS:
await companion.send_recall(peer, text, priority=4, when=SENT)
await asyncio.sleep(APART_S)
await asyncio.sleep(SETTLE_S)
await stop_watch(watch, once=(motor, "buzz"))
buzzed = count(motor, "buzz")
rung = count(speaker, "ring")
shown = screen.read_text() if screen.exists() else ""
seen = [text for text in TEXTS if text in shown]
print(f" buzzes: {buzzed}, rings: {rung}, shown: {len(seen)} of {len(TEXTS)}")
return buzzed == 1 and rung == 1 and len(seen) == len(TEXTS)
- what
- a call I do not want rings my phone; I refuse it from the watch.
- rule: a wheel press while a call rings stops the buzzing and tells the phone to refuse the call.
- how: once linked, the phone writes a recall of priority 6, waits 5 s, and the story presses the wheel; the phone must hear 12, and the fake motor buzz no more.
- rule: the press answers with a double buzz and « refusing the call », then shows the phone’s answer.
- why: « the preqs to hangoff must have more feedvack »
- how: the story’s phone answers 12 with « call refused » at priority 2, as mojito does; the screen must show both lines, and the motor a double buzz.
- rule: a wheel press while a call rings stops the buzzing and tells the phone to refuse the call.
import asyncio
import companion
from air import the_air, start_watch, stop_watch, press
PROMISE = ("a wheel press while a call rings stops the buzzing, tells the phone to refuse the call,"
" and answers with a double buzz and the phone's answer")
CALL = "Phone: Alice: incoming call"
ANSWER = "call refused"
SENT = (2026, 10, 22, 7, 40)
RING_S = 5
QUIET_S = 5
def buzzes(motor):
return len([b for b in motor.read_text().splitlines() if b == "buzz"]) if motor.exists() else 0
async def story(work):
speaker = work / "speaker.log"
motor = work / "motor.log"
wheel = work / "wheel.log"
screen = work / "screen.log"
heard = []
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen,
taps=work / "taps.log", speaker=speaker, motor=motor,
wheel=wheel)
async with companion.linked(f"serial:{phone_pty}") as peer:
await companion.hear_media(peer, heard.append)
await companion.send_recall(peer, CALL, priority=6, when=SENT)
await asyncio.sleep(RING_S)
press(wheel)
for _ in range(100):
if heard:
break
await asyncio.sleep(0.1)
await companion.send_recall(peer, ANSWER, priority=2, when=SENT)
# a buzz already started ends within its 750 ms
await asyncio.sleep(1)
rung = buzzes(motor)
await asyncio.sleep(QUIET_S)
after = buzzes(motor)
await stop_watch(watch, once=(motor, "double"))
doubles = motor.read_text().splitlines().count("double")
shown = screen.read_text()
told = "refusing the call" in shown and ANSWER in shown
print(f" buzzes: {rung} before the press, {after - rung} after; heard: {heard}; "
f"doubles: {doubles}; told: {told}")
return rung >= 3 and after == rung and heard == [12] and doubles == 1 and told
- what
- music plays on my phone in my pocket; I pause it, skip or go back from the watch.
- rule: while the phone plays, the watch shows its title; a tap on it opens a pad whose left, middle
and right go back, play or pause, and skip; the wheel closes it.
- how: once linked, the phone writes a recall of priority 7, « Song: Artist »; the screen’s media row must show it; after a wheel press, the story taps the row, and the pad must open; it taps high on the screen’s middle, left, then right, and the phone must hear 2, 1, then 3; a wheel press must close the pad.
- rule: while the phone plays, the watch shows its title; a tap on it opens a pad whose left, middle
and right go back, play or pause, and skip; the wheel closes it.
import asyncio
import companion
from air import the_air, start_watch, stop_watch, tap, press, written
PROMISE = "while the phone plays, the watch shows its title; a tap on it opens a pad whose left, middle and right go back, play or pause, and skip; the wheel closes it"
TITLE = "Song: Artist"
ROW_Y = 216
PAD_Y = 60
async def story(work):
screen = work / "screen.log"
taps = work / "taps.log"
wheel = work / "wheel.log"
heard = []
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen, taps=taps,
speaker=work / "speaker.log", motor=work / "motor.log",
wheel=wheel)
async with companion.linked(f"serial:{phone_pty}") as peer:
await companion.hear_media(peer, heard.append)
await companion.send_recall(peer, TITLE, priority=7)
await written(work / "screen.log.media", "media " + TITLE)
press(wheel)
tap(taps, 120, ROW_Y)
await written(work / "screen.log.media", "pad media")
for x in (120, 20, 220):
tap(taps, x, PAD_Y)
for _ in range(100):
if len(heard) == 3:
break
await asyncio.sleep(0.1)
press(wheel)
await stop_watch(watch, once=(work / "screen.log.media", "pad off"))
print(f" heard: {heard}")
return heard == [2, 1, 3]
- rule
- a title longer than its row scrolls along it, a letter every 300 ms, while the screen is lit,
so every word shows; dark, it stays still.
- why: « i want to see the whole audio track name, not a truncated version », « scroll »
- how: the phone plays a 58-letter title; the story taps every 3 s for 24 s to keep the screen lit, and each word must show whole in some row of 26 letters at most; once dark, no row may move.
import asyncio
import companion
from air import the_air, start_watch, stop_watch, tap, written
PROMISE = "a title longer than its row scrolls along it while the screen is lit, so every word shows; dark, it stays still"
TITLE = "A Very Long Song Title That Never Ends: The Endless Artist"
def rows(path):
return [line[4:] for line in path.read_text().splitlines() if line.startswith("row ")] if path.exists() else []
def darks(path):
return path.read_text().splitlines().count("dark") if path.exists() else 0
async def story(work):
screen = work / "screen.log"
taps = work / "taps.log"
title = work / "screen.log.title"
dark = work / "screen.log.dark"
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen, taps=taps,
speaker=work / "speaker.log", motor=work / "motor.log",
wheel=work / "wheel.log")
async with companion.linked(f"serial:{phone_pty}") as peer:
await companion.send_recall(peer, TITLE, priority=7)
await written(work / "screen.log.media", "media " + TITLE)
for _ in range(8):
tap(taps, 120, 100)
await asyncio.sleep(3)
before = darks(dark)
for _ in range(100):
if darks(dark) > before:
break
await asyncio.sleep(0.1)
still = len(rows(title))
await asyncio.sleep(2)
moved = len(rows(title)) - still
await stop_watch(watch, once=(work / "screen.log.media", "media " + TITLE))
shown = rows(title)
missing = [word for word in TITLE.split() if not any(word in row for row in shown)]
print(f" rows: {len(shown)}, missing words: {missing}, rows while dark: {moved}")
return bool(shown) and not missing and moved == 0 and all(len(row) <= 26 for row in shown)
- what
- my phone is somewhere in the flat, silent; I make it ring from the watch, and silence it once found.
- rule: holding the wheel, then a tap on the pad’s right, rings the phone, and the next tap stops it.
- how: once linked, the story holds the wheel and taps the actions pad’s right, and the phone must hear 9; then it taps once, and the phone must hear 10, and nothing more.
- rule: holding the wheel, then a tap on the pad’s right, rings the phone, and the next tap stops it.
import asyncio
import companion
from air import the_air, start_watch, stop_watch, tap, hold, written
PROMISE = "holding the wheel, then a tap on the pad's right, rings the phone, and the next tap stops it"
async def heard_at_least(heard, count):
for _ in range(100):
if len(heard) >= count:
return
await asyncio.sleep(0.1)
async def story(work):
screen = work / "screen.log"
taps = work / "taps.log"
wheel = work / "wheel.log"
heard = []
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=screen, taps=taps,
speaker=work / "speaker.log", motor=work / "motor.log",
wheel=wheel)
async with companion.linked(f"serial:{phone_pty}") as peer:
await companion.hear_media(peer, heard.append)
# the watch says the phone's own name, « <name> linked »
for _ in range(100):
if any(line.endswith(" linked") for line in screen.read_text().splitlines()):
break
await asyncio.sleep(0.1)
hold(wheel)
await written(work / "screen.log.media", "pad actions")
tap(taps, 200, 100)
await heard_at_least(heard, 1)
tap(taps, 120, 100)
await heard_at_least(heard, 2)
await asyncio.sleep(1)
await stop_watch(watch, once=(screen, "phone found"))
print(f" heard: {heard}")
return heard == [9, 10]
- what
- away from my phone for hours, the watch should not spend itself calling it.
- rule: unlinked, the watch calls the phone on one beat that four Android scans each hear within 30 s,
and still links.
- why: after a reset, the watch waited 9 min unlinked before oxenhead found it.
- how: no phone links; the story listens to the watch’s calls for 48 s: their gaps stay within a tenth of each other, and their beat, against each scan from any start, is heard within 30 s; then the phone links.
- what: the four scans, listening a window in each interval: 0.5 s in 5 s, 0.512 s in 10.24 s, 0.14 s in 1.4 s, 0.183 s in 0.73 s.
- what: bumble’s controller reads the interval’s 0.625 ms units as ms: 1.34 s comes 2.14 s apart.
- rule: unlinked, the watch calls the phone on one beat that four Android scans each hear within 30 s,
and still links.
import asyncio
import companion
from air import the_air, start_watch, stop_watch
PROMISE = "unlinked, the watch calls the phone on one beat that four Android scans each hear within 30 s, and still links"
LISTEN_S = 48
BUMBLE = 1.6
SCANS = ((500, 5000), (512, 10240), (140, 1400), (183, 730))
HEARD_WITHIN_S = 30
async def heard(transport):
loop = asyncio.get_running_loop()
heard = []
async with await companion.open_transport(transport) as hci:
device = companion.Device.with_hci("phone", companion.Address("F0:F1:F2:F3:F4:F5"),
hci.source, hci.sink)
await device.power_on()
def on_advertisement(advertisement):
name = advertisement.data.get(companion.AdvertisingData.Type.COMPLETE_LOCAL_NAME)
if name == companion.NAME:
heard.append(loop.time())
device.on("advertisement", on_advertisement)
await device.start_scanning(filter_duplicates=False)
await asyncio.sleep(LISTEN_S)
await device.stop_scanning()
return [later - earlier for earlier, later in zip(heard, heard[1:])]
def longest_wait(beat_ms, window_ms, interval_ms, horizon_ms=600_000):
longest = 0
for start in range(0, interval_ms, 7):
call = start
while call < horizon_ms and call % interval_ms >= window_ms:
call += beat_ms
longest = max(longest, call - start)
return longest / 1000
async def story(work):
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=work / "screen.log",
taps=work / "taps.log", speaker=work / "speaker.log",
motor=work / "motor.log")
gaps = await heard(f"serial:{phone_pty}")
async with companion.linked(f"serial:{phone_pty}"):
pass
await stop_watch(watch, once=(work / "screen.log", "waiting for the phone"))
print(f" gaps: {' / '.join(str(round(gap, 1)) for gap in gaps)}")
if len(gaps) < 3:
return False
beat = round(sum(gaps) / len(gaps) / BUMBLE, 2)
waits = [longest_wait(round(beat * 1000), window, interval) for window, interval in SCANS]
print(f" beat: {beat} s, longest waits: {' / '.join(map(str, waits))}")
return max(gaps) < 1.1 * min(gaps) and max(waits) <= HEARD_WITHIN_S
- what
- linked and idle, the watch and the phone should talk as rarely as the link allows.
- rule: once the phone sets its low-power link, the watch asks to meet it every second, skipping none.
- why: « focus on preserving the battery of the phone while ensuring the connection remains possible »: the phone wakes at every meeting, skips or not, so only a longer interval spares it.
- what: a notification then waits at most a second, as the user allows.
- what: Android’s low-power link exchanges every 100 to 125 ms and lets the watch skip 2; the phone app can ask nothing slower.
- how: the phone links, sets 100 to 125 ms with 2 skips and 5 s of silence allowed, as Android does; within 10 s the link must meet every 1000 ms.
- rule: once the phone sets its low-power link, the watch asks to meet it every second, skipping none.
import asyncio
import companion
from air import the_air, start_watch, stop_watch
PROMISE = "once the phone sets its low-power link, the watch asks to meet it every second, skipping none"
EVERY_MS = 1000
WITHIN_S = 10
async def story(work):
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=work / "screen.log",
taps=work / "taps.log", speaker=work / "speaker.log",
motor=work / "motor.log")
async with companion.linked(f"serial:{phone_pty}") as peer:
connection = peer.connection
await connection.update_parameters(100, 125, 2, 5000)
for _ in range(WITHIN_S * 10):
if round(connection.parameters.connection_interval) == EVERY_MS:
break
await asyncio.sleep(0.1)
parameters = connection.parameters
await stop_watch(watch, once=(work / "screen.log", "waiting for the phone"))
print(f" link: every {parameters.connection_interval} ms, skips {parameters.peripheral_latency}")
return round(parameters.connection_interval) == EVERY_MS and parameters.peripheral_latency == 0
- rule
- each pace the link takes, the watch writes in light.log: how many ms between exchanges, and
how many it may skip.
- why: « focus on preserving the battery of the phone while ensuring the connection remains possible »: nothing on the phone shows the pace, so the watch’s log is where to read it.
- how: the phone links and sets 100 to 125 ms with 2 skips; light.log must then show « pace every 1000 skips 0 ».
import asyncio
import companion
from air import the_air, start_watch, stop_watch
PROMISE = "each pace the link takes, the watch writes in light.log: how many ms between exchanges, and how many it may skip"
WANTED = ["pace", "every", "1000", "skips", "0"]
WITHIN_S = 10
def paces(trace):
kept = trace.read_text().splitlines() if trace.exists() else []
return [line.split()[1:6] for line in kept if line.split()[1:2] == ["pace"]]
async def story(work):
trace = work / "light.log"
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=work / "screen.log",
taps=work / "taps.log", speaker=work / "speaker.log",
motor=work / "motor.log")
async with companion.linked(f"serial:{phone_pty}") as peer:
await peer.connection.update_parameters(100, 125, 2, 5000)
for _ in range(WITHIN_S * 10):
if WANTED in paces(trace):
break
await asyncio.sleep(0.1)
await stop_watch(watch, once=(work / "screen.log", "waiting for the phone"))
seen = paces(trace)
print(f" paces: {seen}")
return WANTED in seen
- rule
- each pace the watch asks, it writes in light.log whether the ask went out, or its error.
- why: on oxenhead the 1 s ask never takes, and a missing firmware patch or a refusal look alike.
- how: the phone links and sets 100 to 125 ms with 2 skips; light.log must then show « pace ask ok ».
import asyncio
import companion
from air import the_air, start_watch, stop_watch
PROMISE = "each pace the watch asks, it writes in light.log whether the ask went out, or its error"
WANTED = ["pace", "ask", "ok"]
WITHIN_S = 10
def asks(trace):
kept = trace.read_text().splitlines() if trace.exists() else []
return [line.split()[1:] for line in kept if line.split()[1:3] == ["pace", "ask"]]
async def story(work):
trace = work / "light.log"
async with the_air(work) as (watch_pty, phone_pty):
watch = await start_watch(watch_pty, mic=work / "none.wav", screen=work / "screen.log",
taps=work / "taps.log", speaker=work / "speaker.log",
motor=work / "motor.log")
async with companion.linked(f"serial:{phone_pty}") as peer:
await peer.connection.update_parameters(100, 125, 2, 5000)
for _ in range(WITHIN_S * 10):
if any(words[:3] == WANTED for words in asks(trace)):
break
await asyncio.sleep(0.1)
await stop_watch(watch, once=(work / "screen.log", "waiting for the phone"))
seen = asks(trace)
print(f" asks: {seen}")
return any(words[:3] == WANTED for words in seen)
- what
- from the desktop, I reach the watch through the phone’s relay, and leave it running after.
- rule: a desktop that leaves the relay with bytes still waiting leaves none for the watch’s next link.
- what: bytes left waiting reach the watch’s REPL as keys at its next link; a Ctrl-C among them
stops its
main.py, leaving it dead to taps and phone. - how: the story runs the phone’s companion with its android parts faked; a desktop sends a line
while the watch is away and leaves; then the watch links and must be written nothing on
REPL_IN.
- what: bytes left waiting reach the watch’s REPL as keys at its next link; a Ctrl-C among them
stops its
- rule: a desktop that leaves the relay with bytes still waiting leaves none for the watch’s next link.
import asyncio
import socket
import sys
import threading
import types
from unittest import mock
PROMISE = "a desktop that leaves the relay with bytes still waiting leaves none for the watch's next link"
class Dispatcher:
gatt = None
_ble = None
def __init__(self):
self.written = []
def write_characteristic(self, characteristic, data):
self.written.append((characteristic, bytes(data)))
def close_gatt(self):
pass
def _set_queue(self):
pass
def android_fakes():
able = types.ModuleType("able")
able.GATT_SUCCESS = 0
able.BluetoothDispatcher = Dispatcher
filters = types.ModuleType("able.filters")
filters.DeviceNameFilter = lambda name: name
broadcast = types.ModuleType("android.broadcast")
broadcast.BroadcastReceiver = mock.MagicMock()
osc = types.ModuleType("helpers.osc")
osc.oschandler = lambda *args, **kwargs: lambda handler: handler
osc.to_app = lambda *args: None
jnius = types.ModuleType("jnius")
jnius.autoclass = mock.MagicMock()
return {"able": able, "able.filters": filters, "android": types.ModuleType("android"),
"android.broadcast": broadcast, "helpers": types.ModuleType("helpers"), "helpers.osc": osc,
"jnius": jnius}
def free_port():
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
async def said(lines, words):
async with asyncio.timeout(10):
while not any(words in line for line in lines):
await asyncio.sleep(0.1)
async def desktop6(port, data):
await desktop(port, data, host="::1")
async def desktop(port, data, host="127.0.0.1"):
async with asyncio.timeout(10):
while True:
try:
_, writer = await asyncio.open_connection(host, port)
break
except OSError:
await asyncio.sleep(0.1)
writer.write(data)
await writer.drain()
writer.close()
await writer.wait_closed()
async def story(work):
with mock.patch.dict(sys.modules, android_fakes()):
import twatch
twatch.RELAY_PORT = free_port()
twatch.show_playing = lambda: None
twatch.link_state = lambda state: None
lines = []
companion = twatch.Companion(lines.append)
threading.Thread(target=companion.serve_relay, daemon=True).start()
await desktop(twatch.RELAY_PORT, b"print('left behind')\r\n")
await said(lines, "relay closed")
companion.steps.clear()
companion.services = mock.MagicMock()
companion.services.search.side_effect = lambda uuid: mock.MagicMock(name=uuid)
companion.next_step()
sent = [data for target, data in companion.written if target is companion.repl_in]
print(f" said: {' / '.join(lines)}")
print(f" sent at the link: {sent}")
return not sent
- rule
- a desktop reaching the phone by its name over IPv6 gets the relay too.
- why: the phone’s name now resolves to IPv6 only, and a relay heard on IPv4 alone refused it.
- how: the story runs the phone’s companion with its android parts faked; a desktop connects on
::1and the companion must say it relays for it.
import sys
import threading
from unittest import mock
import relay
PROMISE = "a desktop reaching the phone by its name over IPv6 gets the relay too"
async def story(work):
with mock.patch.dict(sys.modules, relay.android_fakes()):
import twatch
twatch.RELAY_PORT = relay.free_port()
twatch.show_playing = lambda: None
twatch.link_state = lambda state: None
lines = []
companion = twatch.Companion(lines.append)
threading.Thread(target=companion.serve_relay, daemon=True).start()
try:
await relay.desktop6(twatch.RELAY_PORT, b"")
await relay.said(lines, "relaying the REPL for ::1")
except TimeoutError:
pass
print(f" said: {' / '.join(lines)}")
return any("relaying the REPL for ::1" in line for line in lines)
- rule
- bluetooth switched off then back on, the phone looks for the watch again, without an app restart.
- how: the story runs the companion with able faked as able behaves: android’s error at bluetooth’s off leaves the dispatcher’s ble broken; once bluetooth is back on, the companion must start a scan.
import asyncio
import sys
from unittest import mock
import relay
PROMISE = "bluetooth switched off then back on, the phone looks for the watch again, without an app restart"
class Broken:
def __init__(self, msg):
self.msg = msg
def __getattr__(self, name):
raise Exception(self.msg)
class Ble:
def __init__(self):
self.scans = 0
def startScan(self, *args):
self.scans += 1
def stopScan(self):
pass
class Dispatcher(relay.Dispatcher):
def __init__(self):
super().__init__()
self._ble = Ble()
def on_error(self, msg):
self._ble = Broken(msg)
raise Exception(msg)
def start_scan(self, *args, **kwargs):
self._ble.startScan()
def stop_scan(self):
self._ble.stopScan()
async def story(work):
fakes = relay.android_fakes()
fakes["able"].BluetoothDispatcher = Dispatcher
with mock.patch.dict(sys.modules, fakes):
import twatch
twatch.ADAPTER_WAIT_S = 0.1
twatch.SEARCH_AGAIN_S = 0.5
twatch.link_state = lambda state: None
twatch.connected_watch = lambda: None
lines = []
companion = twatch.Companion(lines.append)
ble = companion._ble
try:
companion.on_error("BLE adapter is not enabled")
except Exception:
pass
companion.on_bluetooth_adapter_state_change(twatch.ADAPTER_ON)
await asyncio.sleep(0.4)
companion.search += 1
await asyncio.sleep(0.3)
print(f" said: {' / '.join(lines)}")
print(f" scans started: {ble.scans}")
return ble.scans == 1
- rule
- a watch that drops the phone just after « ready » is not linked again before the scan pause.
- why: the watch, another phone chosen, drops the one that says « ready »; that phone linked again at once, every 5 s for an hour, at 259 s of CPU.
- how: the story runs the companion with the watch always in sight; it links, says « ready », is dropped, and must not link again until the scan pause is over.
import asyncio
import sys
from unittest import mock
import relay
PROMISE = "a watch that drops the phone just after « ready » is not linked again before the scan pause"
class Dispatcher(relay.Dispatcher):
def __init__(self):
super().__init__()
self.links = 0
def connect_gatt(self, device):
self.links += 1
def start_scan(self, *args, **kwargs):
pass
def stop_scan(self):
pass
async def story(work):
fakes = relay.android_fakes()
fakes["able"].BluetoothDispatcher = Dispatcher
with mock.patch.dict(sys.modules, fakes):
import twatch
twatch.SCAN_PAUSE_S = 0.5
twatch.SEARCH_AGAIN_S = 2
twatch.link_state = lambda state: None
twatch.show_playing = lambda: None
watch = mock.MagicMock()
watch.getName.return_value = twatch.NAME
twatch.connected_watch = lambda: watch
lines = []
companion = twatch.Companion(lines.append)
companion.receive_one()
companion.steps.clear()
companion.services = mock.MagicMock()
companion.services.search.side_effect = lambda uuid: mock.MagicMock(name=uuid)
companion.next_step()
companion.on_connection_state_change(twatch.GATT_SUCCESS, False)
await asyncio.sleep(0.2)
soon = companion.links
await asyncio.sleep(0.6)
later = companion.links
companion.search += 1
print(f" said: {' / '.join(lines)}")
print(f" links: {soon} within 0.2 s, {later} after the pause")
return soon == 1 and later == 2
- rule
- bluetooth switched off while the phone looks for the watch, the search pauses and comes back, without a crash.
- why: on 31 Oct at 09:01, oxenhead’s bluetooth off, stopping the scan raised « BT Adapter is not turned ON » and killed the search thread.
import asyncio
import sys
import threading
from unittest import mock
import relay
PROMISE = "bluetooth switched off while the phone looks for the watch, the search pauses and comes back, without a crash"
class Dispatcher(relay.Dispatcher):
def __init__(self):
super().__init__()
self.started = 0
def start_scan(self, *args, **kwargs):
self.started += 1
def stop_scan(self):
raise Exception("BT Adapter is not turned ON")
async def story(work):
fakes = relay.android_fakes()
fakes["able"].BluetoothDispatcher = Dispatcher
with mock.patch.dict(sys.modules, fakes):
import twatch
twatch.SEARCH_AGAIN_S = 0.3
twatch.SCAN_PAUSE_S = 0.2
twatch.link_state = lambda state: None
twatch.connected_watch = lambda: None
crashes = []
hook = threading.excepthook
threading.excepthook = lambda args: crashes.append(repr(args.exc_value))
try:
lines = []
companion = twatch.Companion(lines.append)
companion.receive_one()
await asyncio.sleep(0.8)
companion.search += 1
finally:
threading.excepthook = hook
print(f" said: {' / '.join(lines)}")
print(f" scans: {companion.started}, crashes: {crashes}")
return not crashes and companion.started >= 2
- what
- later, I go for a walk and start recording my gnss location, to get the track afterwards.
- rule: after a walk, I get the gnss track the watch recorded.
- scope
- others will come, and these are a good start.
- rule
- the hour and the track can drop when they cost me the voice or the recall.
which OS to run on the watch
- how
- I look at what already exists before deciding.
- what
- the choice is an existing OS, or my own.
- rule
- the watch runs plain micropython, carrying the pdm patch1.
- what
- my previous attempt at aggregating my ESP work is my micro python distribution.
- how: if I implement my own stuff, it is a thing to extend and reuse.
the hardware it has to fit
- what
- this board is known to me only from a vendor announcement as reported, neither a datasheet nor a measurement of my own2.
- what
- the board is a t-watch-s3 plus.
- what
- the screen is 1.54 inch, 240x240, capacitive touch.
- what
- the cell holds 940mAh.
- what
- the voice comes in through a built-in pdm microphone.
- what
- the audio goes out through a MAX98357A class D amplifier.
- what
- the place comes from a MIA-M10Q multi-GNSS receiver.
the constraints
- rule
- it is easy to maintain.
- rule
- micropython, by a wide preference.
- rule
- it is compatible with the hardware, screen included.
- why: micropython that displays nothing on the screen would be stupid.
- rule
- the battery is spared hardcore.
- rule: one week on a charge.
- rule
- an existing project is a plus.
- rule
- writing my own stuff does not bother me.
what already exists
- rule
- each project is read against my stories and my constraints, not against its own goals.
- scope: how easy it is to maintain is not read off anything — that will be a subjective feeling when I try them.
- what
- freedom here is being free to adapt the tool to whatever strange use I want, without fighting
a project too specific for it.
- example: infinitime is awesome, but changing the way a notification appears is long work and long discussions with its team.
- scope
- every cell is read off what a project publishes — a doc page, a config, a repo, somebody in a
thread — and nothing here was run on my board.
- scope: the battery column holds what a project offers for sleeping, never a measured draw.
- what
- « not said » is the project not saying, and never a « no ».
- what
- the vendor names the s3 and the s3 plus apart3, the plus being the one that gets the gnss receiver and the 940mAh cell2.
| project | freedom | micropython | hardware | voice | audio | talks to my phone | battery | gnss |
|---|---|---|---|---|---|---|---|---|
| plain micropython1 | total | yes | power and screen exist, s3 | not through machine.I2S | i2s transmit | ble central and peripheral | lightsleep and deepsleep | not said |
| circuitpython5 | my code on its runtime | no, circuitpython | official board, s3 only | yes on latest, no on stable | amp pins named | native ble on s3 | light and deep sleep | not said |
| lilygolib3 | its c api | no, c | the vendor’s own, s3 and plus | record wav to a buffer | play wav example | ble not wrapped | light sleep, five wake examples | gps examples |
| esphome4 | its yaml components | no, yaml | listed as features, s3 | mic yaml, voice assistant | media player yaml | not said | deep sleep | not said |
| meshtastic6 | its protocol and purpose | no, c++ | supported, s3 only | codec2, sent to the mesh | codec2, sent to the mesh | its own protobuf api | light sleep, lora on, ble off | gps in light sleep |
| zephyr7 | my c on its kernel | should work | upstream board, s3 only | mic listed, no node | amp listed, no node | ble hci on-chip | light sleep auto, deep forced | u-blox m10 driver |
- therefore
- speaking into the watch under micropython means carrying a patched firmware, machine.I2S having no pdm and the pull request that adds it being open since 20241.
- what
- the light sleep that spares the battery turns the radio off in those examples, and the recall
arrives over it3, 6.
- therefore: here the chip light-sleeps with the ble controller in modem sleep, waking for each connection event, as the watch’s firmware builds it; whether the link holds is yet to see.
the android companion
- rule
- something on the phone receives what I said, and sends the recall.
- what
- the choice is an existing app, or my own.
the test harness
- rule
- the stories above get run, as far as the note goes.
- how
- under test, the watch is micropython’s unix port, built with ble through nimble.
- why: the voice and the recall cross ble, which espressif’s qemu does not emulate.
- how
- under test, the companion runs on the desktop, on google’s bumble ble stack.
- how
- the two meet over two bumble virtual controllers sharing one simulated air, each on a pty.
- why: it needs no root and no radio, where bluez’s virtual controller needs
/dev/vhci.
- why: it needs no root and no radio, where bluez’s virtual controller needs
- scope
- the unix port reproduces the ble link and my code, not the chip.
- what: the pdm microphone is a fake that plays a wav file.
- what: the screen, the i2s amplifier and the sleep are fakes too.
- therefore: a green says nothing about them; the real watch checks them.
- how
- one command builds what it needs, plays every story, and prints each one’s promise, green or red.
#!/usr/bin/env nix-shell #!nix-shell -i bash harness-shell.nix set -eu here=$(dirname "$(readlink -f "$0")") micropython=$(nix-build "$here/micropython-ble.nix" --no-out-link) TWATCH_MICROPYTHON=$micropython/bin/micropython exec python3 -u "$here/harness/run_stories.py" "$@" - how
- the watch’s micropython is nixpkgs’ own, with ble switched on through nimble, its own test suite
skipped.
- why: btstack wants an
mpbthciport.hthe unix port lacks, where nimble builds.
(import ./pins.nix).nixpkgs.micropython.overrideAttrs (old: { pname = "micropython-ble"; patches = (old.patches or [ ]) ++ [ ./patches/gap-update.patch ]; patchFlags = [ "-p1" "--fuzz=0" ]; makeFlags = old.makeFlags ++ [ "MICROPY_PY_BLUETOOTH=1" "MICROPY_BLUETOOTH_NIMBLE=1" ]; hardeningDisable = [ "format" ]; doCheck = false; }) - why: btstack wants an
- how
- the harness itself is python, with bumble, from the same pinned nixpkgs.
let pkgs = (import ./pins.nix).nixpkgs; in pkgs.mkShell { buildInputs = [ (pkgs.python3.withPackages (p: [ p.bumble ])) ]; } - how
- the desktop’s nixos imports one module: it lets me open the bluetooth radio and the watch’s usb
port without root, and loads the driver that gives the watch its
/dev/ttyACM0.- why: « use nixos conf », not a
sudo modprobegone at the next boot.
{ pkgs, ... }: { boot.kernelModules = [ "cdc_acm" ]; services.udev.packages = [ (pkgs.writeTextFile { name = "twatchs3-radio"; destination = "/etc/udev/rules.d/70-twatchs3-radio.rules"; text = '' SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTR{idProduct}=="b85b", TAG+="uaccess" SUBSYSTEM=="tty", ATTRS{idVendor}=="303a", TAG+="uaccess" ''; }) ]; } - why: « use nixos conf », not a
- how
- each story plays in a fresh directory, and a red names the promise it broke.
- rule: a story still running after 2 min is red, and the run goes on; stories named on the command line play alone.
import asyncio
import sys
import tempfile
from pathlib import Path
root = Path(__file__).resolve().parent.parent
sys.path[:0] = [str(root / "harness"), str(root / "phone"), str(root / "stories"), str(root / "android")]
import accents # noqa: E402
import adapter # noqa: E402
import awake # noqa: E402
import bounce # noqa: E402
import btoff # noqa: E402
import call # noqa: E402
import batch # noqa: E402
import chosen # noqa: E402
import charge # noqa: E402
import chargelog # noqa: E402
import crash # noqa: E402
import drop # noqa: E402
import find # noqa: E402
import first # noqa: E402
import findme # noqa: E402
import firmware # noqa: E402
import resumed # noqa: E402
import hour # noqa: E402
import icons # noqa: E402
import kept # noqa: E402
import low # noqa: E402
import notify # noqa: E402
import nowifi # noqa: E402
import pace # noqa: E402
import sparse # noqa: E402
import paced # noqa: E402
import asked # noqa: E402
import phone # noqa: E402
import picture # noqa: E402
import plug # noqa: E402
import fresh # noqa: E402
import heard # noqa: E402
import light # noqa: E402
import lightlog # noqa: E402
import lost # noqa: E402
import media # noqa: E402
import ntfy # noqa: E402
import quiet # noqa: E402
import reach # noqa: E402
import recall # noqa: E402
import refresh # noqa: E402
import refuse # noqa: E402
import restore # noqa: E402
import relay # noqa: E402
import relay6 # noqa: E402
import stall # noqa: E402
import start # noqa: E402
import down # noqa: E402
import rectick # noqa: E402
import stay # noqa: E402
import scene # noqa: E402
import still # noqa: E402
import pad_still # noqa: E402
import title # noqa: E402
import tick # noqa: E402
import tints # noqa: E402
import torch # noqa: E402
import turn # noqa: E402
import voice # noqa: E402
import wake # noqa: E402
import wifi # noqa: E402
STORIES = [voice, kept, stall, hour, recall, quiet, tints, drop, low, notify, ntfy, accents, torch, start, refresh, plug, wifi,
nowifi, phone, fresh, light, awake, wake, down, tick, rectick, pace, sparse, paced, asked, call, batch, refuse, media, title, find, findme, picture, icons, crash, chosen,
stay, scene, still, pad_still, reach, lightlog, heard, charge, chargelog, lost, turn, first, relay, relay6, adapter, bounce,
btoff, firmware, resumed, restore]
STORY_LIMIT_S = 120
async def main(names):
reds = 0
for story in [story for story in STORIES if story.__name__ in names] or STORIES:
with tempfile.TemporaryDirectory() as work:
try:
async with asyncio.timeout(STORY_LIMIT_S):
green = await story.story(Path(work))
except Exception as error:
green = False
print(f" {type(error).__name__}: {error}")
print(f"{'green' if green else 'red '} :: {story.PROMISE}")
reds += not green
return reds
sys.exit(asyncio.run(main(sys.argv[1:])))
- how
- the air is two bumble controllers linked together, one pty for the watch, one for the phone.
import asyncio
import contextlib
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
async def appears(path):
for _ in range(100):
if path.exists():
return
await asyncio.sleep(0.05)
raise TimeoutError(path)
@contextlib.asynccontextmanager
async def the_air(work):
watch_pty, phone_pty = work / "watch.pty", work / "phone.pty"
controllers = await asyncio.create_subprocess_exec(
sys.executable, "-I", str(ROOT / "harness" / "controllers.py"),
f"pty:{watch_pty}", f"pty:{phone_pty}",
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
await appears(watch_pty)
await appears(phone_pty)
yield watch_pty, phone_pty
finally:
controllers.terminate()
await controllers.wait()
- how
- bumble’s controllers answer a link’s change of pace, which they ignore alone: both ends hear
it took the pace and skips asked.
- why: the story « sparse » needs the link to change pace; bumble’s own controller lets the ask time out.
import asyncio
import sys
from bumble import hci
from bumble.controller import Controller
from bumble.link import LocalLink
from bumble.transport import open_transport
class PacedController(Controller):
def on_hci_le_connection_update_command(self, command):
connection = self.find_le_connection_by_handle(command.connection_handle)
if connection is None:
self._send_hci_command_status(hci.HCI_ErrorCode.UNKNOWN_CONNECTION_IDENTIFIER_ERROR,
command.op_code)
return
self._send_hci_command_status(hci.HCI_COMMAND_STATUS_PENDING, command.op_code)
ends = [(self, connection)]
peer = self.link.find_le_controller(connection.peer_address)
if peer is not None and connection.self_address in peer.le_connections:
ends.append((peer, peer.le_connections[connection.self_address]))
for controller, end in ends:
controller.send_hci_packet(hci.HCI_LE_Connection_Update_Complete_Event(
status=hci.HCI_ErrorCode.SUCCESS, connection_handle=end.handle,
connection_interval=command.connection_interval_max,
peripheral_latency=command.max_latency,
supervision_timeout=command.supervision_timeout))
async def main(names):
link = LocalLink()
transports = [await open_transport(name) for name in names]
for index, transport in enumerate(transports):
PacedController(f"C{index}", host_source=transport.source, host_sink=transport.sink, link=link)
await asyncio.get_running_loop().create_future()
asyncio.run(main(sys.argv[1:]))
- how
- the watch is its own micropython process, finding the fakes where the watch has hardware,
its microphone hearing the file the story names, its touch reading taps from the file it names, its
screen, speaker and motor writing to the files it names, its battery at the percent it names, charging
while the file it names exists, its wifi joining unless it names no network, its errors kept beside
its screen’s file unless it names another, and its chosen phone beside it too.
- why: it runs from the fakes’ directory, lest a stray
main.pywhere the harness started be the one it runs.
async def start_watch(watch_pty, mic, screen, taps, speaker, motor, battery=80, dark=False, wheel=None, charger=None, network=True, errors=None): return await asyncio.create_subprocess_exec( os.environ["TWATCH_MICROPYTHON"], str(ROOT / "watch" / "main.py"), cwd=ROOT / "fake", env={**os.environ, "MICROPYPATH": str(ROOT / "fake"), "MICROPYBTUART": str(watch_pty), "TWATCH_FAKE_MIC": str(mic), "TWATCH_FAKE_SCREEN": str(screen), "TWATCH_FAKE_TAPS": str(taps), "TWATCH_FAKE_BATTERY": str(battery), "TWATCH_FAKE_SPEAKER": str(speaker), "TWATCH_FAKE_MOTOR": str(motor), "TWATCH_FAKE_DARK": "1" if dark else "", "TWATCH_FAKE_WHEEL": str(wheel) if wheel else "", "TWATCH_FAKE_CHARGER": str(charger) if charger else "", "TWATCH_FAKE_NO_NETWORK": "" if network else "1", "TWATCH_FAKE_ERRORS": str(errors or Path(screen).with_name("errors.log")), "TWATCH_FAKE_PHONE": str(Path(screen).with_name("phone.txt"))}) - why: it runs from the fakes’ directory, lest a stray
- how
- the story waits, up to 10 s unless it names longer, for a fake to write a line.
async def written(path, line, seconds=10): for _ in range(seconds * 20): if path.exists() and line in path.read_text().splitlines(): return await asyncio.sleep(0.05) raise TimeoutError(f"{path.name} never said {line}") - how
- the story taps the watch’s screen when it wants, by adding the tap to the fake touch’s file.
def tap(taps, x, y): with taps.open("a") as log: log.write(f"{x},{y}\n") - how
- or a tap lifted before the watch looked, « lost ».
def lose(taps): with taps.open("a") as log: log.write("lost\n") - how
- or a finger landed and still held, « down »; the next tap is its lift.
def land(taps): with taps.open("a") as log: log.write("down\n") - how
- likewise, the story presses the wheel by adding a line to the fake wheel’s file, « press », or
holds it, « long ».
def press(wheel): with wheel.open("a") as log: log.write("press\n") def hold(wheel): with wheel.open("a") as log: log.write("long\n") - how
- the watch never ends by itself, so the story stops it once a fake has written the line it waits
for.
async def stop_watch(watch, once): try: await written(*once) finally: watch.terminate() await watch.wait() - how
- what the fake microphone hears is half a second of a 440 Hz tone, 8 kHz, 16 bits, mono.
import math import struct import wave def spoken_thought(path): rate = 8000 frames = b"".join( struct.pack("<h", int(12000 * math.sin(2 * math.pi * 440 * n / rate))) for n in range(rate // 2)) with wave.open(str(path), "wb") as wav: wav.setnchannels(1) wav.setsampwidth(2) wav.setframerate(rate) wav.writeframes(frames)
the real trial
- what
- the voice story, played on the real watch, with this desktop as the phone.
- how
- from a terminal, the watch on usb:
/var/run/user/1000/twatchs3/play-voice.sh. - how
- it plays as drawn, the firmware built and flashed as the watch’s firmware says.
- scope
- the watch then boots into the voice at each restart;
deploy-mic-test.shputs the mic test back.
- how
- one script plays both the watch’s side and the phone’s.
#!/usr/bin/env nix-shell #!nix-shell -i bash device-shell.nix set -eu here=$(dirname "$(readlink -f "$0")") port=${1:-/dev/ttyACM0} "$here/deploy-watch.sh" "$port" mpremote connect "$port" cp "$here/watch/main.py" :main.py + run "$here/watch/updated.py" + reset exec "$here/receive-voice.sh" "$here/inbox"
the code
- rule
- the code is whichever of the watch and the companion turns out to be mine.
- rule
- every block tangles under
/var/run/user/1000/twatchs3.- why: the note is the source, and what it tangles is a by-product, so a reboot emptying that directory costs only a new tangle and a rebuild.
- how:
org-babel-tangle, C-c C-v t, on this note writes them, and each script runs from a terminal by its path there.
- rule
- every build is reproducible: each of its inputs is pinned by revision and hash.
rec { # micropython 1.27.0, bumble, and the unix port the harness runs nixpkgs = import (fetchTarball { url = "https://github.com/NixOS/nixpkgs/archive/02c087f3fe673c9e317f206a627cb6b910336dbe.tar.gz"; sha256 = "1l60ar0j7j7jrrg4r7j4kscrih0qn2n0lslffzflhv86lyq650r2"; }) { }; # esp-idf 5.5.2 for the esp32-s3, on the nixpkgs it was made with esp-dev = fetchTarball { url = "https://github.com/mirrexagon/nixpkgs-esp-dev/archive/5287d6e1ca9e15ebd5113c41b9590c468e1e001b.tar.gz"; sha256 = "12mhbc5r5k5ng2361184blfgn58hiqpla7mjrk6ppy0xjsnpaqa1"; }; esp-pkgs = import (fetchTarball { url = "https://github.com/NixOS/nixpkgs/archive/d351d0653aeb7877273920cd3e823994e7579b0b.tar.gz"; sha256 = "049hhh8vny7nyd26dfv7i962jpg18xb5bg6cv126b8akw5grb0dg"; }) { overlays = [ (import "${esp-dev}/overlay.nix") ]; config.permittedInsecurePackages = [ "python3.13-ecdsa-0.19.1" ]; }; # russ hughes' st7789 driver in pure micropython, with its bitmap fonts st7789py = fetchTarball { url = "https://github.com/russhughes/st7789py_mpy/archive/7265925bd0c092e8105200d18b2dba9dfbc12c27.tar.gz"; sha256 = "0yapxrh99kx0xm943qyrb50san7mi9f0vm5yhyf1yhyd9dn788yv"; }; }
the watch’s firmware
- what
- the watch runs micropython 1.27 for the esp32-s3 with octal psram, carrying the pdm pull
request.
- how: its two code commits are fetched by hash, the second’s
ESP_ERROR_CHECKwrittencheck_esp_err, as micropython 1.27 names it. - how: between two looks, the chip light-sleeps on its own, meant to keep its ble link; not yet tried.
- how: freertos idles tickless, and
machine.freq, which boot.py calls, lets the chip sleep, unless its second argument, which this build adds, says no. - how: the ble controller sleeps between its connection events, clocked by the main crystal.
- why: the watch has no 32 kHz crystal wired to the chip; the main one stays powered in sleep, a few mA, against tens awake.
- scope: on usb, the serial port keeps it awake, so the repl stays usable there.
- how: micropython’s sleep_ms waits its span one 10 ms tick at a time, while the chip sleeps only
when no task runs for 3 ticks8; so its wait asks for all the whole ticks left but one at once.
- why: « It was inc’edible hpw the pçnetime could be used one week wçthoit charge. by contrast, the micropython os was only one day »
- how: freertos idles tickless, and
- how: the flash holds two firmware slots, each as large as micropython’s single one9, the files
after them, meant to let a new firmware go over the phone into the idle slot; that transfer is not yet
written.
- why: « i’m far from the desktop. can we flasn anyway? »
- how:
BLE.gap_update(conn, min_us, max_us, skips, timeout_ms)asks the phone for a new pace on a link the watch holds, asgap_connectsets one on a link it starts; the harness’s micropython carries it too.- why: micropython lets a peripheral change nothing of its link; nimble’s
ble_gap_update_paramsdoes. - how: each change to micropython’s C is a diff here, applied to the pinned source; a diff that no
longer applies stops the build.
- why: « how will we ensure reproducibility and maintaonability ? »
- why: micropython lets a peripheral change nothing of its link; nimble’s
- how: its two code commits are fetched by hash, the second’s
--- a/extmod/modbluetooth.h
+++ b/extmod/modbluetooth.h
@@ -350,6 +350,8 @@
// Disconnect from a central or peripheral.
int mp_bluetooth_gap_disconnect(uint16_t conn_handle);
+int mp_bluetooth_gap_update(uint16_t conn_handle, int32_t min_conn_interval_us, int32_t max_conn_interval_us, uint16_t latency, int32_t supervision_timeout_ms);
+
// Set/get the MTU that we will respond to a MTU exchange with.
int mp_bluetooth_get_preferred_mtu(void);
int mp_bluetooth_set_preferred_mtu(uint16_t mtu);
--- a/extmod/modbluetooth.c
+++ b/extmod/modbluetooth.c
@@ -709,6 +709,17 @@
}
static MP_DEFINE_CONST_FUN_OBJ_2(bluetooth_ble_gap_disconnect_obj, bluetooth_ble_gap_disconnect);
+static mp_obj_t bluetooth_ble_gap_update(size_t n_args, const mp_obj_t *args) {
+ (void)n_args;
+ uint16_t conn_handle = mp_obj_get_int(args[1]);
+ mp_int_t min_conn_interval_us = mp_obj_get_int(args[2]);
+ mp_int_t max_conn_interval_us = mp_obj_get_int(args[3]);
+ mp_int_t latency = mp_obj_get_int(args[4]);
+ mp_int_t supervision_timeout_ms = mp_obj_get_int(args[5]);
+ return bluetooth_handle_errno(mp_bluetooth_gap_update(conn_handle, min_conn_interval_us, max_conn_interval_us, latency, supervision_timeout_ms));
+}
+static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(bluetooth_ble_gap_update_obj, 6, 6, bluetooth_ble_gap_update);
+
#if MICROPY_PY_BLUETOOTH_ENABLE_PAIRING_BONDING
static mp_obj_t bluetooth_ble_gap_pair(mp_obj_t self_in, mp_obj_t conn_handle_in) {
(void)self_in;
@@ -943,6 +954,7 @@
{ MP_ROM_QSTR(MP_QSTR_gap_scan), MP_ROM_PTR(&bluetooth_ble_gap_scan_obj) },
#endif
{ MP_ROM_QSTR(MP_QSTR_gap_disconnect), MP_ROM_PTR(&bluetooth_ble_gap_disconnect_obj) },
+ { MP_ROM_QSTR(MP_QSTR_gap_update), MP_ROM_PTR(&bluetooth_ble_gap_update_obj) },
#if MICROPY_PY_BLUETOOTH_ENABLE_PAIRING_BONDING
{ MP_ROM_QSTR(MP_QSTR_gap_pair), MP_ROM_PTR(&bluetooth_ble_gap_pair_obj) },
{ MP_ROM_QSTR(MP_QSTR_gap_passkey), MP_ROM_PTR(&bluetooth_ble_gap_passkey_obj) },
--- a/extmod/nimble/modbluetooth_nimble.c
+++ b/extmod/nimble/modbluetooth_nimble.c
@@ -1027,6 +1027,21 @@
return ble_hs_err_to_errno(ble_gap_terminate(conn_handle, BLE_ERR_REM_USER_CONN_TERM));
}
+int mp_bluetooth_gap_update(uint16_t conn_handle, int32_t min_conn_interval_us, int32_t max_conn_interval_us, uint16_t latency, int32_t supervision_timeout_ms) {
+ if (!mp_bluetooth_is_active()) {
+ return ERRNO_BLUETOOTH_NOT_ACTIVE;
+ }
+ struct ble_gap_upd_params params = {
+ .itvl_min = min_conn_interval_us / BLE_HCI_CONN_ITVL,
+ .itvl_max = max_conn_interval_us / BLE_HCI_CONN_ITVL,
+ .latency = latency,
+ .supervision_timeout = supervision_timeout_ms / 10,
+ .min_ce_len = BLE_GAP_INITIAL_CONN_MIN_CE_LEN,
+ .max_ce_len = BLE_GAP_INITIAL_CONN_MAX_CE_LEN,
+ };
+ return ble_hs_err_to_errno(ble_gap_update_params(conn_handle, ¶ms));
+}
+
int mp_bluetooth_gatts_read(uint16_t value_handle, const uint8_t **value, size_t *value_len) {
if (!mp_bluetooth_is_active()) {
return ERRNO_BLUETOOTH_NOT_ACTIVE;
let
pins = import ./pins.nix;
inherit (pins.nixpkgs) fetchpatch;
pdm-patches = [
(fetchpatch {
url = "https://github.com/micropython/micropython/commit/ec24a150e02b7eff1271b59f0667cdb94a7aef62.patch";
hash = "sha256-Hu+0eT915QhupvO5s6+HYLsEzcPFTMFIjDowxnQLZDQ=";
})
(fetchpatch {
url = "https://github.com/micropython/micropython/commit/843085b7cf25d93aa712cf807298ae293ac64a8b.patch";
postFetch = "sed -i 's/ESP_ERROR_CHECK(/check_esp_err(/' $out";
hash = "sha256-3Je5VFn3P8rVa/oNOCxKluevPgErqk53MYDXNDk/1ZM=";
})
];
in
pins.esp-pkgs.mkShell {
buildInputs = [ pins.esp-pkgs.esp-idf-esp32s3 ];
MICROPYTHON_PDM_SRC = pins.nixpkgs.applyPatches {
name = "micropython-pdm-src";
src = pins.nixpkgs.micropython.src;
patches = pdm-patches ++ [ ./patches/gap-update.patch ];
patchFlags = [ "-p1" "--fuzz=0" ];
postPatch = ''
cat >> ports/esp32/boards/ESP32_GENERIC_S3/sdkconfig.board <<EOF
CONFIG_FREERTOS_USE_TICKLESS_IDLE=y
CONFIG_BT_CTRL_MODEM_SLEEP=y
CONFIG_BT_CTRL_MODEM_SLEEP_MODE_1=y
CONFIG_BT_CTRL_LPCLK_SEL_MAIN_XTAL=y
CONFIG_BT_CTRL_MAIN_XTAL_PU_DURING_LIGHT_SLEEP=y
CONFIG_USJ_NO_AUTO_LS_ON_CONNECTION=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions-twatch-ota.csv"
EOF
cat > ports/esp32/partitions-twatch-ota.csv <<EOF
nvs, data, nvs, 0x9000, 0x4000,
otadata, data, ota, 0xd000, 0x2000,
phy_init, data, phy, 0xf000, 0x1000,
ota_0, app, ota_0, 0x10000, 0x1F0000,
ota_1, app, ota_1, 0x200000, 0x1F0000,
EOF
substituteInPlace ports/esp32/modmachine.c \
--replace-fail ".light_sleep_enable = false" ".light_sleep_enable = n_args < 2 || mp_obj_is_true(args[1])"
echo "#define MICROPY_PY_MACHINE_FREQ_NUM_ARGS_MAX (2)" >> ports/esp32/mpconfigport.h
substituteInPlace ports/esp32/mphalport.c \
--replace-fail "ulTaskNotifyTake(pdFALSE, 1);" \
"ulTaskNotifyTake(pdFALSE, (us - dt) / 1000 / portTICK_PERIOD_MS - 1);"
'';
};
}
- how
- one script builds it from scratch, into
build/firmware.bin.#!/usr/bin/env nix-shell #!nix-shell -i bash firmware-shell.nix set -eu here=$(dirname "$(readlink -f "$0")") work=$here/build/micropython rm -rf "$work" mkdir -p "$here/build" cp -r "$MICROPYTHON_PDM_SRC" "$work" chmod -R u+w "$work" make -C "$work/mpy-cross" make -C "$work/ports/esp32" BOARD=ESP32_GENERIC_S3 BOARD_VARIANT=SPIRAM_OCT cp "$work/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/firmware.bin" "$here/build/firmware.bin"- scope: esp-idf 5.5.2 rewrites micropython’s lockfile, written for 5.5.1; its two components, mdns 1.1.0 and tinyusb e4c0ec3c, stay those it pins.
- how
- the watch is reached over its usb serial port, with esptool and mpremote from the pinned nixpkgs,
and over the air with bleak.
- why: the python with bleak comes first, since the pythons esptool and mpremote carry would shadow it.
let pins = import ./pins.nix; in pins.nixpkgs.mkShell { buildInputs = [ (pins.nixpkgs.python3.withPackages (p: [ p.bleak ])) pins.nixpkgs.esptool pins.nixpkgs.mpremote pins.nixpkgs.socat ]; ST7789PY = pins.st7789py; } - how
- flashing erases the watch whole, then writes the firmware at the start of the flash.
- how: the watch enters flashing mode by hand, as the real trial draws; BOOT and RST are both reachable with the back closed.
- why: behind this desktop’s usb hubs, esptool cannot pull the reset line, so the watch enters flashing mode by hand; once flashed, a press on RST starts the new firmware.
#!/usr/bin/env nix-shell
#!nix-shell -i bash device-shell.nix
set -eu
here=$(dirname "$(readlink -f "$0")")
port=${1:-/dev/ttyACM0}
rm -rf "$here/sent"
esptool --chip esp32s3 --port "$port" --before no-reset --after no-reset \
write-flash --erase-all 0 "$here/build/firmware.bin"
- how
- the erase takes every file with it, so after the press on RST
restore-watch.shputs them back, main.py with them, and restarts the watch.
#!/usr/bin/env nix-shell
#!nix-shell -i bash device-shell.nix
set -eu
here=$(dirname "$(readlink -f "$0")")
port=${1:-/dev/ttyACM0}
"$here/deploy-watch.sh" "$port"
mpremote connect "$port" resume cp "$here/watch/main.py" :main.py + run "$here/watch/updated.py" + reset
- how
- once the watch holds two slots, a new firmware comes over its wifi REPL:
over-wifi.sh update-firmware.sh; the image is copied to the watch, written into the idle slot, checked against its hash, and only then made the boot slot and started.- why: « again? ypu were supposed rtouse two slot to allow flashing OTA »
- why: over mojito’s bluetooth relay, 1.7 MB would take hours: 4.9 KB took 79 s.
- what: a firmware that boots but misbehaves stays: no rollback is built in; the old slot is untouched until the next update.
- how: the app image is the firmware from 0x10000 on, its trailing erased bytes dropped.
import sys APP_AT = 0x10000 firmware = open(sys.argv[1], "rb").read()[APP_AT:] open(sys.argv[2], "wb").write(firmware.rstrip(b"\xff"))- how: the update’s log alone tells where it is: each step with its time, the copy in twentieths,
« installing », the watch’s « ota: » line, then « done » or « failed ».
- how: the image goes in twenty pieces, each appended on the watch as it lands.
- how: as each piece lands, the watch shows and prints its percent and the time left, the time so far spread over the pieces still to come.
import time
import screen
_total = 0
_done = 0
_kept = 0
_start = 0
def start(total, kept=0):
global _total, _done, _kept, _start
_total, _done, _kept, _start = total, kept, kept, time.ticks_ms()
if not kept:
open("app.bin", "wb").close()
def _left(ms):
s = ms // 1000
if s >= 3600:
return "%d h %02d" % (s // 3600, s % 3600 // 60)
if s >= 60:
return "%d min" % (s // 60)
return "%d s" % s
def add():
global _done
block = bytearray(4096)
with open("app.bin", "ab") as whole, open("app.part", "rb") as part:
while True:
got = part.readinto(block)
if not got:
break
whole.write(memoryview(block)[:got])
_done += 1
line = "firmware %d %%" % (100 * _done // _total)
if _done < _total:
spent = time.ticks_diff(time.ticks_ms(), _start)
line += ", %s left" % _left(spent * (_total - _done) // (_done - _kept))
print(line)
screen.say(line)
- how
- « done » comes only from the watch’s « written and checked », not from mpremote’s exit.
- how
- a send cut short is taken up again: the watch tells how many bytes
app.binholds, and the pieces those bytes make whole are kept, not sent again.
#!/usr/bin/env nix-shell
#!nix-shell -i bash device-shell.nix
set -eu
here=$(dirname "$(readlink -f "$0")")
port=$1
image=$here/build/app.bin
pieces=$here/build/app.pieces
PIECES=20
step() { echo "$(date +%T) $*"; }
step "cutting the image"
python3 -I "$here/app-image.py" "$here/build/firmware.bin" "$image"
digest=$(sha256sum "$image")
rm -rf "$pieces"
mkdir -p "$pieces"
split -n "$PIECES" -d -a 3 "$image" "$pieces/"
held=$(mpremote connect "$port" resume exec "import os
try:
print('held', os.stat('app.bin')[6])
except OSError:
print('held', 0)" | sed -n 's/^held //p' | tr -d '\r')
kept=0
sum=0
n=0
for piece in "$pieces"/*; do
sum=$((sum + $(stat -c %s "$piece")))
n=$((n + 1))
if [ "$sum" -eq "${held:-0}" ]; then
kept=$n
fi
done
step "copying $(stat -c %s "$image") bytes in $PIECES pieces, $kept already on the watch"
copies=(cp "$here/watch/pieces.py" :pieces.py + exec "import pieces; pieces.start($PIECES, $kept)")
n=0
for piece in "$pieces"/*; do
n=$((n + 1))
if [ "$n" -gt "$kept" ]; then
copies+=(+ cp "$piece" :app.part + exec "pieces.add()")
fi
done
copies+=(+ exec "import os
try:
os.remove('app.part')
except OSError:
pass
print('installing')
import ota
ota.install('app.bin', '${digest%% *}')")
PYTHONUNBUFFERED=1 mpremote connect "$port" resume "${copies[@]}" | tee "$here/build/ota.out"
if grep -q "ota: written and checked" "$here/build/ota.out"; then
step "done"
else
step "failed"
exit 1
fi
- how
- on the watch, the image is written block by block, read back for its hash, and the watch restarts on it only if the hash matches.
import binascii
import hashlib
import os
import esp32
import machine
BLOCK = 4096
def install(path, digest):
target = esp32.Partition(esp32.Partition.RUNNING).get_next_update()
size = os.stat(path)[6]
blocks = (size + BLOCK - 1) // BLOCK
block = bytearray(BLOCK)
with open(path, "rb") as image:
for at in range(blocks):
got = image.readinto(block)
for i in range(got, BLOCK):
block[i] = 0xFF
target.writeblocks(at, block)
check = hashlib.sha256()
for at in range(blocks):
target.readblocks(at, block)
check.update(block[:min(BLOCK, size - at * BLOCK)])
if binascii.hexlify(check.digest()).decode() != digest:
print("ota: hash mismatch, not booted")
return
os.remove(path)
target.set_boot()
print("ota: written and checked, restarting")
machine.reset()
- how
- the watch’s modules go onto its flash; which
main.pyit boots, the trial or the mic test, is put by the script that plays it.- how: the watch first says « updating », and keeps it until the reset that ends the update; a
freshly flashed watch, with no screen module yet, says nothing.
- why: over oxenhead the copy takes minutes; the screen would otherwise stay frozen on its last line.
- how: under « updating », a line shows the file being sent and a bar of all that is sent so far,
redrawn as each 512-byte piece lands.
- why: mpremote’s
cpsays nothing to the watch until a file is whole; one file looked hung for 79 s. - how: the desk sends each piece itself, in base64, into
<name>.part, renamed once whole, so a cut update never leaves a half module to import.
- why: mpremote’s
- how: only the files changed since they were last sent go, all in one connection; the desk keeps
each sent file’s hash in
sent/, which a flash empties.- why: asked which files differ, the watch hashes each one and answers over ble, a round trip a file, where an update most often changes one.
- how: then, shipped or not, the watch hashes every file of the list in one round trip, and a line
« name desk’s sha256 watch’s sha256 » is printed for each; a mismatch, or a file missing, fails the
run and forgets that file’s hash in
sent/, so the next run sends it again.- why: « can ypu make the hpdate script outpjt the sha of the delloyed files to make confirmation trivial ? »
- what: untested: no story runs the deploy; its first run on the watch is its test.
- how: the watch first says « updating », and keeps it until the reset that ends the update; a
freshly flashed watch, with no screen module yet, says nothing.
#!/usr/bin/env nix-shell
#!nix-shell -i bash device-shell.nix
set -eu
here=$(dirname "$(readlink -f "$0")")
port=${1:-/dev/ttyACM0}
sent="$here/sent"
mkdir -p "$sent"
PIECE=512
files=("$here/watch/link.py" "$here/watch/repl.py" "$here/watch/ota.py"
"$here/device/mic.py" "$here/device/screen.py"
"$here/device/speaker.py" "$here/device/motor.py" "$here/device/touch.py"
"$here/device/wifi.py" "$here/device/battery.py" "$here/device/wheel.py" "$here/device/sleep.py"
"$here/device/boot.py" "$here/device/clock.py" "$here/device/glyphs.py" "$here/device/flash.py"
"$ST7789PY/lib/st7789py.py" "$ST7789PY/romfonts/vga2_8x16.py")
[ -e "$here/device/wifi_secret.py" ] && files+=("$here/device/wifi_secret.py")
check() {
local names="" status=0 file name mine theirs hashes
for file in "${files[@]}"; do
names+="'$(basename "$file")',"
done
hashes=$(mpremote connect "$port" resume exec "$(cat <<EOF
import binascii, hashlib
for name in ($names):
digest = hashlib.sha256()
try:
with open(name, 'rb') as f:
while True:
piece = f.read($PIECE)
if not piece:
break
digest.update(piece)
print(name, binascii.hexlify(digest.digest()).decode())
except OSError:
print(name, 'missing')
EOF
)" | tr -d '\r')
for file in "${files[@]}"; do
name=$(basename "$file")
mine=$(sha256sum < "$file" | cut -d' ' -f1)
theirs=$(printf '%s\n' "$hashes" | awk -v name="$name" '$1 == name {print $2}')
echo "$name $mine $theirs"
if [ "$mine" != "$theirs" ]; then
rm -f "$sent/$name"
status=1
fi
done
return $status
}
changed=()
for file in "${files[@]}"; do
sha256sum < "$file" | cmp -s - "$sent/$(basename "$file")" || changed+=("$file")
done
if [ ${#changed[@]} -eq 0 ]; then
check
exit
fi
total=0
for file in "${changed[@]}"; do
total=$((total + $(stat -c %s "$file")))
done
copies=()
done_bytes=0
for file in "${changed[@]}"; do
name=$(basename "$file")
size=$(stat -c %s "$file")
copies+=(+ exec "open('$name.part','wb').close()")
for ((offset = 0; offset < size; offset += PIECE)); do
piece=$(tail -c +$((offset + 1)) "$file" | head -c "$PIECE" | base64 -w0)
done_bytes=$((done_bytes + (size - offset < PIECE ? size - offset : PIECE)))
copies+=(+ exec "f=open('$name.part','ab');f.write(a2b_base64('$piece'));f.close();show('$name',$done_bytes,$total)")
done
copies+=(+ exec "os.rename('$name.part','$name')")
done
mpremote connect "$port" resume \
exec "$(cat <<'EOF'
import os
from binascii import a2b_base64
try:
import screen
screen.say("updating")
screen.say("")
def show(name, sent, total):
filled = 12 * sent // total
screen.redo("{} {}{} {}%".format(name[:10], "#" * filled, "." * (12 - filled), 100 * sent // total))
except ImportError:
def show(name, sent, total):
pass
EOF
)" \
"${copies[@]}"
for file in "${changed[@]}"; do
sha256sum < "$file" > "$sent/$(basename "$file")"
done
check
- how
- each script that ends an update with a reset first runs this, so the watch says it is done
rather than going black unannounced.
try: import screen screen.say("updated, restarting now") except ImportError: pass - how
- the cpu runs at 80 MHz from boot on, the least ble and wifi run at.
- how: plugged at boot, it is left awake, so its usb port lives;
main.pythen follows the plug. - why: the chip’s light sleep kills its usb port, so an update over usb fails.
- how: plugged at boot, it is left awake, so its usb port lives;
- how
- at boot, the pmu’s dc4 and dc5, « No use » in LilyGo’s library, and bldo1, its gps power,
go off: bits 3 and 4 of 0x80, bit 4 of 0x90.
- why: they were found on, fed for nothing; the gnss track will turn bldo1 back on.
- how
- at boot, the slot running is marked valid, so a firmware sent over the air stays booted.
- why: unmarked, the bootloader rolled the watch back to its old slot at the next reset.
import esp32
import machine
from machine import I2C, Pin
try:
esp32.Partition.mark_app_valid_cancel_rollback()
except OSError:
pass
PMU = 0x34
STATUS1, VBUS_GOOD = 0x00, 0b100000
DCDC_ONOFF, LDO_ONOFF = 0x80, 0x90
DC4_DC5, BLDO1 = 0b11000, 0b10000
_i2c = I2C(0, sda=Pin(10), scl=Pin(11), freq=400_000)
if not _i2c.readfrom_mem(PMU, STATUS1, 1)[0] & VBUS_GOOD:
machine.freq(80_000_000)
for register, bits in ((DCDC_ONOFF, DC4_DC5), (LDO_ONOFF, BLDO1)):
_i2c.writeto_mem(PMU, register, bytes((_i2c.readfrom_mem(PMU, register, 1)[0] & ~bits,)))
- how
- once switched on, the watch joins the wifi as « twatch » and serves its REPL on tcp port 23,
so it is reached without usb.
- rule: the watch starts with the wifi off; the bar’s button switches it on, and it goes off again after 30 min with nothing typed in its REPL.
- rule: the phone never waits on the wifi: the watch calls out to it first, and gives up on a wifi not joined within 30 s.
- how: the wifi’s name and password live in
wifi_secret.py, never in this note: the tangle asks clk for the password and writes it indevice/, and the deploy copies it to the watch. - how: the REPL is hooked as webrepl does it, the socket’s callback waking
os.dupterm, so a ctrl-C over the wifi stops a runningmain.py. - how: switching on only starts joining; the REPL’s port opens at the first look that finds the wifi joined.
import network import os import socket import time import wifi_secret PORT = 23 _CALLBACK = 20 _station = network.WLAN(network.STA_IF) _server = None _used = time.ticks_ms() def _typed(client): global _used _used = time.ticks_ms() os.dupterm_notify(client) def _accept(server): client, _ = server.accept() client.setblocking(False) client.setsockopt(socket.SOL_SOCKET, _CALLBACK, _typed) os.dupterm(client) def idle_ms(): return time.ticks_diff(time.ticks_ms(), _used) def on(): global _used _used = time.ticks_ms() network.hostname("twatch") _station.active(True) _station.connect(wifi_secret.SSID, wifi_secret.PASSWORD) def joined(): global _server if not _station.isconnected(): return None if _server is not None: return _station.ifconfig()[0] _server = socket.socket() _server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) _server.bind(("0.0.0.0", PORT)) _server.listen(1) _server.setsockopt(socket.SOL_SOCKET, _CALLBACK, _accept) return _station.ifconfig()[0] def off(): global _server os.dupterm(None) if _server is not None: _server.close() _server = None _station.active(False) - how
- under test, the wifi is a fake that is on or off and reaches nothing; it joins at once, unless
the harness names no network, then never.
import os def on(): pass def joined(): return None if os.getenv("TWATCH_FAKE_NO_NETWORK") else "fake" def idle_ms(): return 0 def off(): pass - how
- the watch keeps the time the phone gives in its rtc; until it is given, it knows none.
- how: an rtc never set starts in 2000, so a later year is a time given.
import machine import time def set(when): year, month, day, weekday, hour, minute, second = when machine.RTC().datetime((year, month, day, weekday, hour, minute, second, 0)) def now(): when = time.localtime() return when if when[0] > 2000 else None - how
- under test, the clock is a fake that runs from the time given, on the desktop’s seconds.
import time _offset = None def set(when): global _offset year, month, day, weekday, hour, minute, second = when _offset = time.mktime((year, month, day, hour, minute, second, weekday, 0)) - time.time() def now(): return None if _offset is None else time.localtime(time.time() + _offset) - how
- the watch keeps its errors in
errors.log, its chosen phone inphone.txt, its light’s log inlight.logand its hourly charge incharge.logon its flash, and restarts by resetting the chip.
import machine
ERRORS = "errors.log"
PHONE = "phone.txt"
LIGHT_LOG = "light.log"
CHARGE_LOG = "charge.log"
def restart():
machine.reset()
- how
- under test, the errors and the chosen phone go to the files the harness names, the light’s log beside the errors, and a restart ends the process.
import os
import sys
ERRORS = os.getenv("TWATCH_FAKE_ERRORS")
PHONE = os.getenv("TWATCH_FAKE_PHONE")
LIGHT_LOG = ERRORS.rsplit("/", 1)[0] + "/light.log"
CHARGE_LOG = ERRORS.rsplit("/", 1)[0] + "/charge.log"
def restart():
sys.exit(1)
- how
- here, socat turns that port into a pty, and any script taking the watch’s port runs through it.
- how: the watch is reached by the name « twatch », the router’s to resolve;
TWATCH_HOSTnames it otherwise, asTWATCH_HOST=192.168.1.23 over-wifi.sh play-oxenhead.sh. - how: from afar, the same reaches the watch through oxenhead, which relays its port 9721 to the
watch’s REPL over ble:
TWATCH_HOST=oxenhead TWATCH_PORT=9721 over-wifi.sh deploy-watch.sh. - how: mpremote then =resume=s, since a soft reset would close the socket it talks through.
- how: a reset over the wifi cuts the link it travels on, so its mpremote fails, and that is ignored.
- rule: each bridge links its own tty, so two at once never take each other’s.
- how: a watch that has not answered within
REACH_Sseconds is out of reach, said in one line, naming the bar’s « W » to tap, before anything is sent.
- how: the watch is reached by the name « twatch », the router’s to resolve;
#!/usr/bin/env bash
set -eu
here=$(dirname "$(readlink -f "$0")")
host=${TWATCH_HOST:-twatch}
port=${TWATCH_PORT:-23}
REACH_S=5
if ! timeout "$REACH_S" bash -c ": < /dev/tcp/$host/$port" 2> /dev/null; then
echo "cannot reach $host:$port: tap the watch's bar so « W » joins, or start its relay" >&2
exit 1
fi
mkdir -p "$here/build"
tty=$(mktemp -u "$here/build/watch-XXXXXX.tty")
nix-shell "$here/device-shell.nix" \
--run "exec socat pty,link=$tty,raw,echo=0 tcp:$host:$port" &
bridge=$!
trap 'kill $bridge' EXIT
while [ ! -e "$tty" ]; do sleep 0.1; done
"$@" "$tty"
- how
- out, only mojito is with me; while it holds the watch, its poc app relays the same port 9721,
reached at its vpn address:
over-mojito.sh deploy-watch.sh.- why: no adb out of home, and the vpn reaches mojito where ssh does, with no tunnel to keep.
#!/usr/bin/env bash here=$(dirname "$(readlink -f "$0")") TWATCH_HOST=192.168.2.34 TWATCH_PORT=9721 exec "$here/over-wifi.sh" "$@" - how
- a battery mark, taken whenever I like through mojito:
battery-mark.shreads the watch’s charge and voltage, keeps them with the time inbattery-marks.txt, and tells the drain since the previous mark.- what: the read restarts main.py on the watch.
#!/usr/bin/env bash
set -eu
here=$(dirname "$(readlink -f "$0")")
if [ "${1:-}" = --read ]; then
nix-shell "$here/device-shell.nix" --run "mpremote connect $2 resume exec 'import battery; print(*battery.level())'"
nix-shell "$here/device-shell.nix" --run "mpremote connect $2 resume reset" > /dev/null || true
exit 0
fi
marks=$here/battery-marks.txt
level=$("$here/over-mojito.sh" "$0" --read | tail -n 1)
read -r percent millivolts <<< "$level"
now=$(date +%s)
previous=$(tail -n 1 "$marks" 2> /dev/null || true)
echo "$now $(date -d "@$now" '+%F %T') $percent $millivolts" >> "$marks"
echo "$(date -d "@$now" '+%F %T'): $percent %, $millivolts mV"
if [ -n "$previous" ]; then
awk -v now="$now" -v percent="$percent" -v millivolts="$millivolts" '{
hours = (now - $1) / 3600
printf "since %s %s, %.1f h: %.1f mV/h, %.1f %%/h\n", $2, $3, hours, ($5 - millivolts) / hours, ($4 - percent) / hours
}' <<< "$previous"
fi
- how
- mojito’s app is restarted by stopping and starting it, as root over ssh, nothing more; the
script waits for the companion to say « ready », then sends the app to the background.
- why: a twatch.py tangled to mojito runs only once the app starts again.
#!/usr/bin/env bash set -u ssh mojito su -c "'logcat -c; am force-stop eu.konubinix.konixpoc; am start -n eu.konubinix.konixpoc/org.kivy.android.PythonActivity'" for _ in $(seq 60); do if ssh mojito su -c "'logcat -d'" | grep -q "twatch: ready"; then ssh mojito curl -sS -m 10 -w '%{http_code}\\n' http://127.0.0.1:9717/background exit 0 fi timeout 2 tail -f /dev/null done echo "mojito's companion never said ready" exit 1 - how
- what mojito’s companion last said of its link, as root over ssh:
mojito-link-log.sh 15.#!/usr/bin/env bash set -eu lines=${1:-15} ssh mojito "date; su -c \"logcat -d | grep -E 'app.twatch|Traceback' | grep -v 'recall ' | tail -n $lines\"" | cut -c1-200 - how
- the same for oxenhead, over adb:
oxenhead-link-log.sh 15.#!/usr/bin/env bash set -eu lines=${1:-15} date adb -s oxenhead:5555 logcat -d -s konixpoc:* | grep -E 'app.twatch|Traceback' | grep -v 'recall ' | tail -n "$lines" | cut -c1-200 - how
- waiting for the watch to link to oxenhead, after a reset or a deploy, ends as soon as it says
« ready », or after as many seconds as given:
oxenhead-wait-ready.sh 120.- why: « create a better command than “timeout 90 adb -s oxenhead:5555 logcat -T 1 -s konixpoc:* …” », which always waited its 90 s, though the watch linked in 70.
#!/usr/bin/env bash set -u seconds=${1:-120} since=$(adb -s oxenhead:5555 shell date "'+%m-%d %H:%M:%S.000'") timeout "$seconds" adb -s oxenhead:5555 logcat -T "$since" -s konixpoc:* \ | awk '/app.twatch/ { print substr($0, 1, 200); fflush() } /twatch: ready/ { found = 1; exit } END { exit !found }' status=$? if [ "$status" -ne 0 ]; then echo "the watch did not say ready within $seconds s" fi exit "$status"
the voice, on the watch
- what
- the watch waits for the phone, then, while it is linked, shows each recall the phone sends, and records from a tap on the screen to the next, 2 min at most, then sends over ble, writing each step on it; when the phone leaves, it waits for it again.
- how
- a recall’s priority is ntfy’s, 1 to 5: 1 is written on a dark screen without lighting it, 2
lights it, 3 also buzzes once, 4 buzzes and rings, 5 buzzes and rings three times.
- how: a recall of 3 to 5 coming within 10 s of the last buzz only shows: a batch buzzes once.
- why: « no need to vibrate for every notif when they arrive in batch »
- how: 6 is a ringing call: it buzzes, never rings, then buzzes every 2 s until a recall of
priority 0 says the call stopped ringing, the wheel is pressed, or 60 s have passed.
- why: « the watch must vibrate when calling, but not ring »
- how: 8 is the phone looking for the watch: as 6, but each buzz every 2 s also rings, and the wheel press that stops it sends 11 on the media characteristic, so the phone knows.
- how: 7 is what the phone plays: its text goes to the media row, not to the lines, empty when
nothing plays; a tap on that row opens the pad, the whole screen in three tall columns, where a
tap on the left, middle or right one goes back, plays or pauses, or skips.
- why: « it’s not practical to have to be precise and target the bar »
- how: the wheel closes the pad, back to the lines, as do the 5 s dark and a new line that lights.
- how: 1 and 2 are for senders too noisy to be heard each time.
- how: a recall that says again what the last one said, in the same minute, is left out.
- why: some ntfy publishers post each message twice, once with the hour before it.
- how: a recall of 3 to 5 coming within 10 s of the last buzz only shows: a batch buzzes once.
- how
- while a recording goes out, its line counts « sending 45 % », redone in place each 5 %, then
becomes « sent in 12 s »; the garbage it left is then collected.
- how: as a send starts, the screen turns read-only, and stays so; the wheel makes it touchable
again.
- why: a tap, or a stray pulse of the panel, during the send would start another recording.
- how: as a send starts, the screen turns read-only, and stays so; the wheel makes it touchable
again.
- how
- a send that fails says « send stalled, kept », and the recording waits for the next link.
- how
- a recall’s line starts with when the phone took it, « 08:42 », or « 22/10 08:42 » when not today.
- how
- unlinked, a recording is kept and sent at the next link; while one is kept, a tap records
nothing more.
- why: more would fill the ram.
- how
- the screen’s bottom row is a button that switches the wifi on and off; it also shows the
battery’s percent, blue while charging, its voltage, the wifi, the phone link and the ram free,
« 95% 4.06V W B 3.2M ».
- how: the wifi is one « W », on black: grey off, yellow joining, green joined, red not found.
- why: « save space -> wifi on an wifi off becomes a colored “W” »
- how: the wifi is one « W », on black: grey off, yellow joining, green joined, red not found.
WIFI_TINTS = {"off": "grey", "joining": "yellow", "joined": "green", "not found": "red"}
- how
- the phone link is one « B » after it, on black: yellow while waiting for the phone, green
linked.
- why: « add “B”, like “W” »
PHONE_TINTS = {"waiting": "yellow", "linked": "green"}
- how
- a refresh reads again from the watch all the screen shows, the bar and the hour, and redraws
them; the lines are not read again, each being said once.
- why: « there lust be a concept of screen refresh »
- how: it comes at start, each minute the screen is lit, when the wheel makes the screen touch-sensitive, and at each tap on a dark or read-only screen.
- how
- once joined, the wifi’s address is said as a line.
- how
- the watch starts lit read-only, lest a touch while it boots record.
- how
- every tap or press on a lit screen restarts its 5 s before dark.
- why: « every interaction with the watch must reset the 5s timer »
- how
- a tap on a dark screen lights it before its tick and redraw, so it shows at once.
- why: the tick and the redraw kept a dark tap from showing for 0.5 to 0.9 s.
- how
- a tap on a dark or read-only screen lights it read-only 5 s more, where taps record nothing; the wheel lights it touch-sensitive, dark or read-only, and darkens it when touch-sensitive.
- how
- holding the wheel, about 1.5 s, opens the actions pad, three tall columns, from any screen:
« torch » on the left, the phone to link with in the middle, « find phone » on the right; holding it
again, or a press, closes it.
- how: a tap on its top row, the names, turns it to its second page, « stay lit » and « scene », and back.
- how: « scene » plays until a wheel press, which does nothing else; taps meanwhile do nothing.
- why: « the action pad could have several screens », « swipe and tap may conflict »
- how
- a tap on the middle column steps through « any phone » and each phone’s name seen linking since
start, kept in
phone.txtacross restarts; the pad stays open.- how: a phone other than the chosen one is dropped as soon as it says its name, and said so, without lighting; choosing drops a linked phone that is not the one.
- why: « can we in a menu of watch decide whar phàneto use ? »
- what: the phone left out keeps looking for the watch, so its battery pays for it.
- why: « even the torch is barely usable. the wheel is not responsive enough » for presses timed under 0.5 s.
- how: the hold is the pmu’s own long-press signal, latched as the short press is.
- how: the torch is put out by any tap or press, back to the read-only screen.
- how
- a picture the phone sends fills the screen as its half-rows come, the screen lit; any tap or
press puts the lines back.
- why: « a way to send a display a picture on watch ? »
- how: « find phone » rings the phone, code 9 on
MEDIA, saying « find phone sent »; the next tap or press, within the minute the phone rings, says it was found, code 10.
- how
- between looks, the watch naps 50 ms while lit and 1 s while dark.
- why: dark is most of the day, and each look wakes the chip; the panel and the wheel latch a tap or a press until looked at, so none is lost.
- how: the dark nap is cut in 100 ms slices and ends as soon as a finger lands on the panel; while
one is on it, the watch naps as when lit, until it lifts.
- why: « tap to wake is not react_ve enough »: a tap took up to 2 s, a look to see the finger, another to see it lift.
- how: the lit nap is cut in 20 ms slices, too short for the chip to light-sleep.
- how: each wake sets the backlight’s pwm up afresh, as a light sleep leaves it dead.
- why: « screen stays dark. i feel vibrations »
- why: « stroboscopically lit+dark+lit »: the backlight’s pwm stops while the chip light-sleeps.
- how: each wake sets the backlight’s pwm up afresh, as a light sleep leaves it dead.
- how
- touch-sensitive, a finger dragged down shows older lines, a row per 16 px, and dragged up newer ones; the wheel’s turn sends nothing, so it can’t.
- how
- under 15 %, every 5 min until it charges, the watch lights its screen with « battery low: 14 % », buzzes and rings; it looks each minute, lit or dark.
- how
- the top row shows the hour and the date, « 07:42 Thu 22/10 », from the time the phone gives at each link; it is redrawn whenever lit and the minute changes.
- how
- an error the main loop does not handle is shown, its traceback added to
errors.logon the flash, the last 4 KB kept, and the watch restarts; at each link, the file goes to the phone onERRORS, a line per notification, and is deleted once all went out.- why: « log excep(ions somewhere boot resistant and at next lçnk send to phone and purge »
- how
- at each link, then each hour while linked, the watch sends its charge as a line on
CHARGE, « charge 80 % 3900 mV », « charging » added on the charger; the phone logs it as « watch charge: … ».- why: « 2 »: a charge sent on
ERRORSread as an hourly watch error in the phone’s log. - what: the story sees the line at the link only; the hourly one stays untested, no story waits an hour.
- why: « 2 »: a charge sent on
import bluetooth
import gc
import io
import os
import sys
import time
import battery
import clock
import flash
import link
import mic
import motor
import screen
import sleep
import speaker
import touch
import wheel
import wifi
GAUGE_EVERY_MS = 60_000
CHARGE_EVERY_MS = 3_600_000
MARK_EVERY_MS = 3_600_000
ERRORS_KEPT = 4096
WIFI_JOIN_MS = 30_000
WIFI_IDLE_MS = 1_800_000
RECORD_MAX_S = 120
LOW_PERCENT = 15
SWIPE_PX = 16
ALARM_EVERY_MS = 300_000
LIT_NAP_MS = 50
AWAKE_SLICE_MS = 20
DARK_NAP_MS = 1000
DARK_SLICE_MS = 100
RING_EVERY_MS = 2_000
RING_FOR_MS = 60_000
BATCH_MS = 10_000
# the media row's left, middle and right thirds: back, play or pause, skip
MEDIA_CODES = (1, 2, 3)
FIND_PHONE = 9
PHONE_FOUND = 10
WATCH_FOUND = 11
REFUSE_CALL = 12
CALL = 6
FIND_ME = 8
FIND_FOR_MS = 60_000
DAYS =("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
_wifi = "off"
_phone = "waiting"
_gauged = time.ticks_ms()
_checked = time.ticks_add(time.ticks_ms(), -GAUGE_EVERY_MS)
_alarmed = None
_told = None
_marked = None
_plugged = None
def mark_charge():
global _marked
if _marked is not None and time.ticks_diff(time.ticks_ms(), _marked) < MARK_EVERY_MS:
return
level = battery.level()
if level is None:
return
_marked = time.ticks_ms()
screen.charge(*level)
def follow_plug():
global _plugged
plugged = battery.plugged()
if plugged != _plugged:
_plugged = plugged
sleep.allow(not plugged)
def nap():
motor.rest()
if screen.lit() or touch.busy():
for _ in range(LIT_NAP_MS // AWAKE_SLICE_MS):
time.sleep_ms(AWAKE_SLICE_MS)
return
for _ in range(DARK_NAP_MS // DARK_SLICE_MS):
time.sleep_ms(DARK_SLICE_MS)
if touch.busy() or wheel.busy():
return
def alarm_low():
global _checked, _alarmed
if time.ticks_diff(time.ticks_ms(), _checked) < GAUGE_EVERY_MS:
return
_checked = time.ticks_ms()
level = battery.level()
if level is None or level[0] >= LOW_PERCENT or battery.charging():
_alarmed = None
return
if _alarmed is None or time.ticks_diff(_checked, _alarmed) >= ALARM_EVERY_MS:
_alarmed = _checked
screen.say("battery low: %d %%" % level[0])
motor.buzz()
speaker.ring()
def tell_charge(watch):
global _told
_told = time.ticks_ms()
level = battery.level()
if level is None:
return
try:
watch.send_charge("charge %d %% %d mV%s" % (level[0], level[1],
" charging" if battery.charging() else ""))
except link.Stalled:
pass
def bar(wifi_state=None, phone_state=None):
global _wifi, _phone, _gauged
_wifi = wifi_state or _wifi
_phone = phone_state or _phone
_gauged = time.ticks_ms()
ram = "%.1fM" % (gc.mem_free() / 1_000_000)
level = battery.level()
if level is None:
screen.button("no battery W B %s" % ram, wifi=_wifi, phone=_phone)
return
percent = "%d%%" % level[0]
screen.button("%s %.2fV W B %s" % (percent, level[1] / 1000, ram),
blue=len(percent) if battery.charging() else 0, charge=level[0], wifi=_wifi,
phone=_phone)
def glance():
when = clock.now()
if when is None:
screen.clock("--:--")
else:
screen.clock("%02d:%02d %s %02d/%02d" % (when[3], when[4], DAYS[when[6]], when[2], when[1]))
def refresh():
bar()
glance()
def stamped(when, text):
year, month, day, hour, minute = when
now = clock.now()
if now is not None and tuple(now[:3]) == (year, month, day):
return "%02d:%02d %s" % (hour, minute, text)
return "%02d/%02d %02d:%02d %s" % (day, month, hour, minute, text)
def recording(seconds):
return "rec %d:%02d / %d:%02d, tap to end" % (
seconds // 60, seconds % 60, RECORD_MAX_S // 60, RECORD_MAX_S % 60)
def ended():
tap = touch.tap()
if touch.down():
motor.tick()
return tap is not None
def record():
screen.say(recording(0))
return mic.record(stop=ended, max_seconds=RECORD_MAX_S,
tick=lambda seconds: screen.redo(recording(seconds)))
def send_voice(watch, voice):
screen.wake(touchable=False, why="voice")
screen.say("sending 0 %")
started = time.ticks_ms()
try:
watch.send(voice, progress=lambda percent: screen.redo("sending %d %%" % percent))
except link.Stalled:
screen.say("send stalled, kept")
return False
# let the last notifications drain before the stack goes down
time.sleep_ms(500)
screen.redo("sent in %d s" % (time.ticks_diff(time.ticks_ms(), started) // 1000))
gc.collect()
return True
def kept_errors():
try:
with open(flash.ERRORS) as log:
return log.read()
except OSError:
return ""
def keep_error(error):
trace = io.StringIO()
sys.print_exception(error, trace)
kept = (kept_errors() + trace.getvalue())[-ERRORS_KEPT:]
with open(flash.ERRORS, "w") as log:
log.write(kept)
def send_errors(watch):
lines = kept_errors().splitlines()
if not lines:
return
try:
watch.send_lines(lines)
except link.Stalled:
return
os.remove(flash.ERRORS)
screen.say("errors sent to the phone")
def chosen_phone():
try:
with open(flash.PHONE) as kept:
return kept.read().strip()
except OSError:
return ""
def choose_phone(chosen, seen):
names = [""] + seen
chosen = names[(names.index(chosen) + 1) % len(names)]
with open(flash.PHONE, "w") as kept:
kept.write(chosen)
screen.pad_label("actions", 1, chosen or "any phone")
return chosen
def toggle_wifi(state):
if state == "off":
wifi.on()
bar("joining")
return "joining", time.ticks_ms()
wifi.off()
bar("off")
return "off", None
def follow_wifi(state, since):
if state == "joined" and wifi.idle_ms() > WIFI_IDLE_MS:
return toggle_wifi(state)
if state != "joining":
return state, since
ip = wifi.joined()
if ip is not None:
bar("joined")
screen.say("wifi " + ip)
return "joined", since
if time.ticks_diff(time.ticks_ms(), since) > WIFI_JOIN_MS:
wifi.off()
bar("not found")
return "off", None
return state, since
try:
linked = False
kept = None
last_recall = None
buzzed = None
ringing = rung = None
loud = False
finding = None
chosen = chosen_phone()
seen = [chosen] if chosen else []
screen.pad_label("actions", 1, chosen or "any phone")
watch = link.Link(bluetooth.BLE())
watch.advertise()
screen.say("waiting for the phone")
wifi_state, wifi_since = "off", None
refresh()
while True:
screen.alive()
mark_charge()
follow_plug()
writes = watch.next_writes()
if writes:
screen.heard(writes)
pace = watch.next_pace()
if pace:
screen.paced(*pace)
ask = watch.next_ask()
if ask:
screen.asked(ask)
wifi_state, wifi_since = follow_wifi(wifi_state, wifi_since)
if watch.phone_ready != linked:
if watch.phone_ready and watch.phone_name and watch.phone_name not in seen:
seen.append(watch.phone_name)
if watch.phone_ready and chosen and watch.phone_name != chosen:
screen.say("%s refused, %s chosen" % (watch.phone_name or "phone", chosen), light=False)
watch.drop()
linked = watch.phone_ready
bar(phone_state="linked" if linked else "waiting")
if linked:
screen.say((watch.phone_name or "phone") + " linked")
send_errors(watch)
tell_charge(watch)
if kept is not None and send_voice(watch, kept):
kept = None
else:
watch.advertise()
screen.say("waiting for the phone")
when = watch.next_time()
if when is not None:
clock.set(when)
if screen.lit():
glance()
pieces = watch.next_pieces()
if pieces is not None:
screen.picture_rows(pieces)
continue
recall = watch.next_recall()
if recall is not None:
priority, when, text = recall
if priority == 0:
ringing = None
continue
if priority == 7:
screen.media(text)
continue
if (when, text) == last_recall:
continue
last_recall = when, text
screen.say(stamped(when, text), light=priority > 1, recall=priority)
if priority not in (CALL, FIND_ME) and priority >= 3:
if buzzed is not None and time.ticks_diff(time.ticks_ms(), buzzed) < BATCH_MS:
continue
buzzed = time.ticks_ms()
for _ in range(3 if priority == 5 else 1):
if priority >= 3:
motor.buzz()
if priority >= 4 and priority != CALL:
speaker.ring()
if priority in (CALL, FIND_ME):
ringing = rung = time.ticks_ms()
loud = priority == FIND_ME
continue
if ringing is not None:
if time.ticks_diff(time.ticks_ms(), ringing) > RING_FOR_MS:
ringing = None
elif time.ticks_diff(time.ticks_ms(), rung) >= RING_EVERY_MS:
rung = time.ticks_ms()
motor.buzz()
if loud:
speaker.ring()
if watch.next_save():
screen.say("saved on the phone")
continue
if finding is not None and time.ticks_diff(time.ticks_ms(), finding) > FIND_FOR_MS:
finding = None
if wheel.held():
if ringing is not None and loud:
watch.command(WATCH_FOUND)
ringing = None
motor.tick()
screen.seen()
screen.torch(False)
screen.picture(False)
screen.scene(False)
if screen.pad_on() in screen.PAGES:
screen.pad(None)
else:
refresh()
screen.wake(touchable=True, why="wheel")
screen.pad("actions")
continue
if wheel.pressed():
refusing = ringing is not None and not loud
if ringing is not None:
watch.command(WATCH_FOUND if loud else REFUSE_CALL)
ringing = None
if refusing:
motor.double()
screen.seen()
screen.say("refusing the call")
continue
waking = finding is None and not (screen.pad_on() or screen.torch_on() or screen.picture_on()
or screen.scene_on() or screen.touchable())
motor.tick()
if waking:
screen.wake(touchable=True, why="wheel")
screen.seen()
if waking:
refresh()
elif finding is not None:
finding = None
watch.command(PHONE_FOUND)
screen.say("phone found")
elif screen.pad_on():
screen.pad(None)
elif screen.torch_on():
screen.torch(False)
elif screen.picture_on():
screen.picture(False)
elif screen.scene_on():
screen.scene(False)
else:
screen.dark("wheel")
continue
tap = touch.tap(dark=not screen.lit())
if touch.down():
motor.tick()
if not screen.lit():
screen.wake(why="tap")
if tap is not None:
if not screen.lit():
screen.wake(why="tap")
screen.seen()
if finding is not None:
finding = None
watch.command(PHONE_FOUND)
screen.say("phone found")
continue
if tap is None:
if screen.lit() and time.ticks_diff(time.ticks_ms(), _gauged) > GAUGE_EVERY_MS:
refresh()
alarm_low()
if linked and _told is not None and time.ticks_diff(time.ticks_ms(), _told) >= CHARGE_EVERY_MS:
tell_charge(watch)
screen.idle()
nap()
elif screen.pad_on() == "media":
if not watch.command(MEDIA_CODES[screen.pad_column(tap[0])]):
screen.say("phone away, not sent")
elif screen.pad_on() in screen.PAGES and tap[1] < screen.HOUR_BOTTOM:
screen.pad("more" if screen.pad_on() == "actions" else "actions")
elif screen.pad_on() in screen.PAGES and tap[1] < screen.SLIDER_BOTTOM:
screen.light_at(tap[0])
elif screen.pad_on() == "more" and screen.pad_column(tap[0]) == 1:
screen.scene(True)
elif screen.pad_on() == "more":
screen.pad(None)
screen.say("lit 1 min for 10 min" if screen.stay_lit() else "dark after 5 s again")
elif screen.pad_on() == "actions" and screen.pad_column(tap[0]) == 1:
chosen = choose_phone(chosen, seen)
if linked and chosen and watch.phone_name != chosen:
watch.drop()
elif screen.pad_on() == "actions":
column = screen.pad_column(tap[0])
screen.pad(None)
if column == 0:
screen.torch(True)
elif watch.command(FIND_PHONE):
finding = time.ticks_ms()
screen.say("find phone sent, tap when found")
else:
screen.say("phone away, not sent")
elif screen.scene_on():
pass
elif screen.torch_on():
screen.torch(False)
elif screen.picture_on():
screen.picture(False)
elif not screen.touchable():
refresh()
screen.wake(why="tap")
elif abs(tap[2]) >= SWIPE_PX:
screen.browse(int(tap[2] / SWIPE_PX))
elif tap[1] < screen.HOUR_BOTTOM:
pass
elif tap[1] >= screen.BUTTON_TOP:
wifi_state, wifi_since = toggle_wifi(wifi_state)
elif tap[1] >= screen.MEDIA_TOP and screen.playing():
screen.pad("media")
elif linked:
voice = record()
if not send_voice(watch, voice):
kept = voice
elif kept is not None:
screen.say("one already waiting")
else:
kept = record()
screen.say("kept until the phone links")
except Exception as error:
screen.say("error: " + str(error))
keep_error(error)
flash.restart()
- how
- on the watch, the screen is a 240x240 st7789 on spi, powered by the pmu’s aldo2 and aldo3.
- why: the pins and rails are those LilyGo’s library gives the t-watch s3; the pmu answers on i2c at 0x34, and each rail is set to 3.3 V before it is switched on.
- how: the rotation is LilyGo’s, 2, which shows the text upright on the watch.
- how: spi runs in mode 3 at 20 MHz, as TFT_eSPI talks to the st7789, with no input pin.
- why: by default spi 1 takes gpio 13, its output, as its input.
- how: the font is st7789py’s vga2_8x16, 8x16 pixels.
- how: six of its unused control codes pairs hold 16x16 icons instead, a code its left half, the
next its right: 1 and 2 a call, 3 and 4 an SMS, 5 and 6 WhatsApp, 7 and 8 a mail, 14 and 15 ntfy,
16 and 17 a calendar.
- why: « icones for weol known apps notifs li;e call or sms »
- how: a glyph is 16 bytes, a row each, its leftmost pixel the high bit, as st7789py reads it.
- how: each icon is a filled shape, its details cut out.
- why: « _cons must be filled, to better see them »
- how: six of its unused control codes pairs hold 16x16 icons instead, a code its left half, the
next its right: 1 and 2 a call, 3 and 4 an SMS, 5 and 6 WhatsApp, 7 and 8 a mail, 14 and 15 ntfy,
16 and 17 a calendar.
ICONS = {
1: ("................",
"..####..........",
".######.........",
".######.........",
".#####..........",
"..####..........",
"..####..........",
"...####.........",
"....####........",
".....####..####.",
"......#########.",
".......########.",
"........#######.",
".........#####..",
"................",
"................"),
3: ("................",
".##############.",
".##############.",
".##..........##.",
".##############.",
".##.......#####.",
".##############.",
".##.....#######.",
".##############.",
".##############.",
"....###.........",
"....##..........",
"...#............",
"................",
"................",
"................"),
5: ("................",
".....######.....",
"...##########...",
"..############..",
".###..#########.",
".###..#########.",
".####.#########.",
".####..########.",
".#####..##..###.",
"..#####....###..",
"..############..",
".############...",
".###.######.....",
"................",
"................",
"................"),
7: ("................",
"................",
".##############.",
".#.##########.#.",
".##.########.##.",
".###.######.###.",
".####.####.####.",
".#####.##.#####.",
".######..######.",
".##############.",
".##############.",
".##############.",
"................",
"................",
"................",
"................"),
14: ("................",
"..############..",
".##############.",
".##.###########.",
".###.##########.",
".####.#########.",
".###.##########.",
".##.###.....###.",
".##############.",
"..############..",
"...###..........",
"...##...........",
"..#.............",
"................",
"................",
"................"),
16: ("................",
"...#.......#....",
".#############..",
".#############..",
".#############..",
".#############..",
".#..#..#..#..#..",
".#..#..#..#..#..",
".#############..",
".#..#..#..#..#..",
".#..#..#..#..#..",
".#############..",
"................",
"................",
"................",
"................"),
}
def glyph(rows, half):
return bytes(sum(0x80 >> x for x, pixel in enumerate(row[half * 8:half * 8 + 8]) if pixel == "#")
for row in rows)
def draw_into(font):
drawn = bytearray(font.FONT)
for code, rows in ICONS.items():
for half in (0, 1):
at = (code + half - font.FIRST) * font.HEIGHT
drawn[at:at + font.HEIGHT] = glyph(rows, half)
font.FONT = bytes(drawn)
def quarter(rows, half, side):
drawn = bytearray()
for y in range(16):
row = rows[half * 8 + y // 2][side * 8:side * 8 + 8]
bits = sum(0x8000 >> x for x in range(16) if row[x // 2] == "#")
drawn += bytes((bits >> 8, bits & 0xFF))
return bytes(drawn)
class Big:
WIDTH = 16
HEIGHT = 16
FIRST = 0
LAST = 4 * len(ICONS)
FONT = b"".join(quarter(ICONS[code], half, side)
for code in sorted(ICONS) for half in (0, 1) for side in (0, 1))
def big(code, half):
at = 4 * sorted(ICONS).index(code) + 2 * half
return chr(at) + chr(at + 1)
- how
- each icon is also drawn doubled, 32x32, as four 16x16 glyphs of a font of its own,
Big, its top half two glyphs, its bottom half two more;biggives the two codes of a half. - how
- on the desk,
icons-preview.pydraws the doubled ones four times bigger into a png, fromBigas the watch takes it, for an eye to check before they go to the watch.
import struct
import sys
import zlib
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent / "device"))
from glyphs import ICONS, Big, big # noqa: E402
SCALE = 4
GAP = 8
def png(path, rows):
height, width = len(rows), len(rows[0])
raw = b"".join(b"\x00" + bytes(0 if pixel else 255 for pixel in row) for row in rows)
chunk = lambda kind, data: (struct.pack(">I", len(data)) + kind + data
+ struct.pack(">I", zlib.crc32(kind + data)))
path.write_bytes(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 0, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))
def lit(code, y, x):
at = ord(big(code, y // 16)[x // 16]) * 32 + (y % 16) * 2 + (x % 16) // 8
return bool(Big.FONT[at] & 0x80 >> x % 8)
rows = []
for y in range(32 * SCALE):
row = []
for code in sorted(ICONS):
row += [lit(code, y // SCALE, x // SCALE) for x in range(32 * SCALE)] + [False] * GAP
rows.append(row)
png(Path(sys.argv[1]), rows)
- what
- « é », « à », « ç » and « è » sit at their codes in the pc’s code page 437, and its other glyphs are taken to follow that page too, as vga fonts do10.
- how
- each character is turned into its glyph’s code as its row is written.
- why: « make notifs work with french words, like ‘été’ »
- how
- the top row holds the hour, yellow and centered, redrawn only when it changes.
- how
- the lines fill the rows between it and the last two: the media row, then the button.
- how
- the media row shows what the phone plays, magenta and centered as « < title > », empty when nothing plays.
- how
- a pad fills the screen with tall columns, their names on the hour row: the media one three,
blue, green, blue, « back », « play/pause », « skip », the title on the media row; the actions one
three, olive, blue and red, « torch », the phone and « find », under the light bar, « 1/2 » after
the names; its second page two, orange « stay lit » and green « scene », « 2/2 »; lines, hour and bar are kept but
not drawn, and put back whole when it closes.
- how: a pad blackens its area first, so a page drawn over another keeps none of its gaps.
- how: the light bar spans the two rows under the hour row, white up to the light, grey after.
- how: a tap’s column is the screen asking which of its pad’s columns lies under it.
- how
- the hour, the media row and the pad’s names are centered by one helper, and whatever covers
the lines, torch, pad or picture, is asked of one test.
- why: « dry: the button, screen, slider etc can be frol a lib ? »
- why: the hour and media rows never scroll, nor do columns the full height, so the pad draws right wherever the lines stand.
- how
- the backlight, on gpio 45, goes off 5 s after the last line or wake, and back on at the
next; a new button label alone lights nothing, nor a line said not to light it.
- why: « 5s rather than 10s »
- how: with the backlight, the panel itself sleeps, and wakes before it lights.
- why: asleep, it keeps what was drawn and still takes new lines, at a fraction of its current.
- how: aldo2, the backlight’s own rail, goes off with it and back on before it lights, as LilyGo’s library does at brightness 0; aldo3 keeps the panel and touch fed11.
- how: when the light goes off, 5 s or a minute while staying lit, is one piece the screen and its fake under test both hold, so the stories time the watch’s own.
DARK_AFTER_MS = 5_000
STAY_LIT_MS = 60_000
STAY_FOR_MS = 600_000
_lit_until = None
def staying():
return _lit_until is not None and time.ticks_diff(_lit_until, time.ticks_ms()) > 0
def stay_lit():
global _lit_until
_lit_until = None if staying() else time.ticks_add(time.ticks_ms(), STAY_FOR_MS)
return _lit_until is not None
def _due():
return time.ticks_diff(time.ticks_ms(), _changed) > (STAY_LIT_MS if staying() else DARK_AFTER_MS)
LIGHT_LOG_BYTES = 4_000
_light_logged = None
def _log_light(what, drew=None):
global _light_logged
now = time.ticks_ms()
since = "" if _light_logged is None else " %d" % time.ticks_diff(now, _light_logged)
_light_logged = now
try:
if os.stat(flash.LIGHT_LOG)[6] > LIGHT_LOG_BYTES:
os.rename(flash.LIGHT_LOG, flash.LIGHT_LOG + ".old")
except OSError:
pass
with open(flash.LIGHT_LOG, "a") as log:
log.write("%d %s%s%s\n" % (now, what, since, "" if drew is None else " : " + drew))
ALIVE_EVERY_MS = 300_000
_alive = None
def alive():
global _alive
if _alive is None or time.ticks_diff(time.ticks_ms(), _alive) >= ALIVE_EVERY_MS:
_alive = time.ticks_ms()
_log_light("alive")
return True
return False
def heard(writes):
_log_light("heard " + writes)
def paced(every_ms, skips):
_log_light("pace every %d skips %d" % (every_ms, skips))
def asked(outcome):
_log_light("pace ask " + outcome)
CHARGE_LOG_BYTES = 4_000
def charge(percent, millivolts):
try:
if os.stat(flash.CHARGE_LOG)[6] > CHARGE_LOG_BYTES:
os.rename(flash.CHARGE_LOG, flash.CHARGE_LOG + ".old")
except OSError:
pass
with open(flash.CHARGE_LOG, "a") as log:
log.write("%d %d %d\n" % (time.ticks_ms(), percent, millivolts))
- how
- each dark and wake is written to the flash’s light log, as its ticks, « dark » or « wake »,
what caused it, and the ms since the one before; past 4 kB, the log becomes its
.oldand starts afresh.- how: a wake adds, after « : », what it shows: its view, the lines kept and the last one.
- why: « never work aroud, always fix. If not clear, add logs to improve knowledge »
- how
- lit, the backlight’s pin is pulsed fast, on the light’s share of the time, 25 % at start;
a tap along the actions pad’s light bar sets it there, from its left edge, 2 %, to its right, 100 %.
- why: « a way to cuange brightness »
- why: « control with q slider instead in the 1.5s :enu? »
- how
- a recall’s rows are drawn reversed, black on their color, until the next tap or wheel press.
- why: « new notif watching phone highlighted, like bold or something »
- how
- lit, the screen is either touch-sensitive, its bar white and its text black, or read-only; a line lighting a dark screen lights it read-only.
- how
- read-only, the bar’s color tells the charge, its text grey: blue down to 60 %, green to 40, yellow to 20, orange to 10, red below, white when no battery answers.
- how
- the bar’s first characters can stand out, as the percent does while charging: blue on the white bar, white on the colored one.
- how
- the last line can be redone alone, its row only redrawn, as a running timer is.
- how
- nothing is erased before being drawn: each row is written over itself padded to the full width, a new line redraws only its own row, and the bar only when its label or color changes.
- how
- when the lines are full, the st7789 scrolls them itself, a row at a time, and the new line
is drawn in the row that came free; the hour, the media row and the bar stay fixed.
- how: rotation 2 writes the frame memory bottom up, its row 239 at the screen’s top: the bar and the media row are the memory’s fixed top, the hour and the 80 rows never shown its fixed bottom.
- how: the scroll start goes back a row at each new line, so the lines go up the screen.
- how
- the last 50 lines are kept; browsing older ones scrolls the other way and draws the line that came in at the top; a new line brings the newest back, redrawing them all.
- how
- once there are more lines than rows, the hour row’s last 2 px show where the rows stand
among the kept lines, a blue thumb sized and placed in proportion, redrawn after each move and
after the hour.
- why: that row never scrolls, so the thumb never tears while the lines move.
- how
- as a torch, the screen is filled white, lit read-only and never darkened by the 5 s; lines,
hour and bar are kept but not drawn, and put back whole, read-only, when the torch goes out.
- how: the torch lights full, whatever the level the screen is set to.
- how
- as a picture, the screen is filled black, its scroll put back, and each half-row drawn as it
comes; lines, hour and bar are kept but not drawn, and put back whole when it closes.
- why: rows land where the picture says only while the memory is not scrolled.
- how
- as a scene, the screen is filled with the scene’s backdrop, its scroll put back, the scene drawn once, then each frame every 250 ms; it is never darkened by the 5 s; lines, hour and bar are put back whole after.
- how
- a line wider than the screen is cut at its spaces into rows of 30 columns at most, each kept as a line of its own; a word wider than a row is cut where the row ends.
- how
- a line holding an icon’s two codes shows the icon doubled, 32x32, at its left edge over its
first two rows; the codes are taken out, and the rest, stamp first, is cut into rows of 26 columns
beside it, two rows at least.
- why: « icons shluld be bigger », « taller », « also alçgned w_th text »
- how: each icon is drawn in its app’s color on black, whatever its line’s: call green, SMS
blue, WhatsApp light green, mail red, ntfy teal, calendar orange.
- why: « icones in color »
- how: the fake screen writes the icon’s two codes ahead of the first row, two spaces ahead of the others.
- how
- a recall, phone notifications included, is drawn by its priority, cyan when only shown, yellow when it buzzes, red when it rings; the watch’s own lines are white; each kept line keeps its color, so browsing and redoing it draw it the same.
def _wrap(text, columns):
rows, row = [], ""
for word in text.split(" "):
while len(word) > columns:
if row:
rows.append(row)
row = ""
rows.append(word[:columns])
word = word[columns:]
if row and len(row) + 1 + len(word) > columns:
rows.append(row)
row = word
else:
row = row + " " + word if row else word
rows.append(row)
return rows
ICON_CODES = (1, 3, 5, 7, 14, 16)
ICON_COLUMNS = 4
def _laid(line, columns):
for code in ICON_CODES:
pair = chr(code) + chr(code + 1)
if pair in line:
rows = _wrap(line.replace(pair, "", 1), columns - ICON_COLUMNS)
return [(code, half, row) for half, row in enumerate(rows + [""] * (2 - len(rows)))]
return [(None, 0, row) for row in _wrap(line, columns)]
def _tint(priority):
return "red" if priority >= 4 else "yellow" if priority == 3 else "cyan"
- how
- a scene’s drawing is a list of rectangles, each with its color, that the screen fills and
its fake under test counts, so the stories compute every shape the watch draws.
- why: the pumpkin’s last band asked a square root below zero and crashed the watch, where the fake, drawing nothing, stayed green.
- how
- as halloween, kept but played no more, a jack-o’-lantern is drawn on night purple in bands of
rectangles: five lobes, darker outwards so their ribs show, a curved stem and a leaf, triangle eyes
and nose and a toothed grin glowing yellow; every frame a black bat moves 10 px along the top,
flapping, and the glow dims one frame in eight, like a candle.
- why: « the pumpkin is not realistic. it looks like orange ball »
- why: « keep halloween code, but don’t put on the watch anymore »
NIGHT = (40, 0, 64)
BAT = (0, 0, 0)
GLOW, EMBER = (255, 220, 0), (200, 70, 0)
PUMPKIN_X, PUMPKIN_Y = 120, 135
LOBES = ((70, 42, 55, (150, 50, 0)), (38, 48, 66, (215, 85, 0)), (0, 36, 72, (255, 120, 10)))
STEM = ((112, 52, 16, 14, (90, 70, 20)), (118, 44, 14, 10, (90, 70, 20)), (124, 38, 12, 8, (90, 70, 20)),
(128, 58, 22, 3, (60, 140, 30)), (146, 52, 4, 8, (60, 140, 30)))
BAND = 4
BAT_Y = 24
FRAME_MS = 250
def _lobe(cx, rx, ry, color):
rects = []
for dy in range(-ry, ry, BAND):
mid = (dy + BAND / 2) / ry
half = int(rx * math.sqrt(max(0, 1 - mid * mid)))
if half:
rects.append((cx - half, PUMPKIN_Y + dy, 2 * half, BAND, color))
return rects
def pumpkin_rects():
rects = []
for dx, rx, ry, color in LOBES:
for cx in ((PUMPKIN_X - dx, PUMPKIN_X + dx) if dx else (PUMPKIN_X,)):
rects += _lobe(cx, rx, ry, color)
return rects + list(STEM)
def _triangle(cx, top, height, width, color):
return [(cx - width * (row + 3) // (2 * height), top + row, width * (row + 3) // height, 3, color)
for row in range(0, height, 3)]
def face_rects(glow):
rects = _triangle(88, 102, 27, 32, glow) + _triangle(152, 102, 27, 32, glow)
rects += _triangle(PUMPKIN_X, 136, 15, 16, glow)
for at, x in enumerate(range(66, 174, 6)):
u = (x + 3 - PUMPKIN_X) / 54
sag = int(12 * (1 - u * u))
top, bottom = 160 + sag, 168 + 2 * sag
if at % 4 == 1:
top += 6
if at % 4 == 3:
bottom -= 6
rects.append((x, top, 6, bottom - top, glow))
return rects
def bat_rects(frame, color):
x = frame * 10 % 280 - 40
if not 0 <= x <= 200:
return []
wing = BAT_Y - 4 if frame % 2 else BAT_Y + 6
return [(x + 16, BAT_Y, 8, 10, color), (x, wing, 16, 4, color), (x + 24, wing, 16, 4, color)]
def glow_at(frame):
return EMBER if frame % 8 == 7 else GLOW
NAME = "halloween"
BACKDROP = NIGHT
PARTS = ("pumpkin", "bat")
WORDS = ()
def scene_rects():
return pumpkin_rects()
def frame_rects(before, frame):
rects = [] if before is None else bat_rects(before, NIGHT)
if before is None or glow_at(frame) != glow_at(before):
rects += face_rects(glow_at(frame))
return rects + bat_rects(frame, BAT)
def outside(rects):
return sum(not (0 <= x and 0 <= y and w >= 0 and h >= 0 and x + w <= 240 and y + h <= 240)
for x, y, w, h, _ in rects)
- how
- as Christmas, the screen is filled midnight blue and a fir drawn in three tiers of green bands,
wider downwards, on a brown trunk, two ribboned gifts at its foot on a snowy ground that says
« Joyeux Noël »; every 250 ms ten garland lights step through red, gold and blue, the star on top
dims one frame in eight, eight sky stars twinkle in turn, six flakes fall 4 px beside the fir onto
two mounds that grow a pixel every 2 s up to 12, and a sleigh drawn by a reindeer crosses the top
every 15 s.
- why: « create a christmas one that will take its place in the watch »
- why: « make a fancier christmas animation »
- how: a scene is a block giving its
NAME, itsBACKDROP, the rectangles drawn once, itsWORDSwritten once on them, those of each frame from the one before, itsPARTSnamed for the stories, andoutside; the screen pulls in the one in use.
NAME = "christmas"
BACKDROP = (0, 0, 48)
SNOW = (255, 255, 255)
STAR, STAR_DIM = (255, 220, 0), (160, 120, 0)
FIR = (0, 110, 40)
TRUNK = (90, 50, 20)
GARLAND = ((220, 0, 0), (255, 200, 0), (0, 90, 255))
TIERS = ((50, 50, 40), (80, 60, 60), (115, 70, 85))
LIGHTS = ((116, 72), (110, 92), (128, 100), (90, 126), (146, 122), (100, 146), (132, 140), (70, 176),
(110, 166), (156, 172))
FLAKES = ((8, 0), (20, 60), (30, 120), (206, 30), (218, 90), (228, 150))
SKY = ((52, 30), (84, 46), (160, 34), (188, 58), (60, 84), (176, 100), (48, 140), (190, 150))
SKY_BRIGHT, SKY_DIM = (255, 255, 200), (70, 70, 110)
GIFTS = ((80, 189, 24, 16, (220, 0, 0)), (136, 193, 20, 12, (0, 90, 255)))
RIBBON = (255, 200, 0)
SLED, DEER = (200, 0, 0), (130, 80, 30)
PARTS = ("fir", "star", "garland", "snow", "pile", "gifts", "sky", "sleigh")
WORDS = (("Joyeux Noël", 76, 216, (200, 0, 0), SNOW),)
BAND = 4
LIGHT = 6
FLAKE = 3
GROUND = 205
PILE = 12
SLEIGH_Y = 4
SLEIGH_WIDE = 46
FRAME_MS = 250
def gift_rects():
rects = []
for x, y, w, h, color in GIFTS:
rects += [(x, y, w, h, color), (x + w // 2 - 2, y, 4, h, RIBBON), (x, y + h // 2 - 2, w, 4, RIBBON)]
return rects
def scene_rects():
rects = []
for top, height, half in TIERS:
for y in range(top, top + height, BAND):
wide = half * (y - top + BAND) // height
rects.append((120 - wide, y, 2 * wide, BAND, FIR))
return rects + [(110, 185, 20, 20, TRUNK), (0, GROUND, 240, 240 - GROUND, SNOW)] + gift_rects()
def pile_at(frame):
return min(PILE, frame // 8)
def pile_rects(frame):
high = pile_at(frame)
return [(0, GROUND - high, 42, high, SNOW), (196, GROUND - high, 44, high, SNOW)]
def sky_rects(frame):
return [(x, y, 2, 2, SKY_DIM if (frame + 3 * at) % 7 == 0 else SKY_BRIGHT) for at, (x, y) in enumerate(SKY)]
def sleigh_rects(frame, background=None):
x = frame * 6 % 360 - 60
if not 0 <= x <= 240 - SLEIGH_WIDE:
return []
y = SLEIGH_Y
rects = [(x + 30, y + 4, 14, 5, DEER), (x + 42, y, 4, 5, DEER), (x + 31, y + 9, 2, 3, DEER),
(x + 41, y + 9, 2, 3, DEER), (x + 20, y + 5, 10, 1, RIBBON), (x, y + 6, 20, 6, SLED),
(x, y + 12, 22, 2, RIBBON), (x + 6, y, 8, 6, SLED)]
return rects if background is None else [rect[:4] + (background,) for rect in rects]
def star_rects(color):
return [(117, 28, 6, 22, color), (109, 36, 22, 6, color), (113, 32, 14, 14, color)]
def light_rects(frame):
return [(x, y, LIGHT, LIGHT, GARLAND[(at + frame) % len(GARLAND)]) for at, (x, y) in enumerate(LIGHTS)]
def flake_rects(frame, color):
return [(x, 20 + (start + 4 * frame) % (GROUND - PILE - 25), FLAKE, FLAKE, color) for x, start in FLAKES]
def star_at(frame):
return STAR_DIM if frame % 8 == 7 else STAR
def frame_rects(before, frame):
rects = [] if before is None else flake_rects(before, BACKDROP) + sleigh_rects(before, BACKDROP)
if before is None or star_at(frame) != star_at(before):
rects += star_rects(star_at(frame))
if before is None or pile_at(frame) != pile_at(before):
rects += pile_rects(frame)
return rects + light_rects(frame) + sky_rects(frame) + flake_rects(frame, SNOW) + sleigh_rects(frame)
def outside(rects):
return sum(not (0 <= x and 0 <= y and w >= 0 and h >= 0 and x + w <= 240 and y + h <= 240)
for x, y, w, h, _ in rects)
- how
- a title longer than the media row’s 26 letters scrolls along it, a letter every 300 ms, only
while lit, three spaces between its end and its start again; a shorter one stays still.
- why: « i want to see the whole audio track name, not a truncated version », « scroll »
MEDIA_COLUMNS = COLUMNS - 4
SCROLL_MS = 300
SCROLL_GAP = " "
_media_step = 0
_media_moved = 0
def _media_window(title, step):
if len(title) <= MEDIA_COLUMNS:
return title
loop = title + SCROLL_GAP
at = step % len(loop)
return (loop + loop)[at:at + MEDIA_COLUMNS]
def _media_due():
global _media_step, _media_moved
if len(_media) <= MEDIA_COLUMNS or time.ticks_diff(time.ticks_ms(), _media_moved) < SCROLL_MS:
return False
_media_moved = time.ticks_ms()
_media_step += 1
return True
- how
- a picture, a scene or a pad holds the whole screen still: new lines are kept, but neither
scroll it nor draw over it, until the wheel puts the lines back.
- why: « bug: the pumpkin scrolls »
- why: « in the menu, the horizontal slider is not always on the top »
def _still():
return _picture or _scene or _pad
import math
import os
import time
from machine import I2C, PWM, SPI, Pin
import st7789py
import vga2_8x16
import flash
import glyphs
glyphs.draw_into(vga2_8x16)
FULL = 65_535
START_LIGHT = 25
LIGHT_MIN = 2
HOUR_BOTTOM = vga2_8x16.HEIGHT
SLIDER_BOTTOM = 3 * vga2_8x16.HEIGHT
GREY = st7789py.color565(64, 64, 64)
PMU = 0x34
LDO_ONOFF = 0x90
ALDO2_VOLT, ALDO3_VOLT = 0x93, 0x94
ALDO2_ON, ALDO3_ON = 0b10, 0b100
COLUMNS = 240 // vga2_8x16.WIDTH
ROWS = 240 // vga2_8x16.HEIGHT - 3
MEDIA_TOP = (ROWS + 1) * vga2_8x16.HEIGHT
BUTTON_TOP = (ROWS + 2) * vga2_8x16.HEIGHT
# upside down, the panel's fixed top area is what shows at the bottom: the media row and the bar
FIXED_BOTTOM = 240 - MEDIA_TOP
MEMORY_ROWS = 320
HISTORY = 50
SCROLLBAR_HEIGHT = 2
SCROLLBAR_Y = vga2_8x16.HEIGHT - SCROLLBAR_HEIGHT
THUMB_MIN = 4
PADS = {
"media": (("back", st7789py.color565(0, 0, 112)), ("play/pause", st7789py.color565(0, 80, 0)),
("skip", st7789py.color565(0, 0, 112))),
"actions": (("torch", st7789py.color565(96, 96, 0)), ("any phone", st7789py.color565(0, 64, 112)),
("find", st7789py.color565(112, 0, 0))),
"more": (("stay lit", st7789py.color565(112, 56, 0)), ("scene", st7789py.color565(0, 96, 32))),
}
PAGES = {"actions": "1/2", "more": "2/2"}
ICON_INKS = {
1: st7789py.color565(0, 160, 0),
3: st7789py.color565(0, 120, 255),
5: st7789py.color565(37, 211, 102),
7: st7789py.color565(230, 40, 40),
14: st7789py.color565(0, 170, 170),
16: st7789py.color565(255, 140, 0),
}
_pmu = I2C(0, sda=Pin(10), scl=Pin(11), freq=400_000)
def _update(register, keep, bits):
value = _pmu.readfrom_mem(PMU, register, 1)[0] & keep | bits
_pmu.writeto_mem(PMU, register, bytes((value,)))
def _backlight_rail(on):
_update(LDO_ONOFF, 0xFF if on else ~ALDO2_ON & 0xFF, ALDO2_ON if on else 0)
def _power_on():
# 3.3 V is (3300 - 500) / 100 steps above the rails' floor
_update(ALDO2_VOLT, 0xE0, 28)
_update(ALDO3_VOLT, 0xE0, 28)
_update(LDO_ONOFF, 0xFF, ALDO2_ON | ALDO3_ON)
_power_on()
_backlight = PWM(Pin(45), freq=1000, duty_u16=0)
_tft = st7789py.ST7789(
SPI(1, baudrate=20_000_000, polarity=1, phase=1, sck=Pin(18), mosi=Pin(13), miso=None),
240, 240,
dc=Pin(38, Pin.OUT), cs=Pin(12, Pin.OUT), backlight=None, rotation=2)
_tft.fill(st7789py.BLACK)
_tft.vscrdef(FIXED_BOTTOM, ROWS * vga2_8x16.HEIGHT, MEMORY_ROWS - FIXED_BOTTOM - ROWS * vga2_8x16.HEIGHT)
_tft.vscsad(FIXED_BOTTOM)
_lines = []
_first = 0
_back = 0
_button = ""
_blue = 0
_charge = None
_wifi = "off"
_phone = "waiting"
_clock = ""
_media = ""
_changed = time.ticks_ms()
_touchable = False
_torch = False
_picture = False
_scene = False
_pad = None
_lit = False
_light = START_LIGHT
HALF = 120
# the font's glyphs from 0x80 on, in code page 437's order
CP437 = "ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒáíóúñѪº¿⌐¬½¼¡«»░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■"
def _glyph(char):
return char if char < "\x80" or char not in CP437 else chr(0x80 + CP437.index(char))
def _full(text, columns=COLUMNS):
text = text[:columns] + " " * (columns - len(text[:columns]))
return "".join(_glyph(char) for char in text)
def _wrap(text, columns):
rows, row = [], ""
for word in text.split(" "):
while len(word) > columns:
if row:
rows.append(row)
row = ""
rows.append(word[:columns])
word = word[columns:]
if row and len(row) + 1 + len(word) > columns:
rows.append(row)
row = word
else:
row = row + " " + word if row else word
rows.append(row)
return rows
ICON_CODES = (1, 3, 5, 7, 14, 16)
ICON_COLUMNS = 4
def _laid(line, columns):
for code in ICON_CODES:
pair = chr(code) + chr(code + 1)
if pair in line:
rows = _wrap(line.replace(pair, "", 1), columns - ICON_COLUMNS)
return [(code, half, row) for half, row in enumerate(rows + [""] * (2 - len(rows)))]
return [(None, 0, row) for row in _wrap(line, columns)]
def _tint(priority):
return "red" if priority >= 4 else "yellow" if priority == 3 else "cyan"
WIFI_TINTS = {"off": "grey", "joining": "yellow", "joined": "green", "not found": "red"}
PHONE_TINTS = {"waiting": "yellow", "linked": "green"}
DARK_AFTER_MS = 5_000
STAY_LIT_MS = 60_000
STAY_FOR_MS = 600_000
_lit_until = None
def staying():
return _lit_until is not None and time.ticks_diff(_lit_until, time.ticks_ms()) > 0
def stay_lit():
global _lit_until
_lit_until = None if staying() else time.ticks_add(time.ticks_ms(), STAY_FOR_MS)
return _lit_until is not None
def _due():
return time.ticks_diff(time.ticks_ms(), _changed) > (STAY_LIT_MS if staying() else DARK_AFTER_MS)
LIGHT_LOG_BYTES = 4_000
_light_logged = None
def _log_light(what, drew=None):
global _light_logged
now = time.ticks_ms()
since = "" if _light_logged is None else " %d" % time.ticks_diff(now, _light_logged)
_light_logged = now
try:
if os.stat(flash.LIGHT_LOG)[6] > LIGHT_LOG_BYTES:
os.rename(flash.LIGHT_LOG, flash.LIGHT_LOG + ".old")
except OSError:
pass
with open(flash.LIGHT_LOG, "a") as log:
log.write("%d %s%s%s\n" % (now, what, since, "" if drew is None else " : " + drew))
ALIVE_EVERY_MS = 300_000
_alive = None
def alive():
global _alive
if _alive is None or time.ticks_diff(time.ticks_ms(), _alive) >= ALIVE_EVERY_MS:
_alive = time.ticks_ms()
_log_light("alive")
return True
return False
def heard(writes):
_log_light("heard " + writes)
def paced(every_ms, skips):
_log_light("pace every %d skips %d" % (every_ms, skips))
def asked(outcome):
_log_light("pace ask " + outcome)
CHARGE_LOG_BYTES = 4_000
def charge(percent, millivolts):
try:
if os.stat(flash.CHARGE_LOG)[6] > CHARGE_LOG_BYTES:
os.rename(flash.CHARGE_LOG, flash.CHARGE_LOG + ".old")
except OSError:
pass
with open(flash.CHARGE_LOG, "a") as log:
log.write("%d %d %d\n" % (time.ticks_ms(), percent, millivolts))
def _shown():
return max(0, len(_lines) - ROWS - _back)
def _scroll(rows):
global _first
_first = (_first + rows) % ROWS
if not _still():
_tft.vscsad(FIXED_BOTTOM + vga2_8x16.HEIGHT * ((ROWS - _first) % ROWS))
def _row(row):
index = _shown() + row
text, color, ruled, fresh, icon, half = (_lines[index] if index < len(_lines)
else ("", st7789py.WHITE, False, False, None, 0))
top = ((_first + row) % ROWS + 1) * vga2_8x16.HEIGHT
ink, paper = (st7789py.BLACK, color) if fresh else (color, st7789py.BLACK)
if icon is None:
_tft.text(vga2_8x16, _full(text), 0, top, ink, paper)
else:
_tft.text(vga2_8x16, _full(" " * ICON_COLUMNS + text), 0, top, ink, paper)
if half < 2:
_tft.text(glyphs.Big, glyphs.big(icon, half), 0, top, ICON_INKS[icon], st7789py.BLACK)
if ruled:
_tft.hline(0, top, 240, RULE)
def _covered():
return _torch or _pad or _picture or _scene
def _scrollbar():
if len(_lines) <= ROWS:
return
left = _shown() * 240 // len(_lines)
right = left + max(THUMB_MIN, ROWS * 240 // len(_lines))
for a, b, color in ((0, left, st7789py.BLACK), (left, right, st7789py.BLUE), (right, 240, st7789py.BLACK)):
if b > a and not _covered():
_tft.fill_rect(a, SCROLLBAR_Y, b - a, SCROLLBAR_HEIGHT, color)
CHARGE_COLORS = ((60, st7789py.color565(0, 160, 255)), (40, st7789py.GREEN), (20, st7789py.YELLOW),
(10, st7789py.color565(255, 140, 0)), (0, st7789py.RED))
READ_ONLY_TEXT = st7789py.color565(96, 96, 96)
RULE = st7789py.color565(96, 96, 96)
def _charge_color():
if _charge is None:
return st7789py.WHITE
return next(color for floor, color in CHARGE_COLORS if _charge >= floor)
def _bar():
color = st7789py.WHITE if _touchable else _charge_color()
ink = st7789py.BLACK if _touchable else READ_ONLY_TEXT
charging = st7789py.BLUE if _touchable else st7789py.WHITE
text = _full(_button)
_tft.text(vga2_8x16, text[:_blue], 0, BUTTON_TOP, charging, color)
_tft.text(vga2_8x16, text[_blue:], _blue * vga2_8x16.WIDTH, BUTTON_TOP, ink, color)
for letter, tint in (("W", WIFI_TINTS[_wifi]), ("B", PHONE_TINTS[_phone])):
at = text.find(" %s " % letter) + 1
if at:
ink = READ_ONLY_TEXT if tint == "grey" else getattr(st7789py, tint.upper())
_tft.text(vga2_8x16, letter, at * vga2_8x16.WIDTH, BUTTON_TOP, ink, st7789py.BLACK)
def _say(line, color, ruled, fresh, icon=None, half=0):
global _back
browsed = _back
_back = 0
if not browsed and len(_lines) >= ROWS:
_scroll(1)
_lines.append((line, color, ruled, fresh, icon, half))
del _lines[:-HISTORY]
if _covered():
return
for row in range(ROWS) if browsed else (min(len(_lines), ROWS) - 1,):
_row(row)
_scrollbar()
def say(line, light=True, recall=0):
color = getattr(st7789py, _tint(recall).upper()) if recall else st7789py.WHITE
for at, (icon, half, row) in enumerate(_laid(line, COLUMNS)):
_say(row, color, recall and at == 0, bool(recall), icon, half)
if light and not _torch:
pad(None)
wake(_touchable and lit())
def seen():
global _changed
if lit():
_changed = time.ticks_ms()
if not any(line[3] for line in _lines):
return
_lines[:] = [line[:3] + (False,) + line[4:] for line in _lines]
if not _covered():
for row in range(ROWS):
_row(row)
def browse(rows):
global _back
for _ in range(abs(rows)):
if rows > 0 and _shown() > 0:
_back += 1
_scroll(-1)
if not _covered():
_row(0)
elif rows < 0 and _back > 0:
_back -= 1
_scroll(1)
if not _covered():
_row(ROWS - 1)
_scrollbar()
wake(_touchable and lit(), "browse")
def redo(line):
_lines[-1] = (line,) + _lines[-1][1:]
if not _back and not _covered():
_row(min(len(_lines), ROWS) - 1)
def button(label, blue=0, charge=None, wifi="off", phone="waiting"):
global _button, _blue, _charge, _wifi, _phone
if (label, blue, charge, wifi, phone) != (_button, _blue, _charge, _wifi, _phone):
_button, _blue, _charge, _wifi, _phone = label, blue, charge, wifi, phone
if not (_torch or _pad or _scene):
_bar()
def _hour():
_tft.text(vga2_8x16, _full(_centered(_clock)), 0, 0, st7789py.YELLOW, st7789py.BLACK)
_scrollbar()
def clock(text):
global _clock
if text != _clock:
_clock = text
if not (_torch or _pad or _scene):
_hour()
MEDIA_COLUMNS = COLUMNS - 4
SCROLL_MS = 300
SCROLL_GAP = " "
_media_step = 0
_media_moved = 0
def _media_window(title, step):
if len(title) <= MEDIA_COLUMNS:
return title
loop = title + SCROLL_GAP
at = step % len(loop)
return (loop + loop)[at:at + MEDIA_COLUMNS]
def _media_due():
global _media_step, _media_moved
if len(_media) <= MEDIA_COLUMNS or time.ticks_diff(time.ticks_ms(), _media_moved) < SCROLL_MS:
return False
_media_moved = time.ticks_ms()
_media_step += 1
return True
def _media_row():
text = "< %s >" % _media_window(_media, _media_step) if _media else ""
_tft.text(vga2_8x16, _full(_centered(text)), 0, MEDIA_TOP, st7789py.MAGENTA, st7789py.BLACK)
def media(title):
global _media, _media_step
if title != _media:
_media, _media_step = title, 0
if not (_torch or _picture or _scene):
_media_row()
def _scroll_title():
if _media_due() and not (_torch or _picture or _scene) and _pad in (None, "media"):
_media_row()
def playing():
return bool(_media)
def torch(on):
global _torch
if on == _torch:
return
_torch = on
if on:
_close_pad()
wake(why="torch")
_tft.fill(st7789py.WHITE)
return
_redraw()
wake(why="torch")
def _redraw():
_tft.fill(st7789py.BLACK)
_hour()
for row in range(ROWS):
_row(row)
_scrollbar()
_media_row()
_bar()
def torch_on():
return _torch
def picture(on):
global _picture
if on == _picture:
return
_picture = on
if on:
_close_pad()
_tft.vscsad(FIXED_BOTTOM)
_tft.fill_rect(0, PICTURE_TOP, 240, BUTTON_TOP - PICTURE_TOP, st7789py.BLACK)
_hour()
_bar()
wake(why="picture")
return
_scroll(0)
_redraw()
wake(why="picture")
# the picture's band, between the hour and the bar, its rows counted from the hour's foot
PICTURE_TOP = vga2_8x16.HEIGHT
def picture_rows(pieces):
picture(True)
view = memoryview(pieces)
for at in range(0, len(pieces), 2 + 2 * HALF):
_tft.blit_buffer(view[at + 2:at + 2 + 2 * HALF], pieces[at + 1] * HALF, PICTURE_TOP + pieces[at],
HALF, 1)
wake(why="picture")
def picture_on():
return _picture
NAME = "christmas"
BACKDROP = (0, 0, 48)
SNOW = (255, 255, 255)
STAR, STAR_DIM = (255, 220, 0), (160, 120, 0)
FIR = (0, 110, 40)
TRUNK = (90, 50, 20)
GARLAND = ((220, 0, 0), (255, 200, 0), (0, 90, 255))
TIERS = ((50, 50, 40), (80, 60, 60), (115, 70, 85))
LIGHTS = ((116, 72), (110, 92), (128, 100), (90, 126), (146, 122), (100, 146), (132, 140), (70, 176),
(110, 166), (156, 172))
FLAKES = ((8, 0), (20, 60), (30, 120), (206, 30), (218, 90), (228, 150))
SKY = ((52, 30), (84, 46), (160, 34), (188, 58), (60, 84), (176, 100), (48, 140), (190, 150))
SKY_BRIGHT, SKY_DIM = (255, 255, 200), (70, 70, 110)
GIFTS = ((80, 189, 24, 16, (220, 0, 0)), (136, 193, 20, 12, (0, 90, 255)))
RIBBON = (255, 200, 0)
SLED, DEER = (200, 0, 0), (130, 80, 30)
PARTS = ("fir", "star", "garland", "snow", "pile", "gifts", "sky", "sleigh")
WORDS = (("Joyeux Noël", 76, 216, (200, 0, 0), SNOW),)
BAND = 4
LIGHT = 6
FLAKE = 3
GROUND = 205
PILE = 12
SLEIGH_Y = 4
SLEIGH_WIDE = 46
FRAME_MS = 250
def gift_rects():
rects = []
for x, y, w, h, color in GIFTS:
rects += [(x, y, w, h, color), (x + w // 2 - 2, y, 4, h, RIBBON), (x, y + h // 2 - 2, w, 4, RIBBON)]
return rects
def scene_rects():
rects = []
for top, height, half in TIERS:
for y in range(top, top + height, BAND):
wide = half * (y - top + BAND) // height
rects.append((120 - wide, y, 2 * wide, BAND, FIR))
return rects + [(110, 185, 20, 20, TRUNK), (0, GROUND, 240, 240 - GROUND, SNOW)] + gift_rects()
def pile_at(frame):
return min(PILE, frame // 8)
def pile_rects(frame):
high = pile_at(frame)
return [(0, GROUND - high, 42, high, SNOW), (196, GROUND - high, 44, high, SNOW)]
def sky_rects(frame):
return [(x, y, 2, 2, SKY_DIM if (frame + 3 * at) % 7 == 0 else SKY_BRIGHT) for at, (x, y) in enumerate(SKY)]
def sleigh_rects(frame, background=None):
x = frame * 6 % 360 - 60
if not 0 <= x <= 240 - SLEIGH_WIDE:
return []
y = SLEIGH_Y
rects = [(x + 30, y + 4, 14, 5, DEER), (x + 42, y, 4, 5, DEER), (x + 31, y + 9, 2, 3, DEER),
(x + 41, y + 9, 2, 3, DEER), (x + 20, y + 5, 10, 1, RIBBON), (x, y + 6, 20, 6, SLED),
(x, y + 12, 22, 2, RIBBON), (x + 6, y, 8, 6, SLED)]
return rects if background is None else [rect[:4] + (background,) for rect in rects]
def star_rects(color):
return [(117, 28, 6, 22, color), (109, 36, 22, 6, color), (113, 32, 14, 14, color)]
def light_rects(frame):
return [(x, y, LIGHT, LIGHT, GARLAND[(at + frame) % len(GARLAND)]) for at, (x, y) in enumerate(LIGHTS)]
def flake_rects(frame, color):
return [(x, 20 + (start + 4 * frame) % (GROUND - PILE - 25), FLAKE, FLAKE, color) for x, start in FLAKES]
def star_at(frame):
return STAR_DIM if frame % 8 == 7 else STAR
def frame_rects(before, frame):
rects = [] if before is None else flake_rects(before, BACKDROP) + sleigh_rects(before, BACKDROP)
if before is None or star_at(frame) != star_at(before):
rects += star_rects(star_at(frame))
if before is None or pile_at(frame) != pile_at(before):
rects += pile_rects(frame)
return rects + light_rects(frame) + sky_rects(frame) + flake_rects(frame, SNOW) + sleigh_rects(frame)
def outside(rects):
return sum(not (0 <= x and 0 <= y and w >= 0 and h >= 0 and x + w <= 240 and y + h <= 240)
for x, y, w, h, _ in rects)
def _still():
return _picture or _scene or _pad
_played = 0
_frame = None
def _fill(rects):
for x, y, w, h, color in rects:
_tft.fill_rect(x, y, w, h, st7789py.color565(*color))
def scene(on):
global _scene, _played, _frame
if on == _scene:
return
_scene = on
if on:
_close_pad()
_tft.vscsad(FIXED_BOTTOM)
_tft.fill(st7789py.color565(*BACKDROP))
_fill(scene_rects())
for text, x, y, ink, paper in WORDS:
_tft.text(vga2_8x16, "".join(_glyph(char) for char in text), x, y,
st7789py.color565(*ink), st7789py.color565(*paper))
_played, _frame = time.ticks_ms(), None
wake(why="scene")
return
_scroll(0)
_redraw()
wake(why="scene")
def scene_on():
return _scene
def _play():
global _frame
frame = time.ticks_diff(time.ticks_ms(), _played) // FRAME_MS
if frame == _frame:
return
_fill(frame_rects(_frame, frame))
_frame = frame
def _close_pad():
global _pad
_pad = None
def pad(name):
global _pad
if name == _pad or _torch or _scene:
return
_pad = name
if name is None:
_scroll(0)
_redraw()
return
_tft.vscsad(FIXED_BOTTOM)
columns = PADS[name]
top = SLIDER_BOTTOM if name in PAGES else 0
width = 240 // len(columns)
_tft.fill_rect(0, top, 240, 240 - top, st7789py.BLACK)
for at, (_, color) in enumerate(columns):
_tft.fill_rect(at * width + 1, top, width - 2, 240 - top, color)
_pad_labels()
if name == "media":
_media_row()
if name in PAGES:
_slider()
_relabeled = {}
def _pad_labels():
columns = PADS[_pad]
page = PAGES.get(_pad, "")
width = (COLUMNS - len(page)) // len(columns)
labels = "".join(_centered(_relabeled.get((_pad, at), label)[:width], width)
for at, (label, _) in enumerate(columns))
_tft.text(vga2_8x16, _full(labels + page), 0, 0, st7789py.WHITE, st7789py.BLACK)
def pad_label(name, column, label):
_relabeled[(name, column)] = label
if _pad == name:
_pad_labels()
def _slider():
filled = 240 * _light // 100
_tft.fill_rect(0, HOUR_BOTTOM, filled, SLIDER_BOTTOM - HOUR_BOTTOM, st7789py.WHITE)
_tft.fill_rect(filled, HOUR_BOTTOM, 240 - filled, SLIDER_BOTTOM - HOUR_BOTTOM, GREY)
def pad_on():
return _pad
def pad_column(x):
return x * len(PADS[_pad]) // 240
def _centered(text, columns=COLUMNS):
left = (columns - len(text)) // 2
return " " * left + text + " " * (columns - len(text) - left)
def wake(touchable=False, why="line"):
global _changed, _touchable
_changed = time.ticks_ms()
if touchable != _touchable:
_touchable = touchable
if not _covered():
_bar()
if not lit():
_log_light("wake " + why, _drew())
_backlight_rail(True)
_tft.cs.on()
_tft.init(_tft.init_cmds)
_tft.rotation(2)
_tft.vscrdef(FIXED_BOTTOM, ROWS * vga2_8x16.HEIGHT, MEMORY_ROWS - FIXED_BOTTOM - ROWS * vga2_8x16.HEIGHT)
_scroll(0)
_log_light("shown")
_shine()
def _drew():
view = "torch" if _torch else "picture" if _picture else "scene" if _scene else _pad or "lines"
return "%s %d %s" % (view, len(_lines), _lines[-1][0] if _lines else "")
def _shine():
global _lit
_lit = True
_backlight.init(freq=1000, duty_u16=FULL if _torch else FULL * _light // 100)
def light_at(x):
global _light
_light = max(LIGHT_MIN, (x + 1) * 100 // 240)
if _pad in PAGES:
_slider()
if lit():
_shine()
def lit():
return _lit
def touchable():
return lit() and _touchable
def dark(why="idle"):
global _lit
_log_light("dark " + why)
_lit = False
_backlight.duty_u16(0)
_tft.sleep_mode(True)
_tft.cs.on()
_backlight_rail(False)
def idle():
if _scene:
_play()
if lit():
_scroll_title()
if lit() and not (_torch or _picture or _scene) and _due():
pad(None)
dark()
- how
- under test, the screen is a fake that appends each line to the file the harness names, cut
into rows as the screen cuts it, each line redone in place too, and the hour when it changes; the
rows of a recall go also to that file’s
.recallstwin, as the screen draws them in their own color, each recall opened by a row of dashes where the screen rules its line, and its color’s name to the.tintstwin; it starts lit and read-only, or dark when the harness says so; of its button, only whether it shows charging and the colors of its « W » and « B » go, as they change, to the.bartwin; a recall’s first row goes to the.freshtwin as « fresh … », then « seen » once a tap or press clears it; the light level goes to the.lighttwin.- how: the torch writes « torch on » and « torch off » as lines.
- how: a picture writes « picture on » and « picture off » as lines, and each half-row it draws to
the
.picturetwin, as its row, its half and its first pixel in hex. - how: a scene writes « scene on » and « scene off » as lines, and to the
.scenetwin its name, rectangles, parts and words when it opens, then each frame as « frame n ». - how: the media row’s title goes, as it changes, to the
.mediatwin as « media … », and each step of a long one’s scroll to the.titletwin as « row … ». - how: it darkens as the screen does, 5 s after its last wake, a minute while staying lit, writing
« dark » to the
.darktwin.
import math
import os
import time
import flash
BUTTON_TOP = 224
MEDIA_TOP = 208
HOUR_BOTTOM = 16
SLIDER_BOTTOM = 48
LIGHT_MIN = 2
COLUMNS = 30
RULE = "-" * COLUMNS
_clock = ""
_light = 25
_fresh = False
_lit = not os.getenv("TWATCH_FAKE_DARK")
_touchable = False
_torch = False
_changed = time.ticks_ms()
_charging = None
_wifi = None
_phone = None
def _wrap(text, columns):
rows, row = [], ""
for word in text.split(" "):
while len(word) > columns:
if row:
rows.append(row)
row = ""
rows.append(word[:columns])
word = word[columns:]
if row and len(row) + 1 + len(word) > columns:
rows.append(row)
row = word
else:
row = row + " " + word if row else word
rows.append(row)
return rows
ICON_CODES = (1, 3, 5, 7, 14, 16)
ICON_COLUMNS = 4
def _laid(line, columns):
for code in ICON_CODES:
pair = chr(code) + chr(code + 1)
if pair in line:
rows = _wrap(line.replace(pair, "", 1), columns - ICON_COLUMNS)
return [(code, half, row) for half, row in enumerate(rows + [""] * (2 - len(rows)))]
return [(None, 0, row) for row in _wrap(line, columns)]
def _tint(priority):
return "red" if priority >= 4 else "yellow" if priority == 3 else "cyan"
WIFI_TINTS = {"off": "grey", "joining": "yellow", "joined": "green", "not found": "red"}
PHONE_TINTS = {"waiting": "yellow", "linked": "green"}
DARK_AFTER_MS = 5_000
STAY_LIT_MS = 60_000
STAY_FOR_MS = 600_000
_lit_until = None
def staying():
return _lit_until is not None and time.ticks_diff(_lit_until, time.ticks_ms()) > 0
def stay_lit():
global _lit_until
_lit_until = None if staying() else time.ticks_add(time.ticks_ms(), STAY_FOR_MS)
return _lit_until is not None
def _due():
return time.ticks_diff(time.ticks_ms(), _changed) > (STAY_LIT_MS if staying() else DARK_AFTER_MS)
LIGHT_LOG_BYTES = 4_000
_light_logged = None
def _log_light(what, drew=None):
global _light_logged
now = time.ticks_ms()
since = "" if _light_logged is None else " %d" % time.ticks_diff(now, _light_logged)
_light_logged = now
try:
if os.stat(flash.LIGHT_LOG)[6] > LIGHT_LOG_BYTES:
os.rename(flash.LIGHT_LOG, flash.LIGHT_LOG + ".old")
except OSError:
pass
with open(flash.LIGHT_LOG, "a") as log:
log.write("%d %s%s%s\n" % (now, what, since, "" if drew is None else " : " + drew))
ALIVE_EVERY_MS = 300_000
_alive = None
def alive():
global _alive
if _alive is None or time.ticks_diff(time.ticks_ms(), _alive) >= ALIVE_EVERY_MS:
_alive = time.ticks_ms()
_log_light("alive")
return True
return False
def heard(writes):
_log_light("heard " + writes)
def paced(every_ms, skips):
_log_light("pace every %d skips %d" % (every_ms, skips))
def asked(outcome):
_log_light("pace ask " + outcome)
CHARGE_LOG_BYTES = 4_000
def charge(percent, millivolts):
try:
if os.stat(flash.CHARGE_LOG)[6] > CHARGE_LOG_BYTES:
os.rename(flash.CHARGE_LOG, flash.CHARGE_LOG + ".old")
except OSError:
pass
with open(flash.CHARGE_LOG, "a") as log:
log.write("%d %d %d\n" % (time.ticks_ms(), percent, millivolts))
def _logged(line):
return [("" if icon is None else chr(icon) + chr(icon + 1) if half == 0 else " ") + row
for icon, half, row in _laid(line, COLUMNS)]
_said = []
def say(line, light=True, recall=0):
global _fresh
rows = _logged(line)
_said.extend(rows)
del _said[:-50]
with open(os.getenv("TWATCH_FAKE_SCREEN"), "a") as log:
for row in rows:
log.write(row + "\n")
for row in rows if not _still() else ():
_twin(".scroll", "scroll")
if recall:
with open(os.getenv("TWATCH_FAKE_SCREEN") + ".recalls", "a") as log:
log.write(RULE + "\n")
for row in rows:
log.write(row + "\n")
with open(os.getenv("TWATCH_FAKE_SCREEN") + ".tints", "a") as log:
log.write(_tint(recall) + "\n")
_twin(".fresh", "fresh " + rows[0])
_fresh = True
if light and not _torch:
pad(None)
wake(_touchable and _lit)
def seen():
global _fresh, _changed
if _lit:
_changed = time.ticks_ms()
if _fresh:
_fresh = False
_twin(".fresh", "seen")
def light_at(x):
global _light
_light = max(LIGHT_MIN, (x + 1) * 100 // 240)
_twin(".light", "light %d%%" % _light)
def _twin(suffix, line):
with open(os.getenv("TWATCH_FAKE_SCREEN") + suffix, "a") as log:
log.write(line + "\n")
_twin(".light", "light %d%%" % _light)
def button(label, blue=0, charge=None, wifi="off", phone="waiting"):
global _charging, _wifi, _phone
if bool(blue) != _charging:
_charging = bool(blue)
_bar("charging" if blue else "on battery")
if wifi != _wifi:
_wifi = wifi
_bar("W " + WIFI_TINTS[wifi])
if phone != _phone:
_phone = phone
_bar("B " + PHONE_TINTS[phone])
def _bar(line):
with open(os.getenv("TWATCH_FAKE_SCREEN") + ".bar", "a") as log:
log.write(line + "\n")
def clock(text):
global _clock
if text != _clock:
_clock = text
say(text, light=False)
_media = ""
MEDIA_COLUMNS = COLUMNS - 4
SCROLL_MS = 300
SCROLL_GAP = " "
_media_step = 0
_media_moved = 0
def _media_window(title, step):
if len(title) <= MEDIA_COLUMNS:
return title
loop = title + SCROLL_GAP
at = step % len(loop)
return (loop + loop)[at:at + MEDIA_COLUMNS]
def _media_due():
global _media_step, _media_moved
if len(_media) <= MEDIA_COLUMNS or time.ticks_diff(time.ticks_ms(), _media_moved) < SCROLL_MS:
return False
_media_moved = time.ticks_ms()
_media_step += 1
return True
def media(title):
global _media, _media_step
if title != _media:
_media, _media_step = title, 0
_twin(".media", "media " + title)
def playing():
return bool(_media)
_pad = None
PAD_COLUMNS = {"media": 3, "actions": 3, "more": 2}
PAGES = {"actions": "1/2", "more": "2/2"}
def pad(name):
global _pad
if name != _pad and not (_torch or _scene):
_pad = name
_twin(".media", "pad " + (name or "off"))
def pad_on():
return _pad
def pad_column(x):
return x * PAD_COLUMNS[_pad] // 240
def pad_label(name, column, label):
_twin(".label", "%s %d %s" % (name, column, label))
def redo(line):
say(line, light=False)
def browse(rows):
wake(_touchable and _lit, "browse")
def wake(touchable=False, why="line"):
global _lit, _touchable, _changed
if not _lit:
_log_light("wake " + why, _drew())
_lit, _touchable, _changed = True, touchable, time.ticks_ms()
def _drew():
view = "torch" if _torch else "picture" if _picture else "scene" if _scene else _pad or "lines"
return "%s %d %s" % (view, len(_said), _said[-1] if _said else "")
def lit():
return _lit
def touchable():
return _lit and _touchable
def dark(why="idle"):
global _lit
_log_light("dark " + why)
_lit = False
_twin(".dark", "dark")
def torch(on):
global _torch
if on != _torch:
if on:
pad(None)
_torch = on
wake(why="torch")
say("torch on" if on else "torch off")
def torch_on():
return _torch
_picture = False
def picture(on):
global _picture
if on != _picture:
if on:
pad(None)
_picture = on
wake(why="picture")
say("picture on" if on else "picture off", light=False)
def picture_rows(pieces):
picture(True)
with open(os.getenv("TWATCH_FAKE_SCREEN") + ".picture", "a") as log:
for at in range(0, len(pieces), 242):
log.write("%d,%d,%02x%02x\n" % (pieces[at], pieces[at + 1], pieces[at + 2], pieces[at + 3]))
wake(why="picture")
def picture_on():
return _picture
NAME = "christmas"
BACKDROP = (0, 0, 48)
SNOW = (255, 255, 255)
STAR, STAR_DIM = (255, 220, 0), (160, 120, 0)
FIR = (0, 110, 40)
TRUNK = (90, 50, 20)
GARLAND = ((220, 0, 0), (255, 200, 0), (0, 90, 255))
TIERS = ((50, 50, 40), (80, 60, 60), (115, 70, 85))
LIGHTS = ((116, 72), (110, 92), (128, 100), (90, 126), (146, 122), (100, 146), (132, 140), (70, 176),
(110, 166), (156, 172))
FLAKES = ((8, 0), (20, 60), (30, 120), (206, 30), (218, 90), (228, 150))
SKY = ((52, 30), (84, 46), (160, 34), (188, 58), (60, 84), (176, 100), (48, 140), (190, 150))
SKY_BRIGHT, SKY_DIM = (255, 255, 200), (70, 70, 110)
GIFTS = ((80, 189, 24, 16, (220, 0, 0)), (136, 193, 20, 12, (0, 90, 255)))
RIBBON = (255, 200, 0)
SLED, DEER = (200, 0, 0), (130, 80, 30)
PARTS = ("fir", "star", "garland", "snow", "pile", "gifts", "sky", "sleigh")
WORDS = (("Joyeux Noël", 76, 216, (200, 0, 0), SNOW),)
BAND = 4
LIGHT = 6
FLAKE = 3
GROUND = 205
PILE = 12
SLEIGH_Y = 4
SLEIGH_WIDE = 46
FRAME_MS = 250
def gift_rects():
rects = []
for x, y, w, h, color in GIFTS:
rects += [(x, y, w, h, color), (x + w // 2 - 2, y, 4, h, RIBBON), (x, y + h // 2 - 2, w, 4, RIBBON)]
return rects
def scene_rects():
rects = []
for top, height, half in TIERS:
for y in range(top, top + height, BAND):
wide = half * (y - top + BAND) // height
rects.append((120 - wide, y, 2 * wide, BAND, FIR))
return rects + [(110, 185, 20, 20, TRUNK), (0, GROUND, 240, 240 - GROUND, SNOW)] + gift_rects()
def pile_at(frame):
return min(PILE, frame // 8)
def pile_rects(frame):
high = pile_at(frame)
return [(0, GROUND - high, 42, high, SNOW), (196, GROUND - high, 44, high, SNOW)]
def sky_rects(frame):
return [(x, y, 2, 2, SKY_DIM if (frame + 3 * at) % 7 == 0 else SKY_BRIGHT) for at, (x, y) in enumerate(SKY)]
def sleigh_rects(frame, background=None):
x = frame * 6 % 360 - 60
if not 0 <= x <= 240 - SLEIGH_WIDE:
return []
y = SLEIGH_Y
rects = [(x + 30, y + 4, 14, 5, DEER), (x + 42, y, 4, 5, DEER), (x + 31, y + 9, 2, 3, DEER),
(x + 41, y + 9, 2, 3, DEER), (x + 20, y + 5, 10, 1, RIBBON), (x, y + 6, 20, 6, SLED),
(x, y + 12, 22, 2, RIBBON), (x + 6, y, 8, 6, SLED)]
return rects if background is None else [rect[:4] + (background,) for rect in rects]
def star_rects(color):
return [(117, 28, 6, 22, color), (109, 36, 22, 6, color), (113, 32, 14, 14, color)]
def light_rects(frame):
return [(x, y, LIGHT, LIGHT, GARLAND[(at + frame) % len(GARLAND)]) for at, (x, y) in enumerate(LIGHTS)]
def flake_rects(frame, color):
return [(x, 20 + (start + 4 * frame) % (GROUND - PILE - 25), FLAKE, FLAKE, color) for x, start in FLAKES]
def star_at(frame):
return STAR_DIM if frame % 8 == 7 else STAR
def frame_rects(before, frame):
rects = [] if before is None else flake_rects(before, BACKDROP) + sleigh_rects(before, BACKDROP)
if before is None or star_at(frame) != star_at(before):
rects += star_rects(star_at(frame))
if before is None or pile_at(frame) != pile_at(before):
rects += pile_rects(frame)
return rects + light_rects(frame) + sky_rects(frame) + flake_rects(frame, SNOW) + sleigh_rects(frame)
def outside(rects):
return sum(not (0 <= x and 0 <= y and w >= 0 and h >= 0 and x + w <= 240 and y + h <= 240)
for x, y, w, h, _ in rects)
def _still():
return _picture or _scene or _pad
_scene = False
_played = 0
_frame = None
def scene(on):
global _scene, _played, _frame
if on != _scene:
if on:
pad(None)
drawn = scene_rects() + frame_rects(None, 0)
_twin(".scene", "%s %d rects, %d outside: %s / %s" % (
NAME, len(drawn), outside(drawn), " ".join(PARTS), " / ".join(word[0] for word in WORDS)))
_scene = on
_played, _frame = time.ticks_ms(), None
wake(why="scene")
say("scene on" if on else "scene off", light=False)
def scene_on():
return _scene
def _play():
global _frame
frame = time.ticks_diff(time.ticks_ms(), _played) // FRAME_MS
if frame != _frame:
drawn = frame_rects(_frame, frame)
_frame = frame
_twin(".scene", "frame %d, %d outside" % (frame, outside(drawn)))
def idle():
if _scene:
_play()
if _lit and _media_due():
_twin(".title", "row " + _media_window(_media, _media_step))
if _lit and not (_torch or _picture or _scene) and _due():
pad(None)
dark()
- how
- on the watch, the wheel is the pmu’s power key; its short press is the pmu’s interrupt bit 3 of
0x41 to allow and of 0x49 to read, cleared by writing it back12.
- how: its hold is bit 2 of the same registers, the long press, raised past the pmu’s own irq time.
- what: untried on the watch: the bit and the time are as XPowersLib names them for the axp2101.
- how: the pmu pulls gpio 21 low when it raises one of its interrupts; that fall ends the dark nap,
and a press logs when the line fell, « wheel down ».
- why: « wheel wakes after like 4s »: read only between naps, a press waited up to a second.
- how: so only the presses may pull it: at import, every other interrupt of 0x40 to 0x42 is
turned off and all pending ones, 0x48 to 0x4a, cleared.
- why: with nearly all of them on and some left pending, the line stayed low and never fell.
import time from machine import I2C, Pin import flash PMU = 0x34 INTEN1 = 0x40 INTEN2 = 0x41 INTEN3 = 0x42 INTSTS1 = 0x48 INTSTS2 = 0x49 INTSTS3 = 0x4A SHORT_PRESS = 0b1000 LONG_PRESS = 0b100 _i2c = I2C(0, sda=Pin(10), scl=Pin(11), freq=400_000) for register, allowed in ((INTEN1, 0), (INTEN2, SHORT_PRESS | LONG_PRESS), (INTEN3, 0)): _i2c.writeto_mem(PMU, register, bytes((allowed,))) for register in (INTSTS1, INTSTS2, INTSTS3): _i2c.writeto_mem(PMU, register, b"\xff") _rang = False _fell = None def _ring(pin): global _rang, _fell _rang = True _fell = time.ticks_ms() _int = Pin(21, Pin.IN, Pin.PULL_UP, hold=True) _int.irq(_ring, Pin.IRQ_FALLING) def busy(): return _rang def _latched(bit): global _rang _rang = False if not _i2c.readfrom_mem(PMU, INTSTS2, 1)[0] & bit: return False _i2c.writeto_mem(PMU, INTSTS2, bytes((bit,))) return True def pressed(): if not _latched(SHORT_PRESS): return False with open(flash.LIGHT_LOG, "a") as log: log.write("%d wheel down %s\n" % (time.ticks_ms(), _fell)) return True def held(): return _latched(LONG_PRESS) - how: its hold is bit 2 of the same registers, the long press, raised past the pmu’s own irq time.
- how
- under test, the wheel is a fake reading its presses, a line each, from the file the story
names, « press » or « long », in order; never pressed when it names none.
import os _played = 0 def _next(kind): global _played try: with open(os.getenv("TWATCH_FAKE_WHEEL")) as log: presses = log.read().split() except (OSError, TypeError): return False if _played == len(presses) or presses[_played] != kind: return False _played += 1 return True def busy(): try: with open(os.getenv("TWATCH_FAKE_WHEEL")) as log: return len(log.read().split()) > _played except (OSError, TypeError): return False def pressed(): return _next("press") def held(): return _next("long") - how
- on the watch, the battery’s percent and voltage are the pmu’s own readings, as XPowersLib takes
them13; none when no cell is there.
- how: the pmu’s voltage measure is switched on once, at import.
- how: the battery is charging when the top bits of the pmu’s second status register say so, as XPowersLib reads it14.
- how: the watch is plugged when bit 5 of the pmu’s first status register, its « vbus good », is set. Under test, it is plugged while it charges.
from machine import I2C, Pin
PMU = 0x34
STATUS1 = 0x00
STATUS2 = 0x01
CHARGING = 0b01
ADC_CHANNEL_CTRL = 0x30
BATTERY_MV = 0x34
BATTERY_PERCENT = 0xA4
CELL_THERE = 0b1000
VBUS_GOOD = 0b100000
_i2c = I2C(0, sda=Pin(10), scl=Pin(11), freq=400_000)
_i2c.writeto_mem(PMU, ADC_CHANNEL_CTRL, bytes((_i2c.readfrom_mem(PMU, ADC_CHANNEL_CTRL, 1)[0] | 1,)))
def level():
if not _i2c.readfrom_mem(PMU, STATUS1, 1)[0] & CELL_THERE:
return None
high, low = _i2c.readfrom_mem(PMU, BATTERY_MV, 2)
return _i2c.readfrom_mem(PMU, BATTERY_PERCENT, 1)[0], (high & 0x1F) << 8 | low
def charging():
return _i2c.readfrom_mem(PMU, STATUS2, 1)[0] >> 5 == CHARGING
def plugged():
return bool(_i2c.readfrom_mem(PMU, STATUS1, 1)[0] & VBUS_GOOD)
- how
- under test, the battery is a fake at 3.9 V, at the percent the harness names, 80 % by default, charging while the file the harness names exists.
import os
def level():
return int(os.getenv("TWATCH_FAKE_BATTERY") or 80), 3900
def charging():
try:
os.stat(os.getenv("TWATCH_FAKE_CHARGER"))
return True
except OSError:
return False
def plugged():
return charging()
- how
- on the watch, the chip may light-sleep through
machine.freq, at 80 MHz, its second argument saying whether; a firmware without that argument can only turn the sleep on.
import machine
FREQ = 80_000_000
def allow(on):
try:
machine.freq(FREQ, on)
except TypeError:
if on:
machine.freq(FREQ)
- how
- under test, the chip never sleeps; whether it may is written beside the screen’s file.
import os
def allow(on):
with open(os.getenv("TWATCH_FAKE_SCREEN") + ".sleep", "a") as log:
log.write("light sleep %s\n" % ("on" if on else "off"))
- how
- on oxenhead, the companion’s share is android’s own estimate for the poc’s uid, in mAh, since
the stats were last reset:
oxenhead-share.sh resetstarts a night,oxenhead-share.shreads it.- how: the whole dump lands under
battery/; only the poc’s estimate, its ble scans and its wake locks are printed.
- how: the whole dump lands under
#!/usr/bin/env bash
set -eu
here=$(dirname "$(readlink -f "$0")")
adb=(adb -s oxenhead:5555)
if [ "${1:-}" = reset ]; then
exec "${adb[@]}" shell dumpsys batterystats --reset
fi
uid=$("${adb[@]}" shell dumpsys package eu.konubinix.konixpoc | sed -n 's/.*userId=\([0-9]*\).*/\1/p')
label=u0a$((${uid%%$'\n'*} - 10000))
log=$here/battery/oxenhead-$(date +%Y%m%dT%H%M%S).log
mkdir -p "$here/battery"
"${adb[@]}" shell dumpsys batterystats eu.konubinix.konixpoc > "$log"
grep -E "Time on battery:|Uid $label:|Bluetooth Scan \(total|TOTAL wake" "$log"
- how
- on the watch, a ring is half a second of a 2.7 kHz tone at full scale through the MAX98357A,
bck on gpio 48, ws on 15 and data on 46.
- why: « find my watch wprks but speaker sound is low »: 880 Hz at a quarter of full scale was; so small a speaker is loudest in the kHz, and the amplifier’s gain is set by the board.
- why: those are the pins LilyGo’s library gives the t-watch s315.
- how: the amplifier is powered by the pmu’s dldo1, switched on as LilyGo’s library does16.
- how: only for the ring: dldo1 goes on before the tone and off after it.
- why: an amplifier left powered draws its idle current all day for a half-second ring now and then.
- how: the tone is waited out before the amplifier is freed.
import math import struct import time from machine import I2C, I2S, Pin PMU = 0x34 LDO_ONOFF = 0x90 DLDO1_ON = 0b10000000 RATE = 16000 _pmu = I2C(0, sda=Pin(10), scl=Pin(11), freq=400_000) def _powered(on): value = _pmu.readfrom_mem(PMU, LDO_ONOFF, 1)[0] _pmu.writeto_mem(PMU, LDO_ONOFF, bytes((value | DLDO1_ON if on else value & ~DLDO1_ON,))) _powered(False) def ring(ms=500, hz=2700): count = RATE * ms // 1000 tone = bytearray(2 * count) for n in range(count): struct.pack_into("<h", tone, 2 * n, int(32000 * math.sin(2 * math.pi * hz * n / RATE))) _powered(True) amp = I2S(1, sck=Pin(48), ws=Pin(15), sd=Pin(46), mode=I2S.TX, bits=16, format=I2S.MONO, rate=RATE, ibuf=len(tone)) amp.write(tone) time.sleep_ms(ms) amp.deinit() _powered(False) - how
- under test, the speaker is a fake that appends « ring » to the file the harness names.
import os def ring(): with open(os.getenv("TWATCH_FAKE_SPEAKER"), "a") as log: log.write("ring\n") - how
- on the watch, a buzz is the drv2605’s 750 ms alert, effect 1517, on its erm motor, as
LilyGo’s library plays it at start16.
- how: the drv2605 answers on the pmu’s i2c at 0x5A17, powered by the pmu’s bldo2 at 3.3 V.
- how: only for the buzz: bldo2 goes on, the drv2605 is set up afresh, the alert is waited out, and bldo2 goes off.
- why: as with the amplifier, a rail left on feeds the drv2605 all day for a rare buzz.
- how: a tick is the same, with the strong buzz, effect 1417, lasting 150 ms.
- how: it is not waited out: the drv2605 plays alone, and bldo2 goes off on the main loop’s next
nap once the 150 ms are past.
- why: « shpuld vibrate when gettçnf the tap/press, not when lighton the screen »: the tick comes first, the screen waking while it plays.
- why: « tap and wheel vibrarion must be more noticeable »: the strong click, effect 1, was missed.
- how: it is not waited out: the drv2605 plays alone, and bldo2 goes off on the main loop’s next
nap once the 150 ms are past.
- how: the drv2605 answers on the pmu’s i2c at 0x5A17, powered by the pmu’s bldo2 at 3.3 V.
- how
- a double buzz is the alert twice, 200 ms apart, in one sequence: a wait step is 0x80 plus tens of ms17.
import time
from machine import I2C, Pin
PMU = 0x34
LDO_ONOFF = 0x90
BLDO2_VOLT = 0x97
BLDO2_ON = 0b100000
DRV = 0x5A
MODE, LIBRARY, SEQUENCE, GO, FEEDBACK = 0x01, 0x03, 0x04, 0x0C, 0x1A
ALERT, ALERT_MS = 15, 750
CLICK, CLICK_MS = 14, 150
WAIT, GAP_MS = 0x80, 200
_i2c = I2C(0, sda=Pin(10), scl=Pin(11), freq=400_000)
def _update(device, register, keep, bits):
value = _i2c.readfrom_mem(device, register, 1)[0] & keep | bits
_i2c.writeto_mem(device, register, bytes((value,)))
_update(PMU, BLDO2_VOLT, 0xE0, 28)
_update(PMU, LDO_ONOFF, ~BLDO2_ON & 0xFF, 0)
_resting = None
def _start(effects):
_update(PMU, LDO_ONOFF, 0xFF, BLDO2_ON)
# internal trigger, out of standby, erm library a, erm feedback
_update(DRV, MODE, 0x00, 0x00)
_update(DRV, LIBRARY, 0xF8, 1)
_update(DRV, FEEDBACK, 0x7F, 0)
_i2c.writeto_mem(DRV, SEQUENCE, bytes(effects + (0,)))
_i2c.writeto_mem(DRV, GO, b"\x01")
def _off():
global _resting
_resting = None
_update(PMU, LDO_ONOFF, ~BLDO2_ON & 0xFF, 0)
def _play(effects, ms):
_start(effects)
time.sleep_ms(ms)
_off()
def rest():
if _resting is not None and time.ticks_diff(time.ticks_ms(), _resting) >= 0:
_off()
def buzz():
_play((ALERT,), ALERT_MS)
def double():
_play((ALERT, WAIT | GAP_MS // 10, ALERT), 2 * ALERT_MS + GAP_MS)
def tick():
global _resting
_start((CLICK,))
_resting = time.ticks_add(time.ticks_ms(), CLICK_MS)
- how
- under test, the motor is a fake that appends « buzz » or « tick » to the file the harness names.
import os def buzz(): with open(os.getenv("TWATCH_FAKE_MOTOR"), "a") as log: log.write("buzz\n") def double(): with open(os.getenv("TWATCH_FAKE_MOTOR"), "a") as log: log.write("double\n") def tick(): with open(os.getenv("TWATCH_FAKE_MOTOR"), "a") as log: log.write("tick\n") def rest(): pass - how
- on the watch, the microphone is the pdm one, clock on gpio 44 and data on gpio 47, read
through the patched
machine.I2Sinto a 16 kHz, 16-bit, mono wav.- why: those are the pins the circuitpython board names; the vendor powers no rail for it, so it hangs on the always-on 3.3 V.
- why: the patched pdm reception gives half the rate it is asked for, 8004 samples a second for 16000 asked, so it is asked twice the rate wanted.
- why: the mic is quiet and sits on an offset, speech peaking near 3500 over about -1500, so the offset is taken away and the rest made 8 times louder.
- how: it records until
stopsays so, looked at each tenth of a second, or until the cap, 3 s by default;tickhears each whole second recorded. - how: the wav is made in one buffer, its header written last, the sound made louder in place by viper code, so a long recording is never copied.
- how: that buffer, room for 2 min, is taken once at import and each recording reuses it; the
next recording overwrites the last.
- why: taken afresh each time, a few recordings cut the heap up until the next could not fit.
- why: the watch holds the whole recording before sending it; two copies of a 2 min one would not fit its 8 MB.
import struct import micropython from machine import I2S, Pin RATE = 16000 GAIN = 8 HEADER = 44 SLICE = RATE * 2 // 10 SUM_CHUNK = 32768 MAX_SECONDS = 120 _wav = bytearray(HEADER + RATE * 2 * MAX_SECONDS) def wav_header(size): return (b"RIFF" + struct.pack("<I", 36 + size) + b"WAVEfmt " + struct.pack("<IHHIIHH", 16, 1, 1, RATE, RATE * 2, 2, 16) + b"data" + struct.pack("<I", size)) @micropython.viper def _sum(samples: ptr16, count: int) -> int: total = 0 for i in range(count): value = int(samples[i]) if value > 32767: value -= 65536 total += value return total @micropython.viper def _louder(samples: ptr16, count: int, offset: int, gain: int): for i in range(count): value = int(samples[i]) if value > 32767: value -= 65536 value = (value - offset) * gain if value > 32767: value = 32767 if value < -32768: value = -32768 samples[i] = value & 0xFFFF def louder(pcm): count = len(pcm) // 2 total = 0 for start in range(0, count, SUM_CHUNK): total += _sum(pcm[2 * start:], min(SUM_CHUNK, count - start)) _louder(pcm, count, total // count, GAIN) def record(stop=None, max_seconds=3, tick=None): mic = I2S(0, sck=Pin(44), sd=Pin(47), mode=I2S.PDM_RX, bits=16, format=I2S.MONO, rate=2 * RATE, ibuf=20000) end = HEADER + RATE * 2 * min(max_seconds, MAX_SECONDS) view = memoryview(_wav) got = 0 second = 0 while HEADER + got < end: got += mic.readinto(view[HEADER + got:min(HEADER + got + SLICE, end)]) if stop is not None and stop(): break if tick is not None and got // (RATE * 2) != second: second = got // (RATE * 2) tick(second) mic.deinit() got -= got % 2 louder(view[HEADER:HEADER + got]) view[:HEADER] = wav_header(got) return view[:HEADER + got] - how
- under test, the microphone is a fake that hears the wav file the harness names, and, given
stop, holds it untilstopsays so.import os import time def record(stop=None, max_seconds=3, tick=None): if stop is not None: for _ in range(max_seconds * 100): if stop(): break time.sleep_ms(10) with open(os.getenv("TWATCH_FAKE_MIC"), "rb") as wav: return wav.read() - how
- the mic is tried alone on the watch, with no desktop: at boot, each tap on the screen records
3 s, shows how loud it was, and keeps the wav on the watch’s flash.
- why: a mute or deaf mic shows at once in its loudness, before ble adds its own doubts.
- how: the touch panel is an ft6336 at 0x38 on its own i2c, sda 39 and scl 40, powered with the screen by aldo3; its register 2 counts the fingers on it, and registers 3 to 6 hold the first finger’s x and y on 12 bits.
- how: a tap is told on release, at where the finger last was, with how far it went down since it touched; the panel’s coordinates are the screen’s as drawn.
- how: the panel pulls gpio 16 low when a finger lands; only from then until the release is it read,
so the i2c rests while nothing touches.
- how: a fall with no finger seen down is no tap: only a finger the panel reported down makes one.
- why: « watch is flashong now. lit, dark, lit, dark etc… »: with the chip light-sleeping, gpio 16 falls by itself, and each fall made a tap, a tick and a lit screen about 3 times a second.
- what: a tap lifted before the next look is lost, save on a dark screen, where it still wakes it.
- why: « a lot of taps just don’t wake the watch »; gpio 16, held up through sleep, no longer falls by itself.
- how: a fall with no finger seen down is no tap: only a finger the panel reported down makes one.
import time
from machine import I2C, Pin
import flash
PANEL = 0x38
FINGERS = 0x02
FIRST_XH = 0x03
_i2c = I2C(1, sda=Pin(39), scl=Pin(40), freq=400_000)
_first = None
_last = None
_landed = False
_down = False
def _land(pin):
global _landed
_landed = True
_int = Pin(16, Pin.IN, Pin.PULL_UP, hold=True)
_int.irq(_land, Pin.IRQ_FALLING)
def touched():
return _i2c.readfrom_mem(PANEL, FINGERS, 1)[0] & 0x0F > 0
def wait_tap():
while not touched():
time.sleep_ms(20)
while touched():
time.sleep_ms(20)
def _where():
xh, xl, yh, yl = _i2c.readfrom_mem(PANEL, FIRST_XH, 4)
return (xh & 0x0F) << 8 | xl, (yh & 0x0F) << 8 | yl
def busy():
return _landed or _last is not None
def _log(what):
with open(flash.LIGHT_LOG, "a") as log:
log.write("%d %s\n" % (time.ticks_ms(), what))
def down():
global _down
landed, _down = _down, False
return landed
def tap(dark=False):
global _first, _last, _landed, _down
if not _landed and _last is None:
return None
_landed = False
if touched():
if _first is None:
_log("touch down")
_down = True
_last = _where()
_first = _first or _last
return None
if _last is None:
_log("touch lost")
_down = True
return (0, 0, 0) if dark else None
(x, y), (_, y0) = _last, _first
_first = _last = None
_log("touch up")
return x, y, y - y0
- how
- under test, the touch is a fake playing, one per look, the taps the story adds as x,y lines to the file the harness names; none moves; a « lost » line is a tap lifted before the look; a « down » line is a finger landed and held, and the tap after it is its lift; any other tap lands and lifts within one look.
import os
_played = 0
_down = False
def _taps():
try:
with open(os.getenv("TWATCH_FAKE_TAPS")) as log:
return log.read().split()
except OSError:
return []
def _held(taps):
return _played > 0 and taps[_played - 1] == "down"
def busy():
taps = _taps()
return len(taps) > _played or _held(taps)
def down():
global _down
landed, _down = _down, False
return landed
def tap(dark=False):
global _played, _down
taps = _taps()
if _played == len(taps):
return None
_down = _down or not _held(taps)
_played += 1
if taps[_played - 1] == "down":
return None
if taps[_played - 1] == "lost":
return (0, 0, 0) if dark else None
x, y = taps[_played - 1].split(",")
return int(x), int(y), 0
import array
import os
import mic
import screen
import touch
FOLDER = "recordings"
def loudness(wav):
samples = array.array("h", wav[44:])
return max(max(samples), -min(samples))
def save(wav):
if FOLDER not in os.listdir():
os.mkdir(FOLDER)
path = "%s/%d.wav" % (FOLDER, len(os.listdir(FOLDER)))
with open(path, "wb") as out:
out.write(wav)
return path
screen.say("mic test: tap to record")
try:
while True:
touch.wait_tap()
screen.say("speak now, 3 s")
wav = mic.record()
screen.say("peak %d of 32767" % loudness(wav))
screen.say("kept as " + save(wav))
screen.say("tap to record again")
except Exception as error:
screen.say("error: " + str(error))
raise
- how
- the mic test becomes the watch’s
main.py, so it starts at each boot, after its modules.
#!/usr/bin/env nix-shell
#!nix-shell -i bash device-shell.nix
set -eu
here=$(dirname "$(readlink -f "$0")")
port=${1:-/dev/ttyACM0}
"$here/deploy-watch.sh" "$port"
mpremote connect "$port" resume cp "$here/device/mic_test.py" :main.py + run "$here/watch/updated.py" + reset
- how
- the recordings come here, each fetch in a folder named by its hour, then leave the watch.
- how
- the fetch stops the test; RST starts it again.
#!/usr/bin/env nix-shell
#!nix-shell -i bash device-shell.nix
set -eu
here=$(dirname "$(readlink -f "$0")")
port=${1:-/dev/ttyACM0}
inbox=$here/recordings-from-watch/$(date +%Y%m%dT%H%M%S)
mkdir -p "$inbox"
mpremote connect "$port" cp -r :recordings "$inbox/" + rm -r :recordings
find "$inbox" -name '*.wav'
- how
- the watch serves one service: the phone writes
READY, its name in at most 20 bytes of utf-8, or a single byte below a space when it gives none, and the voice comes back as notifications onVOICE; a recall is written onRECALL, its priority on one byte, then when it was sent, year on two bytes, month, day, hour and minute on one each, then its text;SAVEDis written once the phone has saved a recording; the REPL’s input is written onREPL_IN, its output notified onREPL_OUT; the phone’s local time is written onTIMEas year on two bytes, then month, day, weekday from monday as 0, hour, minute and second on one each; a media command goes out as a notification onMEDIA, one byte: 1 back, 2 play or pause, 3 skip; each line of the errors kept goes out as a notification onERRORS, cut to a packet; the charge goes out the same way onCHARGE.import bluetooth import micropython import struct import time from micropython import const import repl _IRQ_CENTRAL_CONNECT = const(1) _IRQ_CENTRAL_DISCONNECT = const(2) _IRQ_GATTS_WRITE = const(3) _IRQ_MTU_EXCHANGED = const(21) _IRQ_CONNECTION_UPDATE = const(27) NAME = "twatch" SERVICE = bluetooth.UUID("8d5a7819-0001-4b5a-a8f2-6f7a6c1b0e01") VOICE = bluetooth.UUID("8d5a7819-0002-4b5a-a8f2-6f7a6c1b0e01") READY = bluetooth.UUID("8d5a7819-0003-4b5a-a8f2-6f7a6c1b0e01") RECALL = bluetooth.UUID("8d5a7819-0004-4b5a-a8f2-6f7a6c1b0e01") SAVED = bluetooth.UUID("8d5a7819-0005-4b5a-a8f2-6f7a6c1b0e01") REPL_IN = bluetooth.UUID("8d5a7819-0006-4b5a-a8f2-6f7a6c1b0e01") REPL_OUT = bluetooth.UUID("8d5a7819-0007-4b5a-a8f2-6f7a6c1b0e01") TIME = bluetooth.UUID("8d5a7819-0008-4b5a-a8f2-6f7a6c1b0e01") MEDIA = bluetooth.UUID("8d5a7819-0009-4b5a-a8f2-6f7a6c1b0e01") PICTURE = bluetooth.UUID("8d5a7819-000a-4b5a-a8f2-6f7a6c1b0e01") ERRORS = bluetooth.UUID("8d5a7819-000c-4b5a-a8f2-6f7a6c1b0e01") CHARGE = bluetooth.UUID("8d5a7819-000d-4b5a-a8f2-6f7a6c1b0e01") PIECE = 242 PROGRESS_STEP = 5 STALL_MS = 10_000 CALL_US = 1_340_000 IDLE_US = 1_000_000 IDLE_TIMEOUT_MS = 6000 SLOW_UNITS = 80 UNIT_US = 1250 class Stalled(Exception): pass def phone_name(written): if written[:1] < b" ": return "" try: return written.decode() except UnicodeError: return "" - how
- the link is one class, whose methods the bullets below carve out.
- how
- the link remembers who is connected, whether the phone is ready, how big a packet may be,
and the recalls not yet shown.
- how: a recall’s characteristic holds a full packet, 244 bytes, where micropython gives 20 by default and would cut its text.
- how: once the phone sets a pace of 100 ms or slower, the watch asks to meet it every second,
skipping none, with 6 s of silence allowed; a faster pace, as the phone asks for a recording, it
leaves alone.
- why: the phone wakes at every meeting, skips or not; a second between them spares it, and a notification still arrives within a second.
- how: each pace the link takes waits, ms and skips, until the loop writes it in light.log.
def __init__(self, ble):
self.ble = ble
ble.active(True)
ble.config(gap_name=NAME, mtu=247)
((self.voice, self.ready, self.recall, self.saved, self.repl_in, self.repl_out, self.clock,
self.media, self.picture, self.errors, self.charge),) = (
ble.gatts_register_services(
((SERVICE, ((VOICE, bluetooth.FLAG_NOTIFY), (READY, bluetooth.FLAG_WRITE),
(RECALL, bluetooth.FLAG_WRITE), (SAVED, bluetooth.FLAG_WRITE),
(REPL_IN, bluetooth.FLAG_WRITE), (REPL_OUT, bluetooth.FLAG_NOTIFY),
(TIME, bluetooth.FLAG_WRITE), (MEDIA, bluetooth.FLAG_NOTIFY),
(PICTURE, bluetooth.FLAG_WRITE | bluetooth.FLAG_WRITE_NO_RESPONSE),
(ERRORS, bluetooth.FLAG_NOTIFY), (CHARGE, bluetooth.FLAG_NOTIFY))),)))
ble.gatts_set_buffer(self.repl_in, 512)
ble.gatts_set_buffer(self.recall, 244)
ble.gatts_set_buffer(self.picture, 2 * PIECE, True)
self.names = {self.ready: "ready", self.recall: "recall", self.saved: "saved", self.repl_in: "repl",
self.clock: "time", self.picture: "picture"}
self.writes = {}
self.pieces = bytearray()
self.conn = None
self.mtu = 23
self.phone_ready = False
self.phone_name = ""
self.recalls = []
self.saves = 0
self.time = None
self.repl = None
self.paces = []
self.asks = []
ble.irq(self._irq)
def _irq(self, event, data):
if event == _IRQ_GATTS_WRITE:
name = self.names.get(data[1], "?")
self.writes[name] = self.writes.get(name, 0) + 1
if event == _IRQ_CENTRAL_CONNECT:
self.conn = data[0]
elif event == _IRQ_CENTRAL_DISCONNECT:
self.conn = None
self.phone_ready = False
self.advertise()
elif event == _IRQ_GATTS_WRITE and data[1] == self.ready:
self.phone_name = phone_name(bytes(self.ble.gatts_read(self.ready)))
self.phone_ready = True
elif event == _IRQ_GATTS_WRITE and data[1] == self.recall:
written = self.ble.gatts_read(self.recall)
priority, *when = struct.unpack_from("<BHBBBB", written)
self.recalls.append((priority, when, bytes(written[7:]).decode()))
elif event == _IRQ_GATTS_WRITE and data[1] == self.saved:
self.saves += 1
elif event == _IRQ_GATTS_WRITE and data[1] == self.clock:
self.time = struct.unpack("<HBBBBBB", self.ble.gatts_read(self.clock))
elif event == _IRQ_GATTS_WRITE and data[1] == self.repl_in:
if self.repl is None:
self.repl = repl.Stream(self)
self.repl.received(self.ble.gatts_read(self.repl_in))
elif event == _IRQ_GATTS_WRITE and data[1] == self.picture:
self.pieces += self.ble.gatts_read(self.picture)
elif event == _IRQ_MTU_EXCHANGED:
self.mtu = data[1]
elif event == _IRQ_CONNECTION_UPDATE:
conn, units, skips, timeout, status = data
if status == 0:
self.paces.append((units * UNIT_US // 1000, skips))
if status == 0 and units >= SLOW_UNITS and units * UNIT_US != IDLE_US:
micropython.schedule(self._sparse, conn)
def _sparse(self, conn):
try:
self.ble.gap_update(conn, IDLE_US, IDLE_US, 0, IDLE_TIMEOUT_MS)
self.asks.append("ok")
except (OSError, AttributeError) as error:
self.asks.append("%s %s" % (type(error).__name__, error))
- how
- the watch advertises its name every 1.34 s, until a phone links, and again as soon as any
link drops, ready or not.
- what: each of the four Android scans the pace story names hears this beat within 30 s, from any start.
- what: it costs 3.7 times the calls of a 5 s beat, only while unlinked.
def advertise(self):
name = NAME.encode()
adv = bytes((2, 0x01, 0x06, len(name) + 1, 0x09)) + name
self.ble.gap_advertise(CALL_US, adv_data=adv)
def drop(self):
self.phone_ready = False
if self.conn is not None:
self.ble.gap_disconnect(self.conn)
- how
- a recording goes out as its length on four bytes, then its bytes, cut to the packet size,
sent from where it lies rather than copied behind its length; each 5 % sent is told to whoever asks.
- how: a packet waits while the controller’s buffers are full; after 10 s without one going out, the link is dropped and the send fails, as it does when the phone leaves mid-way.
def send(self, payload, progress=None):
step = self.mtu - 3
view = memoryview(payload)
told = 0
self._notify(struct.pack("<I", len(payload)) + bytes(view[:step - 4]))
for offset in range(step - 4, len(payload), step):
self._notify(view[offset:offset + step])
percent = offset * 100 // len(payload) // PROGRESS_STEP * PROGRESS_STEP
if progress is not None and percent != told:
told = percent
progress(percent)
def send_lines(self, lines):
for line in lines:
self._notify(line.encode()[:self.mtu - 3], self.errors)
def send_charge(self, line):
self._notify(line.encode()[:self.mtu - 3], self.charge)
def _notify(self, chunk, handle=None):
since = time.ticks_ms()
while self.conn is not None:
try:
self.ble.gatts_notify(self.conn, handle or self.voice, chunk)
return
except OSError:
if time.ticks_diff(time.ticks_ms(), since) > STALL_MS:
self.ble.gap_disconnect(self.conn)
break
time.sleep_ms(2)
raise Stalled()
- how
- the recalls come out in the order they came in, none when none waits; so do the phone’s word that a recording is saved, and the last time it gave.
- how
- a media command goes only while linked; unlinked, or stalled, it is lost and said so.
- how
- what the phone writes on
PICTUREpiles up, and comes out as whole half-rows of 242 bytes.- why: micropython reads a written value only once its irq runs; piling them, none is lost.
def next_recall(self):
return self.recalls.pop(0) if self.recalls else None
def next_pace(self):
return self.paces.pop(0) if self.paces else None
def next_ask(self):
return self.asks.pop(0) if self.asks else None
def next_writes(self):
writes, self.writes = self.writes, {}
return " ".join("%s %d" % pair for pair in sorted(writes.items()))
def next_save(self):
if not self.saves:
return False
self.saves -= 1
return True
def next_time(self):
when, self.time = self.time, None
return when
def next_pieces(self):
whole = len(self.pieces) - len(self.pieces) % PIECE
if not whole:
return None
pieces, self.pieces = self.pieces[:whole], self.pieces[whole:]
return pieces
def command(self, code):
if self.conn is None:
return False
try:
self._notify(bytes((code,)), self.media)
except Stalled:
return False
return True
- how
- the REPL also runs over the phone’s link, as micropython’s ble_uart_repl example does: what the
phone writes on
REPL_INis fed toos.dupterm, and what the REPL prints goes back onREPL_OUT, cut to the packet size.- how: the stream takes the REPL each time the phone writes, so the wifi’s REPL and this one share micropython’s one hook: the last to write is served; the usb REPL is apart.
import io import os import micropython from micropython import const _MP_STREAM_POLL = const(3) _MP_STREAM_POLL_RD = const(1) class Stream(io.IOBase): def __init__(self, link): self.link = link self.inbox = bytearray() self.outbox = bytearray() def received(self, data): self.inbox += data os.dupterm(self) os.dupterm_notify(None) def readinto(self, buf): if not self.inbox: return None count = min(len(buf), len(self.inbox)) buf[:count] = self.inbox[:count] self.inbox = self.inbox[count:] return count def ioctl(self, op, arg): if op == _MP_STREAM_POLL and self.inbox: return _MP_STREAM_POLL_RD return 0 def write(self, buf): idle = not self.outbox self.outbox += buf if idle: micropython.schedule(self._flush, None) return len(buf) def _flush(self, _): step = self.link.mtu - 3 chunk, self.outbox = self.outbox[:step], self.outbox[step:] try: self.link._notify(chunk, self.link.repl_out) except Exception: self.outbox = bytearray() if self.outbox: micropython.schedule(self._flush, None)
the voice, on the phone
- what
- the phone finds the watch, takes one recording, saves it in the inbox, and tells the watch.
- scope: under test it runs on the desktop, on bumble; on the phone, the ble stack will differ.
import asyncio import contextlib import struct import time from bumble.core import UUID, AdvertisingData from bumble.device import Device, Peer from bumble.hci import Address from bumble.transport import open_transport NAME = "twatch" SERVICE = UUID("8d5a7819-0001-4b5a-a8f2-6f7a6c1b0e01") VOICE = UUID("8d5a7819-0002-4b5a-a8f2-6f7a6c1b0e01") READY = UUID("8d5a7819-0003-4b5a-a8f2-6f7a6c1b0e01") RECALL = UUID("8d5a7819-0004-4b5a-a8f2-6f7a6c1b0e01") SAVED = UUID("8d5a7819-0005-4b5a-a8f2-6f7a6c1b0e01") TIME = UUID("8d5a7819-0008-4b5a-a8f2-6f7a6c1b0e01") MEDIA = UUID("8d5a7819-0009-4b5a-a8f2-6f7a6c1b0e01") PICTURE = UUID("8d5a7819-000a-4b5a-a8f2-6f7a6c1b0e01") ERRORS = UUID("8d5a7819-000c-4b5a-a8f2-6f7a6c1b0e01") CHARGE = UUID("8d5a7819-000d-4b5a-a8f2-6f7a6c1b0e01") async def receive_one(transport, inbox): async with linked(transport) as peer: recording = await receive_recording(peer) inbox.mkdir(parents=True, exist_ok=True) path = inbox / time.strftime("%Y%m%dT%H%M%S.wav") path.write_bytes(recording) saved = peer.get_characteristics_by_uuid(SAVED, SERVICE)[0] await peer.write_value(saved, b"\x01", with_response=True) return path - how
- the phone stays linked to the watch while the story needs it, and unlinks on leaving; once
linked, it learns the watch’s service.
@contextlib.asynccontextmanager async def linked(transport): async with await open_transport(transport) as hci: device = Device.with_hci("phone", Address("F0:F1:F2:F3:F4:F5"), hci.source, hci.sink) await device.power_on() connection = await device.connect(await find_watch(device)) gone = asyncio.get_running_loop().create_future() connection.on("disconnection", lambda reason: gone.done() or gone.set_result(reason)) try: peer = Peer(connection) peer.gone = gone await peer.discover_services([SERVICE]) await peer.discover_characteristics() yield peer finally: if not gone.done(): await connection.disconnect() - how
- the watch is the first device advertising its name.
async def find_watch(device): found = asyncio.get_running_loop().create_future() def on_advertisement(advertisement): name = advertisement.data.get(AdvertisingData.Type.COMPLETE_LOCAL_NAME) if name == NAME and not found.done(): found.set_result(advertisement.address) device.on("advertisement", on_advertisement) await device.start_scanning() address = await found await device.stop_scanning() return address - how
- the phone asks for packets of 24 bytes, listens on
VOICE, then saysREADYwith its name, « phone »; the recording is whole once the announced length has arrived.async def receive_recording(peer): await peer.request_mtu(24) voice = peer.get_characteristics_by_uuid(VOICE, SERVICE)[0] ready = peer.get_characteristics_by_uuid(READY, SERVICE)[0] received = bytearray() complete = asyncio.get_running_loop().create_future() def on_chunk(chunk): received.extend(chunk) if len(received) >= 4: (length,) = struct.unpack_from("<I", received) if len(received) - 4 >= length and not complete.done(): complete.set_result(bytes(received[4:4 + length])) await peer.subscribe(voice, on_chunk) await peer.write_value(ready, b"phone", with_response=True) return await complete - how
- the phone listens on
ERRORS, orCHARGE, before it saysREADY, and gathers the lines the watch sends there until one holds what it waits for.async def receive_errors(peer, until): return await receive_lines(peer, ERRORS, until) async def receive_charge(peer, until): return await receive_lines(peer, CHARGE, until) async def receive_lines(peer, uuid, until): await peer.request_mtu(247) channel = peer.get_characteristics_by_uuid(uuid, SERVICE)[0] ready = peer.get_characteristics_by_uuid(READY, SERVICE)[0] lines = [] heard = asyncio.get_running_loop().create_future() def on_line(value): lines.append(bytes(value).decode()) if until in lines[-1] and not heard.done(): heard.set_result(lines) await peer.subscribe(channel, on_line) await peer.write_value(ready, b"phone", with_response=True) return await asyncio.wait_for(heard, 10) - how
- the phone says
READYwith the name it is told.async def say_ready(peer, name): ready = peer.get_characteristics_by_uuid(READY, SERVICE)[0] await peer.write_value(ready, name.encode(), with_response=True) - how
- a recall is its priority, 3 unless told, when it was sent, now unless told, then its text,
written on
RECALL.async def send_recall(peer, text, priority=3, when=None): recall = peer.get_characteristics_by_uuid(RECALL, SERVICE)[0] stamp = struct.pack("<BHBBBB", priority, *(when or time.localtime()[:5])) await peer.write_value(recall, stamp + text.encode(), with_response=True) - how
- a picture is 240x208 pixels, the band between the hour and the bar, 16 bits each, big-endian,
red 5, green 6, blue 5, as the st7789 takes them; it goes as 416 half-rows on
PICTURE, each its row, its half, 0 left or 1 right, then its 120 pixels, 242 bytes, so packets of 247 bytes are asked first.async def send_picture(peer, pixels): await peer.request_mtu(247) picture = peer.get_characteristics_by_uuid(PICTURE, SERVICE)[0] for y in range(208): for half in (0, 1): at = (y * 240 + half * 120) * 2 await peer.write_value(picture, bytes((y, half)) + pixels[at:at + 240], with_response=True) - how
- the phone hears each media command the watch notifies on
MEDIA, a single byte: 1 back, 2 play or pause, 3 skip, 9 ring the phone, 10 found, 11 watch found, 12 refuse the ringing call.async def hear_media(peer, heard): media = peer.get_characteristics_by_uuid(MEDIA, SERVICE)[0] await peer.subscribe(media, lambda value: heard(value[0])) - how
- a notification is a recall whose text is its app, its title and its text, one after the other.
- how: ntfy’s go without the app’s name, and a title that is only the server and the topic, as
« 192.168.2.14:9705/n », shrinks to the topic.
- why: that address and « ntfy » took a whole row before the message began.
- how: a time opening the text goes, as the stamp already says it; a link shrinks to its host, a
date and time to its hour and minute, and what the watch’s font cannot draw, as emoji, goes.
- how: a letter the font lacks loses its accent, « ō » becoming « o », and « œ » is spelled « oe ».
- why: the screen is 30 columns; a grafana link alone filled some 30 rows.
- how: a known app’s icon takes the place of its name: Signal, SMS and mail apps by their package, any call or mail by its category; other apps keep their name.
- how: ntfy’s go without the app’s name, and a title that is only the server and the topic, as
« 192.168.2.14:9705/n », shrinks to the topic.
import re
import unicodedata
NTFY_ADDRESS = re.compile(r"^[\w.-]+(:\d+)?/([\w-]+)$")
LEADING_TIME = re.compile(r"^\d\d:\d\d: ?")
LINK = re.compile(r"https?://([^/\s]+)\S*")
DATE_TIME = re.compile(r"\b\d{4}-\d\d-\d\d[ T](\d\d:\d\d)(:\d\d(\.\d+)?)?")
def plain(text):
text = DATE_TIME.sub(r"\1", LINK.sub(r"\1", text))
return " ".join("".join(_drawable(char) for char in text).split())
SPELLED = {"œ": "oe", "Œ": "OE"}
def _drawable(char):
try:
char.encode("cp437")
return char
except UnicodeEncodeError:
return SPELLED.get(char) or unicodedata.normalize("NFKD", char).encode("ascii", "ignore").decode()
CALL_ICON, SMS_ICON, WHATSAPP_ICON, MAIL_ICON = "\x01\x02", "\x03\x04", "\x05\x06", "\x07\x08"
NTFY_ICON, CALENDAR_ICON = "\x0e\x0f", "\x10\x11"
PACKAGE_ICONS = {
"com.whatsapp": WHATSAPP_ICON,
"io.heckel.ntfy": NTFY_ICON,
"com.google.android.apps.messaging": SMS_ICON,
"com.android.messaging": SMS_ICON,
"com.android.mms": SMS_ICON,
"com.google.android.gm": MAIL_ICON,
"com.fsck.k9": MAIL_ICON,
"eu.faircode.email": MAIL_ICON,
}
CATEGORY_ICONS = {"call": CALL_ICON, "email": MAIL_ICON, "event": CALENDAR_ICON, "reminder": CALENDAR_ICON}
def icon(package, category):
return PACKAGE_ICONS.get(package) or CATEGORY_ICONS.get(category)
def worded(app, title, text, package=None, category=None):
text = LEADING_TIME.sub("", text or "")
if app == "ntfy":
address = NTFY_ADDRESS.match(title or "")
title = address.group(2) if address else title
return NTFY_ICON + plain("{}: {}".format(title, text) if title else text)
known = icon(package, category)
if known:
return known + plain("{}: {}".format(title or "", text))
return plain("{}: {}: {}".format(app, title or "", text))
import re
import unicodedata
NTFY_ADDRESS = re.compile(r"^[\w.-]+(:\d+)?/([\w-]+)$")
LEADING_TIME = re.compile(r"^\d\d:\d\d: ?")
LINK = re.compile(r"https?://([^/\s]+)\S*")
DATE_TIME = re.compile(r"\b\d{4}-\d\d-\d\d[ T](\d\d:\d\d)(:\d\d(\.\d+)?)?")
def plain(text):
text = DATE_TIME.sub(r"\1", LINK.sub(r"\1", text))
return " ".join("".join(_drawable(char) for char in text).split())
SPELLED = {"œ": "oe", "Œ": "OE"}
def _drawable(char):
try:
char.encode("cp437")
return char
except UnicodeEncodeError:
return SPELLED.get(char) or unicodedata.normalize("NFKD", char).encode("ascii", "ignore").decode()
CALL_ICON, SMS_ICON, WHATSAPP_ICON, MAIL_ICON = "\x01\x02", "\x03\x04", "\x05\x06", "\x07\x08"
NTFY_ICON, CALENDAR_ICON = "\x0e\x0f", "\x10\x11"
PACKAGE_ICONS = {
"com.whatsapp": WHATSAPP_ICON,
"io.heckel.ntfy": NTFY_ICON,
"com.google.android.apps.messaging": SMS_ICON,
"com.android.messaging": SMS_ICON,
"com.android.mms": SMS_ICON,
"com.google.android.gm": MAIL_ICON,
"com.fsck.k9": MAIL_ICON,
"eu.faircode.email": MAIL_ICON,
}
CATEGORY_ICONS = {"call": CALL_ICON, "email": MAIL_ICON, "event": CALENDAR_ICON, "reminder": CALENDAR_ICON}
def icon(package, category):
return PACKAGE_ICONS.get(package) or CATEGORY_ICONS.get(category)
def worded(app, title, text, package=None, category=None):
text = LEADING_TIME.sub("", text or "")
if app == "ntfy":
address = NTFY_ADDRESS.match(title or "")
title = address.group(2) if address else title
return NTFY_ICON + plain("{}: {}".format(title, text) if title else text)
known = icon(package, category)
if known:
return known + plain("{}: {}".format(title or "", text))
return plain("{}: {}: {}".format(app, title or "", text))
async def notify(peer, app, title, text, priority=3, when=None, package=None, category=None):
await send_recall(peer, worded(app, title, text, package, category), priority, when)
- how
- the time is the one the story gives, written on
TIME.async def send_time(peer, when): clock = peer.get_characteristics_by_uuid(TIME, SERVICE)[0] await peer.write_value(clock, struct.pack("<HBBBBBB", *when), with_response=True) - what
- on the real air, until the android companion exists, this desktop stands in for the phone,
through bleak over bluez.
- why: bluez holds the desktop’s adapter; bleak speaks to it without root, where bumble would need it taken from bluez.
- scope: it speaks the same protocol as the bumble companion, but bluez settles the packet size by itself.
import asyncio import struct import sys import time from pathlib import Path from bleak import BleakClient, BleakScanner NAME = "twatch" VOICE = "8d5a7819-0002-4b5a-a8f2-6f7a6c1b0e01" READY = "8d5a7819-0003-4b5a-a8f2-6f7a6c1b0e01" async def receive_one(inbox): print("looking for the watch") watch = await BleakScanner.find_device_by_name(NAME, timeout=60) if watch is None: raise SystemExit("no watch seen in 60 s") async with BleakClient(watch) as client: print(f"linked to {watch.address}, packets of {client.mtu_size} bytes") received = bytearray() complete = asyncio.get_running_loop().create_future() def on_chunk(_, chunk): received.extend(chunk) if len(received) >= 4: (length,) = struct.unpack_from("<I", received) if len(received) - 4 >= length and not complete.done(): complete.set_result(bytes(received[4:4 + length])) await client.start_notify(VOICE, on_chunk) await client.write_gatt_char(READY, b"\x01", response=True) recording = await asyncio.wait_for(complete, 120) inbox.mkdir(parents=True, exist_ok=True) path = inbox / time.strftime("%Y%m%dT%H%M%S.wav") path.write_bytes(recording) print(f"saved {len(recording)} bytes in {path}") asyncio.run(receive_one(Path(sys.argv[1]))) - how
- one command waits for one recording and saves it in the inbox it is given.
#!/usr/bin/env nix-shell #!nix-shell -i bash device-shell.nix set -eu here=$(dirname "$(readlink -f "$0")") exec python3 -I "$here/phone/desk_companion.py" "${1:-$here/inbox}" - how
- on oxenhead, the companion runs in the service of my python runtime, from its start, speaking
ble through able; tangling this note puts it there.
- how: it speaks the bumble companion’s protocol, and saves the recording as
/sdcard/Recordings/twatch/<time>.wav. - how: once the recording is saved, it writes
SAVEDand stays linked for the next; when it loses the watch, or bluetooth comes back on, android’sSTATE_ON, it looks for it again.- why: on
STATE_ON, able does not yet take a scan, « BLE adapter is not enabled »; it waitsADAPTER_WAIT_Sfirst. - why: android’s error at bluetooth’s off makes able swap its ble for one that refuses every
call, for good; on
STATE_ONthe companion puts back the ble it kept at its start.
- why: on
- how: it looks for the watch by its name, which android allows while the phone’s screen is off.
- rule: below android 12, as oxenhead’s 8.1, the phone’s location must be on, battery-saving mode enough: without it a scan finds nothing and says nothing.
- how: it closes any link it still holds before linking anew.
- why: bluetooth switched off sends no unlink, and able refuses a new link while the old one is open.
- how: closing gives able a fresh queue of writes.
- why: able sends each write once the last one is answered; a write lost with the link is never answered, its timeout needs a ticking kivy clock the service lacks, so every later link hung on its packet size and its notifications.
- how: when the watch is not ready
SEARCH_AGAIN_Safter it began looking, it drops its scan and any half-made link, and looks again.- how: before looking again it pauses,
SCAN_PAUSE_Safter the first miss, twice longer after each next one, at mostSCAN_PAUSE_MAX_S; finding the watch starts the pauses anew.- therefore: a watch away for hours costs the phone a scan only part of the time.
- why: on 24/10, after a deploy reset the watch, mojito found it again only once the app was restarted.
- how: before looking again it pauses,
- how: before looking for the watch, it takes the one android may still hold linked from before a restart, which does not advertise while it believes itself linked.
- how: once connected, it asks for packets of 247 bytes, and learns the watch’s service on the answer, or after 2 s without one, as when android hands it a link already set up.
- how: from a recording’s first packet to its saving, it asks android for its fast link,
CONNECTION_PRIORITY_HIGH, and back to the low-power one after. - how: once linked, it asks android for its low-power link,
CONNECTION_PRIORITY_LOW_POWER, through the gatt able’s dispatcher holds. - how: once it knows the service, it writes one thing at a time, each on the last one’s answer: both
notifications, the time,
READYwith the phone’s bluetooth name; only then the recalls and the REPL
relay.
- how: a step android refuses, or answers with a failure, is said; one left unanswered
STEP_WAIT_Sis sent again, and afterSTEP_TRIESthe link is dropped and looked for anew. - why: on 24/10 mojito twice linked, found the services, and never heard back on the voice notifications, so the link hung before « ready ».
- how: a recall is queued when the service is told so, as by
clk android -d oxenhead remote call "twatch:recall?text=call the dentist&priority=4&expires=2", its priority 3 and its life 1 h when not told, stamped with the minute it came. - how: each notification the app’s listener broadcasts becomes a recall, worded as the desk companion
words it, « Signal: Alice: on my way »;
ongoing ones, as a player, and the app’s own are left out, but for a ringing call: a « call » of
high importance, sent at priority 6, buzzing until it stops ringing.
- how: once android’s call state leaves « RINGING », answered or ended, a recall of priority 0 tells the watch to stop.
- how: android’s importance sets its priority: high or more, 3, a buzz; default, 2, shown in silence; low and min are left out, as android shows them in silence too.
- how: a « navigation » one, as OsmAnd’s « 75 m • Tournez à gauche », goes through whatever its
importance, as « 75 m Tournez a gauche », lit in silence, with a buzz when the turn after « • »
changes; it lives a minute.
- what: OsmAnd posts it twice, one ongoing; a title already sent is not sent again.
- how: a player’s « transport » one, its title and text, goes at priority 7, for the media row, each time it changes; it lives a minute.
- how: a media command the watch notifies on
MEDIAdrives the first of android’s active media sessions, which the app’s notification listener may reach: back, play or pause by its state, skip.- how: that session’s title and artist go at priority 7 at each link and a second after each
command, empty when none plays.
- why: Cromite never posts its media notification again as it plays on.
- how: a 9 rings the phone’s alarm, looping at full volume, until a 10, the screen turning on or
off, or a minute; the alarm’s volume is then put back.
- how: the alarm is a beep of its own, a quarter second of 2.7 kHz at full scale then a quarter
second of silence, written once as
/sdcard/poc/twatch_beep.wavand played on the alarm’s stream.- why: on 26/10 mojito’s default alarm named a file gone, and the first one android listed played 15 s in silence.
- why: « same remark for phone »:
Alarm_Beep_01.oggat full volume was too low; a phone’s speaker, as the watch’s, is loudest in the kHz.
- how: it answers the watch with a recall, « phone ringing », or « phone silent: » and why.
- why: « must have message to indicate it got my tap »
- how: the alarm is a beep of its own, a quarter second of 2.7 kHz at full scale then a quarter
second of silence, written once as
- how: that session’s title and artist go at priority 7 at each link and a second after each
command, empty when none plays.
- how: « twatch:ring », as the Twatch screen’s « ring watch » button sends it, queues a recall of
priority 8, « find me », living a minute: the watch beeps and buzzes till its wheel is pressed.
- why: « watch buzzed but did not ring »; a call’s priority 6 keeps buzzing only.
- how: while it rings the button reads « stop », and a second « twatch:ring » sends the 0.
- why: « the “ring watch” must become “stop” »
- how: the button turns back on the stop, on the watch’s 11 after its wheel press, or after the minute; the screen asks « twatch:is_ringing » when it opens.
- how: the queue is written on
RECALLone at a time, at each link and each new recall; one leaves it only once the watch took it, or once expired, which is said. - how: at each link, it gives the watch the phone’s local time, as android’s calendar knows it, its sunday first turned into the watch’s monday first.
- how: it keeps its last 50 lines, sends each new one to the app, and gives them all on « twatch:lines ».
- how: each new line also becomes the text of an ongoing notification of its own, on a « twatch »
channel of low importance: shown, never heard.
- why: on 26/10 mojito showed no notification of the app at all, foreground service or not.
- why: « need a background notif on phone to know what’s going on »
- how: each new line also becomes the text of an ongoing notification of its own, on a « twatch »
channel of low importance: shown, never heard.
- how: it relays tcp port 9721 to the watch’s REPL: what comes in is written on
REPL_IN, one packet at a time, and what the watch notifies onREPL_OUTgoes back out.- how: on each new client, it first stops the watch’s
main.pyand drops 2 s of what the watch says, before relaying anything. - how: a client cut off mid-way ends its relay only; the next one is served.
- how: while a client is served, the link is the fast one, the low-power one again after.
- why: each of the REPL’s round trips waits a connection interval: a 4.9 KB file took 79 s on the low-power link.
- how: on each new client, it first stops the watch’s
- how: « twatch:picture », given a picture’s path, shows it on the watch: the whole picture,
shrunk into the 240x208 band between the hour and the bar, centred on black, goes as the 416
half-rows the watch takes on
PICTURE, one write at a time, on the fast link, the low-power one again after.- why: « not fit in screen », of a wide picture whose middle square only was sent.
- how: each write waits for no answer from the watch, only for android to take it.
- why: « very slow »: 480 writes each waiting the watch’s answer took 17 s.
- how: android packs each pixel as RGB565 little-endian; its two bytes are swapped for the st7789.
- how: REPL packets go first when both wait.
- how: once the desktop leaves, the relay says how many bytes went to the watch, came back, still
wait, and whether a write is left unanswered.
- why: « never work aroud, always fix. If not clear, add logs to improve knowledge »: linked, the watch gave no REPL byte at 14:14.
- how: once the desktop leaves, the bytes still waiting are dropped, never sent at the next link.
- what: sent there, they reach the watch’s REPL as keys, and a Ctrl-C among them stops its
main.py.
- what: sent there, they reach the watch’s REPL as keys, and a Ctrl-C among them stops its
- how: each line the watch sends on
ERRORSis said as « watch error: … », its notifications asked for beforeREADY.- why: « log excep(ions somewhere boot resistant and at next lçnk send to phone and purge »
- how: each line on
CHARGEis said as « watch charge: … », its notifications asked for the same way; untested, no story running phone code.- why: « 2 »: the charge kept apart from the errors.
- how: a 12 from the watch refuses the ringing call, through android’s telecom service.
- how: its answer, « call refused » or why not, goes back to the watch as a lit, silent recall,
priority 2, dropped after 6 min.
- why: « the preqs to hangoff must have more feedvack »
- how: it needs android 9 or later and the ANSWER_PHONE_CALLS permission; a refusal is said.
- how: its answer, « call refused » or why not, goes back to the watch as a lit, silent recall,
priority 2, dropped after 6 min.
- how: once an hour has passed, the next line it says is followed by its own cost since the last
count: its process’s cpu seconds, its scans and their time, and its time linked to the watch.
- why: « this must be captured in the poc app itself, not via the desktop »
- what: its own mAh is out of reach: android gives it only with BATTERY_STATS, which apps do not get18.
- how: no timer of its own, so the count costs no wake; untested, no story running phone code.
- how: every 5 min it appends a line to
/sdcard/poc/twatch_record.csv: the time, the battery’s percent, what plugs it, whether the screen is on, the link’s state, and the links made and lost since.- why: « oxenhead PoC must autonomously record relevant data that we will analyse later »
- how: a plain timer, no alarm: asleep, the phone writes nothing, and the gap in the times tells it.
- scope: untested, no story running phone code.
- how: it speaks the bumble companion’s protocol, and saves the recording as
import math
import socket
import struct
import threading
import time
from collections import deque
from logging import getLogger
from pathlib import Path
from able import GATT_SUCCESS, BluetoothDispatcher
from able.filters import DeviceNameFilter
from android.broadcast import BroadcastReceiver
from helpers.osc import oschandler, to_app
from jnius import autoclass
NAME = "twatch"
NOTIFICATION = "eu.konubinix.konixpoc.NOTIFICATION"
PHONE_STATE = "android.intent.action.PHONE_STATE"
VOICE = "8d5a7819-0002-4b5a-a8f2-6f7a6c1b0e01"
READY = "8d5a7819-0003-4b5a-a8f2-6f7a6c1b0e01"
RECALL = "8d5a7819-0004-4b5a-a8f2-6f7a6c1b0e01"
SAVED = "8d5a7819-0005-4b5a-a8f2-6f7a6c1b0e01"
REPL_IN = "8d5a7819-0006-4b5a-a8f2-6f7a6c1b0e01"
REPL_OUT = "8d5a7819-0007-4b5a-a8f2-6f7a6c1b0e01"
TIME = "8d5a7819-0008-4b5a-a8f2-6f7a6c1b0e01"
MEDIA = "8d5a7819-0009-4b5a-a8f2-6f7a6c1b0e01"
PICTURE = "8d5a7819-000a-4b5a-a8f2-6f7a6c1b0e01"
ERRORS = "8d5a7819-000c-4b5a-a8f2-6f7a6c1b0e01"
CHARGE = "8d5a7819-000d-4b5a-a8f2-6f7a6c1b0e01"
SIDE = 240
BAND = 208
HALF_ROW = 242
WRITE_TYPE_NO_RESPONSE = 1
LISTENER = "eu.konubinix.konixpoc.NotificationForwarder"
PLAYING = 3
MEDIA_SETTLE_S = 1
FIND_PHONE = 9
PHONE_FOUND = 10
WATCH_FOUND = 11
REFUSE_CALL = 12
SHOWN_LIT = 2
ANSWER_EXPIRES_H = 0.1
FIND_ME = 8
FIND_FOR_S = 60
NOTIFY_CHANNEL = "twatch"
NOTIFY_ID = 9721
IMPORTANCE_LOW = 2
SCREEN_ON = "android.intent.action.SCREEN_ON"
SCREEN_OFF = "android.intent.action.SCREEN_OFF"
STREAM_ALARM = 4
USAGE_ALARM = 4
BEEP = Path("/sdcard/poc/twatch_beep.wav")
BEEP_HZ = 2700
BEEP_RATE = 16000
INBOX = Path("/sdcard/Recordings/twatch")
RELAY_PORT = 9721
DRAIN_S = 2
HIGH = 1
LOW_POWER = 2
ADAPTER_ON = 12
ADAPTER_WAIT_S = 2
MTU_WAIT_S = 2
SEARCH_AGAIN_S = 300
SCAN_PAUSE_S = 30
SCAN_PAUSE_MAX_S = 300
BOUNCE_S = 5
COST_EVERY_S = 3600
RECORD = Path("/sdcard/poc/twatch_record.csv")
RECORD_EVERY_S = 300
STEP_WAIT_S = 5
STEP_TRIES = 2
GATT_PROFILE = 7
IMPORTANCE_NONE = 0
IMPORTANCE_DEFAULT = 3
IMPORTANCE_HIGH = 4
Calendar = autoclass("java.util.Calendar")
logger = getLogger(__name__)
class Companion(BluetoothDispatcher):
def __init__(self, say):
super().__init__()
self._say = say
self.cost_at = time.monotonic()
self.cpu_at = time.process_time()
self.scans = 0
self.scan_s = 0
self.scan_since = None
self.linked_s = 0
self.linked_since = None
self.received = bytearray()
self.mtu = 23
self.recall = None
self.discovering = False
self.recalls = deque()
self.recalling = None
self.repl_in = None
self.picture = None
self.steps = deque()
self.to_watch = deque()
self.pieces = deque()
self.writing = False
self.lock = threading.Lock()
self.desktop = None
self.draining = False
self.repl_sent = 0
self.repl_back = 0
self.search = 0
self.misses = 0
self.ready_at = None
self.step = None
self.step_id = 0
self.step_tries = 0
self.made = 0
self.lost = 0
self.ble = self._ble
def say(self, line):
self._say(line)
self.note_cost()
def note_cost(self):
now = time.monotonic()
if now - self.cost_at < COST_EVERY_S:
return
cpu = time.process_time()
scan_s = self.scan_s + (0 if self.scan_since is None else now - self.scan_since)
linked_s = self.linked_s + (0 if self.linked_since is None else now - self.linked_since)
self._say("cost over {:.1f} h: cpu {:.0f} s, scan {:.0f} s in {} scans, linked {:.0f} s".format(
(now - self.cost_at) / 3600, cpu - self.cpu_at, scan_s, self.scans, linked_s))
self.cost_at, self.cpu_at, self.scans, self.scan_s, self.linked_s = now, cpu, 0, 0, 0
self.scan_since = None if self.scan_since is None else now
self.linked_since = None if self.linked_since is None else now
def start_scan(self, *args, **kwargs):
if self.scan_since is None:
self.scans += 1
self.scan_since = time.monotonic()
super().start_scan(*args, **kwargs)
def stop_scan(self):
if self.scan_since is not None:
self.scan_s += time.monotonic() - self.scan_since
self.scan_since = None
try:
super().stop_scan()
except Exception as error:
logger.info("scan not stopped: %s", error)
def linked(self, now_linked):
if now_linked and self.linked_since is None:
self.linked_since = time.monotonic()
self.made += 1
elif not now_linked and self.linked_since is not None:
self.linked_s += time.monotonic() - self.linked_since
self.linked_since = None
self.lost += 1
def receive_one(self):
self.received = bytearray()
self.linking = False
with self.lock:
self.search += 1
search = self.search
threading.Timer(SEARCH_AGAIN_S, self.search_again, (search,)).start()
watch = connected_watch()
if watch is not None:
self.on_device(watch, None, None)
return
link_state("looking for the watch")
self.say("looking for the watch")
self.start_scan(filters=[DeviceNameFilter(NAME)])
def close_gatt(self):
super().close_gatt()
self._set_queue()
def search_again(self, search):
with self.lock:
if search != self.search:
return
pause = min(SCAN_PAUSE_S * 2 ** self.misses, SCAN_PAUSE_MAX_S)
self.misses += 1
self.say("not back after {} s, searching again in {} s".format(SEARCH_AGAIN_S, pause))
self.stop_scan()
self.close_gatt()
link_state("watch away")
threading.Timer(pause, self.resume_search, (search,)).start()
def resume_search(self, search):
with self.lock:
if search != self.search:
return
self.receive_one()
def on_bluetooth_adapter_state_change(self, state):
link_state(_state)
if state == ADAPTER_ON:
self._ble = self.ble
self.say("bluetooth back")
threading.Timer(ADAPTER_WAIT_S, self.receive_one).start()
def on_device(self, device, rssi, advertisement):
if device.getName() == NAME and not self.linking:
self.linking = True
self.stop_scan()
self.say("linking")
self.close_gatt()
self.connect_gatt(device)
def on_connection_state_change(self, status, state):
self.linked(status == GATT_SUCCESS and state)
if status == GATT_SUCCESS and state:
self.discovering = False
self.request_mtu(247)
threading.Timer(MTU_WAIT_S, self.discover, ("no packet size answer",)).start()
else:
link_state("watch unlinked")
self.say("unlinked")
with self.lock:
self.recall = self.recalling = self.repl_in = self.picture = None
self.pieces.clear()
self.writing = False
self.step_id += 1
self.steps.clear()
self.close_gatt()
bounced = self.ready_at is not None and time.monotonic() - self.ready_at < BOUNCE_S
self.ready_at = None
if bounced:
pause = min(SCAN_PAUSE_S * 2 ** self.misses, SCAN_PAUSE_MAX_S)
self.misses += 1
self.say("dropped just after ready, searching again in {} s".format(pause))
with self.lock:
self.search += 1
search = self.search
threading.Timer(pause, self.resume_search, (search,)).start()
else:
self.misses = 0
self.receive_one()
def on_mtu_changed(self, mtu, status):
self.mtu = mtu
self.discover("linked, packets of {} bytes".format(mtu))
def discover(self, why):
with self.lock:
if self.discovering:
return
self.discovering = True
self.say(why)
self.discover_services()
def on_services(self, status, services):
self.voice = services.search(VOICE)
self.saved = services.search(SAVED)
self.services = services
self.gatt.requestConnectionPriority(LOW_POWER)
self.steps = deque([
("voice notifications", lambda: self.enable_notifications(self.voice)),
("REPL notifications", lambda: self.enable_notifications(services.search(REPL_OUT))),
("time", lambda: self.write_characteristic(services.search(TIME), local_time())),
("ready", lambda: self.write_characteristic(services.search(READY), phone_name())),
])
if services.search(MEDIA) is not None:
self.steps.insert(2, ("media notifications",
lambda: self.enable_notifications(services.search(MEDIA))))
if services.search(ERRORS) is not None:
self.steps.insert(2, ("error notifications",
lambda: self.enable_notifications(services.search(ERRORS))))
if services.search(CHARGE) is not None:
self.steps.insert(2, ("charge notifications",
lambda: self.enable_notifications(services.search(CHARGE))))
self.next_step()
def next_step(self):
with self.lock:
self.step_id += 1
if self.steps:
self.step = self.steps.popleft()
self.step_tries = 0
self.run_step()
return
link_state("watch linked")
self.say("ready")
self.ready_at = time.monotonic()
with self.lock:
self.search += 1
self.recall = self.services.search(RECALL)
self.repl_in = self.services.search(REPL_IN)
self.picture = self.services.search(PICTURE)
if self.picture is not None:
self.picture.setWriteType(WRITE_TYPE_NO_RESPONSE)
threading.Thread(target=show_playing, daemon=True).start()
self.send_recalls()
self.pump()
def run_step(self):
name, call = self.step
self.step_tries += 1
with self.lock:
step_id = self.step_id
if call() is False:
self.say("android refused {}".format(name))
threading.Timer(STEP_WAIT_S, self.step_unanswered, (step_id,)).start()
def step_unanswered(self, step_id):
with self.lock:
if step_id != self.step_id:
return
name = self.step[0]
if self.step_tries < STEP_TRIES:
self.say("no answer to {}, again".format(name))
self.run_step()
return
self.say("no answer to {}, dropping the link".format(name))
with self.lock:
self.step_id += 1
self.steps.clear()
self.close_gatt()
self.receive_one()
def answered(self, status):
if status != GATT_SUCCESS:
self.say("{} answered {}".format(self.step[0], status))
self.next_step()
def on_descriptor_write(self, descriptor, status):
self.answered(status)
def on_characteristic_changed(self, characteristic):
if characteristic.getUuid().toString() == REPL_OUT:
self.repl_back += len(characteristic.getValue())
if self.desktop is not None and not self.draining:
try:
self.desktop.sendall(bytes(b % 256 for b in characteristic.getValue()))
except OSError as error:
self.say("relay cut: {}".format(error))
return
if characteristic.getUuid().toString() == MEDIA:
code = characteristic.getValue()[0] % 256
if code == REFUSE_CALL:
self.queue_recall(refuse_call(self.say), SHOWN_LIT, ANSWER_EXPIRES_H)
return
media_command(code, self.say)
return
if characteristic.getUuid().toString() == ERRORS:
line = bytes(b % 256 for b in characteristic.getValue()).decode(errors="replace")
self.say("watch error: {}".format(line))
return
if characteristic.getUuid().toString() == CHARGE:
line = bytes(b % 256 for b in characteristic.getValue()).decode(errors="replace")
self.say("watch charge: {}".format(line))
return
if not self.received:
self.gatt.requestConnectionPriority(HIGH)
self.received.extend(b % 256 for b in characteristic.getValue())
if len(self.received) < 4:
return
(length,) = struct.unpack_from("<I", self.received)
if len(self.received) - 4 >= length:
INBOX.mkdir(parents=True, exist_ok=True)
path = INBOX / time.strftime("%Y%m%dT%H%M%S.wav")
path.write_bytes(bytes(self.received[4:4 + length]))
self.say("saved {}".format(path))
self.received = bytearray()
self.gatt.requestConnectionPriority(LOW_POWER)
self.write_characteristic(self.saved, b"\x01")
def on_characteristic_write(self, characteristic, status):
if characteristic.getUuid().toString() == SAVED:
self.say("told the watch")
elif characteristic.getUuid().toString() in (TIME, READY):
self.answered(status)
elif characteristic.getUuid().toString() in (REPL_IN, PICTURE):
with self.lock:
self.writing = False
self.pump()
elif characteristic.getUuid().toString() == RECALL:
with self.lock:
sent, self.recalling = self.recalling, None
if status == GATT_SUCCESS and self.recalls and self.recalls[0] is sent:
self.recalls.popleft()
if status == GATT_SUCCESS and sent is not None:
self.say("recall given: {}".format(sent["text"]))
self.send_recalls()
def queue_recall(self, text, priority, expires_h):
with self.lock:
self.recalls.append({"text": text, "priority": priority, "sent": local_minute(),
"expires": time.time() + expires_h * 3600})
self.say("recall queued, priority {}: {}".format(priority, text))
self.send_recalls()
def send_recalls(self):
with self.lock:
while self.recalls and self.recalls[0]["expires"] < time.time():
self.say("recall expired: {}".format(self.recalls.popleft()["text"]))
if self.recall is None or self.recalling is not None or not self.recalls:
return
self.recalling = self.recalls[0]
payload = struct.pack("<BHBBBB", self.recalling["priority"], *self.recalling["sent"])
payload += self.recalling["text"].encode()
recall = self.recall
try:
self.write_characteristic(recall, payload)
except Exception as error:
self.say("recall kept for the next link: {}".format(error))
with self.lock:
self.recalling = None
def relay(self, data):
step = self.mtu - 3
with self.lock:
self.to_watch.extend(data[i:i + step] for i in range(0, len(data), step))
self.pump()
def show_picture(self, pieces):
with self.lock:
if self.picture is None:
self.say("no picture: the watch is not linked")
return
if self.mtu - 3 < HALF_ROW:
self.say("no picture: packets of {} bytes, {} needed".format(self.mtu, HALF_ROW + 3))
return
self.pieces.clear()
self.pieces.extend(pieces)
self.say("sending a picture")
self.prioritize(HIGH)
self.pump()
def pump(self):
with self.lock:
if self.writing:
return
if self.to_watch and self.repl_in is not None:
target, chunk = self.repl_in, self.to_watch.popleft()
self.repl_sent += len(chunk)
elif self.pieces and self.picture is not None:
target, chunk = self.picture, self.pieces.popleft()
last = not self.pieces
else:
return
self.writing = True
self.write_characteristic(target, chunk)
if target is self.picture and last:
self.say("picture sent")
if self.desktop is None:
self.prioritize(LOW_POWER)
def serve_relay(self):
server = socket.socket(socket.AF_INET6)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
server.bind(("::", RELAY_PORT))
server.listen(1)
while True:
self.desktop, address = server.accept()
self.repl_sent = self.repl_back = 0
self.say("relaying the REPL for {}".format(address[0]))
self.prioritize(HIGH)
self.draining = True
self.relay(b"\r\x03\x03")
time.sleep(DRAIN_S)
self.draining = False
try:
while True:
data = self.desktop.recv(1024)
if not data:
break
self.relay(data)
except OSError as error:
self.say("relay cut: {}".format(error))
self.desktop.close()
self.desktop = None
self.prioritize(LOW_POWER)
with self.lock:
waiting = sum(len(chunk) for chunk in self.to_watch)
self.to_watch.clear()
writing = self.writing
self.say("relay closed: {} bytes to the watch, {} back, {} waiting dropped{}".format(
self.repl_sent, self.repl_back, waiting, ", a write unanswered" if writing else ""))
def prioritize(self, level):
gatt = self.gatt
if gatt is not None:
gatt.requestConnectionPriority(level)
def local_time():
now = Calendar.getInstance()
return struct.pack(
"<HBBBBBB", now.get(Calendar.YEAR), now.get(Calendar.MONTH) + 1,
now.get(Calendar.DAY_OF_MONTH), (now.get(Calendar.DAY_OF_WEEK) + 5) % 7,
now.get(Calendar.HOUR_OF_DAY), now.get(Calendar.MINUTE), now.get(Calendar.SECOND))
def picture_pieces(path):
# the whole picture, shrunk into the band between the hour and the bar, centred on black, as the
# watch's 416 half-rows
BitmapFactory = autoclass("android.graphics.BitmapFactory")
Bitmap = autoclass("android.graphics.Bitmap")
options = autoclass("android.graphics.BitmapFactory$Options")()
options.inJustDecodeBounds = True
BitmapFactory.decodeFile(path, options)
if options.outWidth <= 0:
raise ValueError("not a picture: {}".format(path))
options.inJustDecodeBounds = False
options.inSampleSize = max(1, max(options.outWidth, options.outHeight) // (2 * SIDE))
whole = BitmapFactory.decodeFile(path, options)
scale = min(SIDE / whole.getWidth(), BAND / whole.getHeight())
fitted = Bitmap.createScaledBitmap(whole, max(1, round(whole.getWidth() * scale)),
max(1, round(whole.getHeight() * scale)), True)
small = Bitmap.createBitmap(SIDE, BAND, autoclass("android.graphics.Bitmap$Config").RGB_565)
canvas = autoclass("android.graphics.Canvas")(small)
canvas.drawColor(autoclass("android.graphics.Color").BLACK)
canvas.drawBitmap(fitted, float((SIDE - fitted.getWidth()) // 2),
float((BAND - fitted.getHeight()) // 2), None)
buffer = autoclass("java.nio.ByteBuffer").allocate(SIDE * BAND * 2)
small.copyPixelsToBuffer(buffer)
# android keeps each pixel little-endian, the st7789 takes it big-endian
pixels = bytearray(b % 256 for b in buffer.array())
pixels[0::2], pixels[1::2] = pixels[1::2], pixels[0::2]
return [bytes((y, half)) + bytes(pixels[(y * SIDE + half * SIDE // 2) * 2:][:SIDE])
for y in range(BAND) for half in (0, 1)]
def phone_name():
name = autoclass("android.bluetooth.BluetoothAdapter").getDefaultAdapter().getName() or ""
return name.encode()[:20].decode("utf-8", "ignore").encode() or b"\x01"
def media_session():
service = autoclass("org.kivy.android.PythonService").mService
manager = service.getSystemService(autoclass("android.content.Context").MEDIA_SESSION_SERVICE)
# jnius matches no constructor taking the service as a context; two strings it does
listener = autoclass("android.content.ComponentName")(service.getPackageName(), LISTENER)
sessions = manager.getActiveSessions(listener)
return None if sessions.isEmpty() else sessions.get(0)
def alarm_sound():
if not BEEP.exists():
quarter = BEEP_RATE // 4
tone = b"".join(struct.pack("<h", int(32000 * math.sin(2 * math.pi * BEEP_HZ * n / BEEP_RATE)))
for n in range(quarter)) + bytes(2 * quarter)
BEEP.write_bytes(b"RIFF" + struct.pack("<I", 36 + len(tone)) + b"WAVEfmt "
+ struct.pack("<IHHIIHH", 16, 1, 1, BEEP_RATE, 2 * BEEP_RATE, 2, 16)
+ b"data" + struct.pack("<I", len(tone)) + tone)
return str(BEEP)
def find_phone(say):
try:
ring(say)
except Exception as error:
say("silent: {}".format(error))
recall("phone silent: {}".format(error), 3, expires_h=1 / 60)
return
say("ringing the phone")
recall("phone ringing", 2, expires_h=1 / 60)
def ring(say):
global _alarm, _volume, _found
service = autoclass("org.kivy.android.PythonService").mService
audio = service.getSystemService(autoclass("android.content.Context").AUDIO_SERVICE)
with _alarm_lock:
if _alarm is None:
player = autoclass("android.media.MediaPlayer")()
player.setAudioAttributes(
autoclass("android.media.AudioAttributes$Builder")().setUsage(USAGE_ALARM).build())
player.setDataSource(alarm_sound())
player.setLooping(True)
player.prepare()
_volume = audio.getStreamVolume(STREAM_ALARM)
_alarm = player
audio.setStreamVolume(STREAM_ALARM, audio.getStreamMaxVolume(STREAM_ALARM), 0)
_alarm.start()
if _found is not None:
_found.cancel()
_found = threading.Timer(FIND_FOR_S, phone_found, (say,))
_found.start()
def phone_found(say):
global _alarm, _found
with _alarm_lock:
if _alarm is None:
return
_alarm.stop()
_alarm.release()
_alarm = None
if _found is not None:
_found.cancel()
_found = None
service = autoclass("org.kivy.android.PythonService").mService
audio = service.getSystemService(autoclass("android.content.Context").AUDIO_SERVICE)
audio.setStreamVolume(STREAM_ALARM, _volume, 0)
say("phone found")
def refuse_call(say):
# android 9 and later; the app needs ANSWER_PHONE_CALLS granted
service = autoclass("org.kivy.android.PythonService").mService
telecom = service.getSystemService(autoclass("android.content.Context").TELECOM_SERVICE)
try:
ended = telecom.endCall()
except Exception as error:
answer = "call not refused: {}".format(error)
else:
answer = "call refused" if ended else "no call to refuse"
say(answer)
return answer
def media_command(code, say):
if code == FIND_PHONE:
find_phone(say)
return
if code == PHONE_FOUND:
phone_found(say)
return
if code == WATCH_FOUND:
watch_quiet("watch found")
return
if code == REFUSE_CALL:
refuse_call(say)
return
session = media_session()
if session is None:
say("media {}: nothing plays".format(code))
return
controls = session.getTransportControls()
if code == 1:
controls.skipToPrevious()
elif code == 3:
controls.skipToNext()
elif session.getPlaybackState() is not None and session.getPlaybackState().getState() == PLAYING:
controls.pause()
else:
controls.play()
say("media {} to {}".format(code, session.getPackageName()))
threading.Timer(MEDIA_SETTLE_S, show_playing).start()
def show_playing():
global _played
session = media_session()
metadata = session and session.getMetadata()
title = artist = None
if metadata is not None:
title = metadata.getString("android.media.metadata.TITLE")
artist = metadata.getString("android.media.metadata.ARTIST")
_played = plain(": ".join(part for part in (title, artist) if part))
recall(_played, 7, expires_h=1 / 60)
def connected_watch():
service = autoclass("org.kivy.android.PythonService").mService
manager = service.getSystemService(autoclass("android.content.Context").BLUETOOTH_SERVICE)
for device in manager.getConnectedDevices(GATT_PROFILE).toArray():
if device.getName() == NAME:
return device
return None
def local_minute():
now = Calendar.getInstance()
return (now.get(Calendar.YEAR), now.get(Calendar.MONTH) + 1, now.get(Calendar.DAY_OF_MONTH),
now.get(Calendar.HOUR_OF_DAY), now.get(Calendar.MINUTE))
LINES = deque(maxlen=50)
_companion = None
_notifications = None
_calls = None
_guided = None
_played = None
_screen = None
_alarm = None
_volume = None
_found = None
_alarm_lock = threading.Lock()
_ringing_watch = None
_state = "looking for the watch"
def say(text):
logger.info("twatch: %s", text)
line = time.strftime("%H:%M:%S ") + text
LINES.append(line)
to_app("twatch:log", line)
show_in_notification(line)
def link_state(state):
global _state
_state = state
show_in_notification(LINES[-1] if LINES else "")
def show_in_notification(line):
# a notification of the twatch's own, on a low channel: shown, never heard
String = autoclass("java.lang.String")
try:
enabled = autoclass("android.bluetooth.BluetoothAdapter").getDefaultAdapter().isEnabled()
service = autoclass("org.kivy.android.PythonService").mService
manager = service.getSystemService(autoclass("android.content.Context").NOTIFICATION_SERVICE)
manager.createNotificationChannel(autoclass("android.app.NotificationChannel")(
NOTIFY_CHANNEL, String("twatch"), IMPORTANCE_LOW))
notification = (autoclass("android.app.Notification$Builder")(service, NOTIFY_CHANNEL)
.setSmallIcon(service.getApplicationInfo().icon)
.setContentTitle(String(_state if enabled else "bluetooth off"))
.setContentText(String(line))
.setOngoing(True)
.setOnlyAlertOnce(True)
.build())
manager.notify(NOTIFY_ID, notification)
except Exception as error:
logger.info("twatch: notification not shown: %s", error)
@oschandler("twatch:lines", answer=True)
def lines(_):
return list(LINES)
@oschandler("twatch:picture")
def picture(message):
path = message.get("path") if isinstance(message, dict) else message
try:
pieces = picture_pieces(str(path))
except Exception as error:
say("no picture: {}".format(error))
return
_companion.show_picture(pieces)
@oschandler("twatch:ring")
def ring_watch(_):
global _ringing_watch
if _ringing_watch is not None:
recall("", 0, expires_h=1 / 60)
watch_quiet("watch stopped")
return
recall("find me", FIND_ME, expires_h=1 / 60)
_ringing_watch = threading.Timer(FIND_FOR_S, watch_quiet, ("watch rang a minute",))
_ringing_watch.start()
to_app("twatch:ringing", True)
say("ringing the watch")
@oschandler("twatch:is_ringing", answer=True)
def is_ringing(_):
return _ringing_watch is not None
def watch_quiet(why):
global _ringing_watch
if _ringing_watch is None:
return
_ringing_watch.cancel()
_ringing_watch = None
to_app("twatch:ringing", False)
say(why)
def start():
global _companion, _notifications, _calls, _screen
if _companion is None:
_companion = Companion(say)
_companion.receive_one()
threading.Thread(target=_companion.serve_relay, daemon=True).start()
_notifications = BroadcastReceiver(notified, actions=[NOTIFICATION])
_notifications.start()
_calls = BroadcastReceiver(call_state, actions=[PHONE_STATE])
_calls.start()
_screen = BroadcastReceiver(lambda context, intent: phone_found(say),
actions=[SCREEN_ON, SCREEN_OFF])
_screen.start()
record()
def record():
try:
service = autoclass("org.kivy.android.PythonService").mService
Intent = autoclass("android.content.Intent")
battery = service.registerReceiver(None, autoclass("android.content.IntentFilter")(
Intent.ACTION_BATTERY_CHANGED))
percent = battery.getIntExtra("level", -1) * 100 // max(1, battery.getIntExtra("scale", 100))
power = service.getSystemService(autoclass("android.content.Context").POWER_SERVICE)
with RECORD.open("a") as out:
out.write("{},{},{},{},{},{},{}\n".format(
time.strftime("%Y-%m-%d %H:%M:%S"), percent, battery.getIntExtra("plugged", 0),
int(power.isInteractive()), _state, _companion.made, _companion.lost))
_companion.made = _companion.lost = 0
except Exception as error:
logger.info("twatch: not recorded: %s", error)
timer = threading.Timer(RECORD_EVERY_S, record)
timer.daemon = True
timer.start()
def recall(text, priority=3, expires_h=1):
_companion.queue_recall(text, priority, expires_h)
def call_state(context, intent):
if intent.getStringExtra("state") != "RINGING":
# a stop that waits for a link would come too late to matter
recall("", 0, expires_h=1 / 60)
def guided(intent):
global _guided
title = intent.getStringExtra("title")
if title is None or title == _guided:
return
turn = title.split(" • ", 1)[-1]
changed = _guided is None or _guided.split(" • ", 1)[-1] != turn
_guided = title
recall(plain(title.replace(" • ", " ")), 3 if changed else 2, expires_h=1 / 60)
def played(intent):
global _played
title, text = intent.getStringExtra("title"), intent.getStringExtra("text")
shown = plain(": ".join(part for part in (title, text) if part))
if shown != _played:
_played = shown
recall(shown, 7, expires_h=1 / 60)
def notified(context, intent):
if intent.getStringExtra("category") == "navigation":
guided(intent)
return
if intent.getStringExtra("category") == "transport":
played(intent)
return
importance = intent.getIntExtra("importance", IMPORTANCE_DEFAULT)
ringing = intent.getStringExtra("category") == "call" and importance >= IMPORTANCE_HIGH
if intent.getBooleanExtra("ongoing", False) and not ringing:
return
if intent.getStringExtra("package") == context.getPackageName():
return
if IMPORTANCE_NONE <= importance < IMPORTANCE_DEFAULT:
return
title, text = intent.getStringExtra("title"), intent.getStringExtra("text")
if title is None and text is None:
return
priority = 6 if ringing else 3 if importance >= IMPORTANCE_HIGH else 2
recall(worded(intent.getStringExtra("app"), title, text,
intent.getStringExtra("package"), intent.getStringExtra("category")), priority)
import re
import unicodedata
NTFY_ADDRESS = re.compile(r"^[\w.-]+(:\d+)?/([\w-]+)$")
LEADING_TIME = re.compile(r"^\d\d:\d\d: ?")
LINK = re.compile(r"https?://([^/\s]+)\S*")
DATE_TIME = re.compile(r"\b\d{4}-\d\d-\d\d[ T](\d\d:\d\d)(:\d\d(\.\d+)?)?")
def plain(text):
text = DATE_TIME.sub(r"\1", LINK.sub(r"\1", text))
return " ".join("".join(_drawable(char) for char in text).split())
SPELLED = {"œ": "oe", "Œ": "OE"}
def _drawable(char):
try:
char.encode("cp437")
return char
except UnicodeEncodeError:
return SPELLED.get(char) or unicodedata.normalize("NFKD", char).encode("ascii", "ignore").decode()
CALL_ICON, SMS_ICON, WHATSAPP_ICON, MAIL_ICON = "\x01\x02", "\x03\x04", "\x05\x06", "\x07\x08"
NTFY_ICON, CALENDAR_ICON = "\x0e\x0f", "\x10\x11"
PACKAGE_ICONS = {
"com.whatsapp": WHATSAPP_ICON,
"io.heckel.ntfy": NTFY_ICON,
"com.google.android.apps.messaging": SMS_ICON,
"com.android.messaging": SMS_ICON,
"com.android.mms": SMS_ICON,
"com.google.android.gm": MAIL_ICON,
"com.fsck.k9": MAIL_ICON,
"eu.faircode.email": MAIL_ICON,
}
CATEGORY_ICONS = {"call": CALL_ICON, "email": MAIL_ICON, "event": CALENDAR_ICON, "reminder": CALENDAR_ICON}
def icon(package, category):
return PACKAGE_ICONS.get(package) or CATEGORY_ICONS.get(category)
def worded(app, title, text, package=None, category=None):
text = LEADING_TIME.sub("", text or "")
if app == "ntfy":
address = NTFY_ADDRESS.match(title or "")
title = address.group(2) if address else title
return NTFY_ICON + plain("{}: {}".format(title, text) if title else text)
known = icon(package, category)
if known:
return known + plain("{}: {}".format(title or "", text))
return plain("{}: {}: {}".format(app, title or "", text))
- how
- mojito runs the same companion, sent there over ssh.
- how
- the stories run the same companion on the desktop, its android parts faked by the story.
- how
- the app’s Twatch screen has a « ring watch » button on top, sending « twatch:ring ».
- how
- the app’s Twatch screen shows the companion’s lines: those the service kept, asked for when it
opens, then each new one; the launcher opens it on its button or the « twatch » message.
- how: a new line waits for the app’s next turn, without the wakelock
oschandlertakes.- why: that wakelock lights the screen; one per line kept oxenhead’s lit 5 min in 21 while linked.
- how: a new line waits for the app’s next turn, without the wakelock
import json
from kivy.clock import Clock
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import Button
from kivy.uix.label import Label
from kivy.uix.screenmanager import Screen
from helpers.osc import TimeoutException, osc, to_service
SHOWN = 50
class TwatchScreen(Screen):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.lines = []
self.label = Label(font_size=20, halign="left", valign="bottom")
self.label.bind(size=lambda label, size: setattr(label, "text_size", size))
self.column = BoxLayout(orientation="vertical")
buttons = BoxLayout(size_hint_y=None, height=120)
self.ring = Button(text="ring watch")
self.ring.bind(on_release=lambda _: to_service("twatch:ring"))
buttons.add_widget(self.ring)
self.column.add_widget(buttons)
self.column.add_widget(self.label)
self.add_widget(self.column)
osc.bind("/twatch:log", lambda line: Clock.schedule_once(lambda _: self.add(json.loads(line))))
osc.bind("/twatch:ringing",
lambda on: Clock.schedule_once(lambda _: self.ringing(json.loads(on))))
def ringing(self, on):
self.ring.text = "stop" if on else "ring watch"
def add(self, line):
self.lines = (self.lines + [line])[-SHOWN:]
self.label.text = "\n".join(self.lines)
def on_enter(self):
self.lines = []
try:
kept = to_service("twatch:lines", wait_for_answer=True, timeout=5)
self.ringing(to_service("twatch:is_ringing", wait_for_answer=True, timeout=5))
except TimeoutException:
kept = ["the service does not answer"]
for line in kept:
self.add(line)
def populate(sm):
if not sm.has_screen("twatch"):
sm.add_widget(TwatchScreen(name="twatch"))
- how
- mojito gets the same screen, sent there over ssh, so its Twatch button finds it.
- how
- one command puts the watch’s code on it, opens the companion on oxenhead, starts the watch,
and pulls the inbox here.
- how: clk runs outside the device shell, mpremote inside it.
- how: a ctrl-C stops
main.pybut leaves it advertising, so the watch’s radio is switched off before the companion opens; the phone links only to the watch the reset starts.
import bluetooth bluetooth.BLE().active(False)#!/usr/bin/env bash set -eu here=$(dirname "$(readlink -f "$0")") port=${1:-/dev/ttyACM0} "$here/deploy-watch.sh" "$port" nix-shell "$here/device-shell.nix" --run "mpremote connect $port resume cp $here/watch/main.py :main.py + run $here/watch/quiet.py" clk android -d oxenhead poc restart clk android -d oxenhead remote wait clk android -d oxenhead remote call twatch nix-shell "$here/device-shell.nix" --run "mpremote connect $port resume run $here/watch/updated.py + reset" || true sleep 90 mkdir -p "$here/inbox" adb -s oxenhead:5555 pull /sdcard/Recordings/twatch/. "$here/inbox/"
Permalink
-
« Availability: ESP32, MIMXRT, RP2, STM32 » and « receive or transmit » — machine.I2S, where the port named covers this chip, « ESP32_GENERIC_S3 » being a board of the esp32 port. « The PDM protocol is not supported by MicroPython. It is not possible to use the
machine.I2Sclass with a PDM microphone. » — miketeachman, in micropython discussion 16048. « ports/esp32: Add PDM RX feature to I2S. », open and unmerged since 2024 — pull request 14176, where « extmod/machine_i2s needs I2S to be in RX mode to be able to be read from. since PDM_RX is a new mode, it needed some modifications » — micropython discussion 14160. « This patch adds support for I2S PDM to micropython, as implemented by this (unmerged) 2024 PR to micropython: … pull/14176 » and « appears to have gotten stuck in API bikeshedding hell » — joenash, merged 2026-06-27 in emfcamp pull request 352. « Currently this supports Bluetooth Low Energy (BLE) in Central, Peripheral, Broadcaster, and Observer roles, as well as GATT Server and Client and L2CAP connection-oriented-channels. » and « currently only supported when using the NimBLE stack on ESP32, STM32 and Unix » — the bluetooth module. « Setup the AX2101 power manager chip in order to give current to the different subsystems, enable the TFT display backlight, setup charging, and so forth » and « a MicroPython ST7789 display driver that was already available here », for the s3 — t-watch-s3-micropython. « machine.lightsleep([time_ms]) » and « machine.deepsleep([time_ms]) », « Stops execution in an attempt to enter a low power state. », lightsleep giving « full RAM and state retention. Upon wake execution is resumed from the point where the sleep was requested » against deepsleep which « may not retain RAM or any other state » — the machine module. ↩︎ ↩︎ ↩︎ -
« Display – 1.54-inch 16-bit full color TFT LCD display with 240×240 resolution, capacitive touch; ST7789V SPI controller », « Battery – 940mAh LiPo », « Built-in PDM microphone, MAX98357A Class D audio amplifier », « MIA-M10Q GPS module featuring a multi-GNSS engine with support for BeiDou, Galileo, GLONASS, and QZSS », and the plus « gets GPS and 940mAh battery » — CNX Software on the T-Watch S3 Plus. ↩︎ ↩︎ ↩︎
-
« T-Watch Ultra & T-LoRa-Pager & T-Watch-S3 & T-Watch-S3-Plus », language « C », licence « MIT » — LilyGoLib. examples/peripheral holds « RecordWAV », « playWAV », « SimpleTone », « GPSFactory », « GPSFullExample » and « RTC_AlarmByUnits »; examples/sleep holds « LightSleep » with « WakeUpFromTimer », « WakeUpFromTouchPanel », « WakeUpFromSensor », « WakeUpFromPowerButton » and « WakeUpFromBootButton »; examples/ble holds « BLEKeyboard » and « BLEMouse » — the library’s examples. In RecordWAV.ino, « uint8_t *wav_buffer; », « size_t wav_size; » and « wav_buffer = instance.mic.recordWAV(5, &wav_size); », played by « instance.player.playWAV(wav_buffer, wav_size); »; in BLEKeyboard.ino, « #include <BleKeyboard.h> » with « BleKeyboard bleKeyboard(USB_PRODUCT); » beside the library’s own « instance.begin(); ». The board class declares « GPS gps; », « PDM mic; » under « #ifdef USING_PDM_MICROPHONE », « Player player; », « bool initMicrophone(); », « bool initGPS(); » and « bool _is_watch_plus; », with « Rewrite battery parameters , Determining if it’s a T-Watch Plus by checking if GPS is present. » and « Light sleep will turn off Haptic, GPS, Speaker , WiFi , Bluetooth . » — LilyGoWatchS3.h. Its src/ holds « PDM.h », « GPS.h », « LilyGoWatchS3.h », « LilyGoWatchUltra.h », « LilyGoPowerManage.h », « LilyGoKeyboard.h », « LilyGoDispInterface.h », « LV_Helper.h », « _wav_header.h », « bsp_codec » and « bsp_lcd », and no BLE source — its sources. ↩︎ ↩︎ ↩︎
-
under « Features », « (SPM1423HM4H-B PDM) » with « platform: i2s_audio » and « pdm: true », « (MAX98357A) with R0805 speaker » with « media_player: » and « dac_type: external », « ST7789V 240x240 220ppi 16-bit, 1.54" IPS LCD over SPI », « FT6336 Capacitative touch » and « AXP2101 Highly Integrated Power Management Unit », and « - [x] `voice_assistant` enabled » under the microphone and speaker yaml, for the s3 — velijv’s T-Watch S3 configuration for ESPHome. « automatically enter a deep sleep mode on the ESP8266/ESP32/BK72xx/nRF52 after a certain amount of time », in which « the node will not do any work and not respond to any network traffic, even Over The Air updates » — the deep sleep component. ↩︎ ↩︎
-
« T-Watch S3 by LILYGO », and « CircuitPython 10.2.1 » given as « the latest stable release of CircuitPython that will work with the T-Watch S3 » — the circuitpython board page; the boards of the espressif port are « lilygo_twatch_s3 » and « lilygo_twatch_2020_v3 », with no plus among them. On PDMIn, « On Espressif, only available on ESP32, ESP32-S3, and ESP32-P4. » in audiobusio, latest against « Not available on Espressif. » in audiobusio, stable. « _bleio uses native BLE capability on boards that support it, including Nordic nRF, Espressif (except ESP32-S2 and ESP32-P4), and SiLabs. » — _bleio. « / MIC » with « MIC_DATA » and « MIC_SCLK », and « / MAX98357A SPK » with « I2S_BCK », « I2S_WS » and « I2S_DOUT » — the board’s pins; « CIRCUITPY_ESP_FLASH_SIZE = 16MB » with frozen drivers « Adafruit_CircuitPython_FocalTouch », « Adafruit_CircuitPython_DRV2605 », « Adafruit_CircuitPython_PCF8563 », « CircuitPython_AXP2101 » and « CircuitPython_BMA423 », LoRa left commented « To be added in the future » — the board’s makefile. « Light sleep keeps sufficient state so the program can resume after sleeping. » and « Deep sleep shuts down power to nearly all of the microcontroller including the CPU and RAM. » — the alarm module. ↩︎
-
« The official firmware for Meshtastic, an open-source, off-grid mesh communication system. », language « C++ », licence « GPL-3.0 », and the merged pull request « T-Watch S3 Support »; the esp32s3 variants include « t-watch-s3 » and « t-watch-ultra », with no plus among them — the meshtastic firmware. « 1.54 inch ST7789V TFT LCD (Resolution: 240 x 240) » — the Meshtastic T-Watch page. « The Device API is designed to have only a simple stream of ToRadio and FromRadio packets and all polymorphism comes from the flexible set of Google Protocol Buffers. » and « almost identical when it is deployed over BLE, Serial/USB, or TCP (our three currently supported transports for connecting to phone/PC) » — the Meshtastic client API. « codec2.h » and « driver/i2s.h » included, with « PTT_PIN », « CODEC2_700 » and « void sendPayload(NodeNum dest = NODENUM_BROADCAST, bool wantReplies = false); » — the audio module. « When activated, this feature disables Bluetooth, Serial, WiFi, and the device’s screen to conserve power. » and « In light sleep the CPU is suspended, LoRa radio is on, BLE is off and GPS is on » — the power configuration. ↩︎ ↩︎
-
« Microphone (Knowles SPM1423HM4H-B) », « Audio amplifier (Maxim MAX98357A) » and « 470 mAh battery » in the hardware list, against supported features naming « Sitronix ST7789V display controller », « AXP2101 PMIC », « Bluetooth HCI for Espressif ESP32 » and « ESP32 I2S », with no row for the microphone or the amplifier, and no plus named — the Zephyr T-Watch S3 page; the board’s devicetree holds no microphone or gnss node — twatch_s3_procpu.dts. « power-state-name = “standby”; » for light_sleep, and for deep_sleep « status = “disabled”; » under « Deep sleep must be entered using pm_state_force() » — esp32s3_common.dtsi. « gnss_u_blox_m10.c » — the u-blox gnss drivers, the MIA-M10Q coming as « MIA-M10Q-00B » and « MIA-M10Q-01B » under « MIA-M10 series », « u-blox M10 standard precision GNSS SiP modules » — the MIA-M10 product summary. « All boards supported by Zephyr » « should work with MicroPython (but not all were tested) » — the MicroPython Zephyr port. ↩︎
-
« ulTaskNotifyTake(pdFALSE, 1); », inside mp_hal_delay_ms’s loop — micropython 1.27’s mphalport.c; « FreeRTOS will enter light sleep mode if no tasks need to run for this number of ticks. », default 3 — esp-idf 5.5.2’s freertos Kconfig. ↩︎
-
« factory, app, factory, 0x10000, 0x1F0000, » and « The remaining flash is for the user filesystem(s). » — micropython 1.27’s partitions-4MiBplus.csv, the table its esp32 boards default to: « CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=“partitions-4MiBplus.csv” » — sdkconfig.base. ↩︎
-
« FIRST = 0x00 » and « LAST = 0xff » — st7789py’s vga2_8x16.py; « This character set remains the primary set in the core of any EGA and VGA-compatible graphics card. » — Wikipedia’s Code page 437. Drawn as text, its glyphs 0x82, 0x85, 0x87 and 0x8a are é, à, ç and è. ↩︎
-
« enableALDO2(); /! TFT BACKLIGHT VDD », « enableALDO3(); /! Screen touch VDD », and in setBrightness « if (!level) { disableALDO2(); » and « if (!brightness && level != 0) { enableALDO2(); » — LilyGoLib.cpp of LilyGo’s t-watch s3 library. ↩︎
-
« #define XPOWERS_AXP2101_INTEN2 (0x41) » and « #define XPOWERS_AXP2101_INTSTS2 (0x49) » — AXP2101Constants.h; « XPOWERS_AXP2101_PKEY_SHORT_IRQ = _BV(11), // POWERON Short PRESS IRQ(ponsp_irq_en) enable » — XPowersParams.hpp; « uint8_t mask = XPOWERS_AXP2101_PKEY_SHORT_IRQ >> 8; » and clearIrqStatus writing « 0xFF » to the status registers — XPowersAXP2101.hpp. ↩︎
-
« #define XPOWERS_AXP2101_STATUS1 (0x00) », « #define XPOWERS_AXP2101_BAT_PERCENT_DATA (0xA4) », « #define XPOWERS_AXP2101_ADC_DATA_RELUST0 (0x34) », « #define XPOWERS_AXP2101_ADC_CHANNEL_CTRL (0x30) » — AXP2101Constants.h; « return readRegisterH5L8(XPOWERS_AXP2101_ADC_DATA_RELUST0, XPOWERS_AXP2101_ADC_DATA_RELUST1); », « return getRegisterBit(XPOWERS_AXP2101_STATUS1, 3); » and « return setRegisterBit(XPOWERS_AXP2101_ADC_CHANNEL_CTRL, 0); » — XPowersAXP2101.hpp. ↩︎
-
« #define XPOWERS_AXP2101_STATUS2 (0x01) » — AXP2101Constants.h; « return (readRegister(XPOWERS_AXP2101_STATUS2) >> 5) == 0x01; », in isCharging — XPowersAXP2101.hpp. ↩︎
-
« // MAX98357A », « #define BOARD_DAC_IIS_BCK (48) », « #define BOARD_DAC_IIS_WS (15) » and « #define BOARD_DAC_IIS_DOUT (46) » — utilities.h of LilyGo’s t-watch s3 library. ↩︎
-
in « bool LilyGoLib::beginPower() », « /!DRV2605 enable » over « setBLDO2Voltage(3300); » and « enableDLDO1(); /! Speaker »; in « bool LilyGoLib::begin(Stream *stream) », « selectLibrary(1); », « setMode(DRV2605_MODE_INTTRIG); », « useERM(); », « setWaveform(0, 15); / play effect », « setWaveform(1, 0); / end waveform » and « run(); » — LilyGoLib.cpp of LilyGo’s t-watch s3 library. ↩︎ ↩︎
-
« The DRV2605 slave address is 0x5A (7-bit) » and, in the ERM library’s table, « 1 Strong Click - 100% » and « 15 750 ms Alert 100% » — TI’s DRV2605 datasheet. ↩︎ ↩︎ ↩︎ ↩︎
-
« Allows an application to collect battery statistics », « Protection level: signature|privileged|development » — android’s Manifest.permission. ↩︎