Konubinix' opinionated web of thoughts

Use My T-Watch-S3 for Gtd

Fleeting

what 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.
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
      
  • 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.

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

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"]
      
  • 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.

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.

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.py draws 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.

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.

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.

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-halloween instead of screen-christmas puts 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 .scroll twin 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 .scroll twin 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 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.
    • 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 ».

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.

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.

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.

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

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.

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 on ERRORS.
      • why: « 2 »: a charge is no error, so it has its own channel and the phone logs it as « watch charge ».

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.

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.

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.log must 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.log must 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.log itself, 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.log must 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.

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.bin must 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.

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.log must 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.log must 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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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 ::1 and 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.
  • scope: a project shipping an s3 target has not been shown to reach what this board adds.
  • scope: an s3 config reaches the plus where the parts coincide — the amplifier by name, and the microphone by both being pdm2, 4.
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.
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.h the 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;
})
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 modprobe gone 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"
      '';
    })
  ];
}
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.py where 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"))})
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.sh puts 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_CHECK written check_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: 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, as gap_connect sets 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_params does.
    • 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 ? »

--- 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, &params));
+}
+
 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.sh puts 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.bin holds, 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.py it 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 cp says 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.
  • 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.

#!/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.py then follows the plug.
  • why: the chip’s light sleep kills its usb port, so an update over usb fails.
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 in device/, 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 running main.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 in phone.txt, its light’s log in light.log and its hourly charge in charge.log on 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_HOST names it otherwise, as TWATCH_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_S seconds is out of reach, said in one line, naming the bar’s « W » to tap, before anything is sent.

#!/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.sh reads the watch’s charge and voltage, keeps them with the time in battery-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
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
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” »

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.txt across 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
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.log on the flash, the last 4 KB kept, and the watch restarts; at each link, the file goes to the phone on ERRORS, 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 ERRORS read 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.

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 »

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; big gives the two codes of a half.
how
on the desk, icons-preview.py draws the doubled ones four times bigger into a png, from Big as 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 .old and 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, its BACKDROP, the rectangles drawn once, its WORDS written once on them, those of each frame from the one before, its PARTS named for the stories, and outside; 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 .recalls twin, 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 .tints twin; 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 .bar twin; a recall’s first row goes to the .fresh twin as « fresh … », then « seen » once a tap or press clears it; the light level goes to the .light twin.
  • 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 .picture twin, 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 .scene twin 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 .media twin as « media … », and each step of a long one’s scroll to the .title twin as « row … ».
  • how: it darkens as the screen does, 5 s after its last wake, a minute while staying lit, writing « dark » to the .dark twin.

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
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 reset starts a night, oxenhead-share.sh reads it.
  • how: the whole dump lands under battery/; only the poc’s estimate, its ble scans and its wake locks are printed.

#!/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
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.I2S into 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 stop says so, looked at each tenth of a second, or until the cap, 3 s by default; tick hears 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 until stop says 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.

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 on VOICE; a recall is written on RECALL, 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; SAVED is written once the phone has saved a recording; the REPL’s input is written on REPL_IN, its output notified on REPL_OUT; the phone’s local time is written on TIME as 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 on MEDIA, one byte: 1 back, 2 play or pause, 3 skip; each line of the errors kept goes out as a notification on ERRORS, cut to a packet; the charge goes out the same way on CHARGE.
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 PICTURE piles 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_IN is fed to os.dupterm, and what the REPL prints goes back on REPL_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 says READY with 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, or CHARGE, before it says READY, 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 READY with 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.

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 SAVED and stays linked for the next; when it loses the watch, or bluetooth comes back on, android’s STATE_ON, it looks for it again.
    • why: on STATE_ON, able does not yet take a scan, « BLE adapter is not enabled »; it waits ADAPTER_WAIT_S first.
    • why: android’s error at bluetooth’s off makes able swap its ble for one that refuses every call, for good; on STATE_ON the companion puts back the ble it kept at its start.
  • 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_S after it began looking, it drops its scan and any half-made link, and looks again.
    • how: before looking again it pauses, SCAN_PAUSE_S after the first miss, twice longer after each next one, at most SCAN_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 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, READY with 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_S is sent again, and after STEP_TRIES the 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 MEDIA drives 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.wav and 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.ogg at 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: « 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 RECALL one 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: 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 on REPL_OUT goes back out.
    • how: on each new client, it first stops the watch’s main.py and 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: « 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.
  • how: each line the watch sends on ERRORS is said as « watch error: … », its notifications asked for before READY.
    • why: « log excep(ions somewhere boot resistant and at next lçnk send to phone and purge »
  • how: each line on CHARGE is 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: 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.

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 oschandler takes.
    • why: that wakelock lights the screen; one per line kept oxenhead’s lit 5 min in 21 while linked.

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.py but 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/"

  1. « 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.I2S class 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. ↩︎ ↩︎ ↩︎

  2. « 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. ↩︎ ↩︎ ↩︎

  3. « 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. ↩︎ ↩︎ ↩︎

  4. 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. ↩︎ ↩︎

  5. « 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. ↩︎

  6. « 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. ↩︎ ↩︎

  7. « 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. ↩︎

  8. « 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. ↩︎

  9. « 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. ↩︎

  10. « 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 è. ↩︎

  11. « 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. ↩︎

  12. « #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. ↩︎

  13. « #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. ↩︎

  14. « #define XPOWERS_AXP2101_STATUS2 (0x01) » — AXP2101Constants.h; « return (readRegister(XPOWERS_AXP2101_STATUS2) >> 5) == 0x01; », in isCharging — XPowersAXP2101.hpp. ↩︎

  15. « // 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. ↩︎

  16. 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. ↩︎ ↩︎

  17. « 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. ↩︎ ↩︎ ↩︎ ↩︎

  18. « Allows an application to collect battery statistics », « Protection level: signature|privileged|development » — android’s Manifest.permission. ↩︎