Skip to content

feat(app): round display preview for every knob app page - #242

Merged
tarakanof merged 9 commits into
mainfrom
feat/240-knob-previews
Oct 4, 2026
Merged

tarakanof merged 9 commits into
mainfrom
feat/240-knob-previews

Conversation

@tarakanof

@tarakanof tarakanof commented Oct 4, 2026 •

Copy link
Copy Markdown
Owner

Closes #240

Each knob app pane in Settings › Devices › Knob › Apps (Bot, Focus, Weather) now shows a live round 466 px preview of the knob's display, like PanelPreview does for the TC001. Pages shows every face in the knob's order, dimmed with "Off" when a page is off, and marks the home page.

Renderers (EmberKit KnobFace/, SwiftUI Canvas, ported from cinder's firmware)

  • Bot, in bot_view.c's Head style:
    • black body with a plain 6 px outline in the mood colour (×0.55 for idle and sleepy)
    • the outline's squash/stretch variant is picked by sy/sx, as the firmware does, so the mood-change pop pulses only the eyes
    • bot_shape.c curved triangle and round-capped eye strokes, the badge, and the host label in waiting/error
  • Pomodoro, as in pomo_view.c: the ring (radius 222, 12 px) drains clockwise from 12 o'clock. Covers paused (dim and blinking), parked "FOCUS NEXT", idle "--:--" / "PUSH TO START", OFFLINE, and the POMODORO OFF note.
  • Weather: the weather_face.c WMO/MET mapping with day/night, the weather_scene.c sprites, particles and bolt, and grey when stale.
  • Labels use bundled Montserrat Medium (SIL OFL 1.1, license file included), the font LVGL's built-in sizes are made from. They're placed by LVGL's line box (top, line height, baseline), so their positions match the firmware's.

Live data: GET /v1/devices/self/view takes a device token only, so the preview builds the same inputs from feeds the app already polls:

  • /state sessions, giving mood and host by the firmware's rules; the last mood stays while /state fails
  • /v1/pomodoro/state
  • /v1/weather/state, tracked while a preview is on screen
  • /v1/display/brightness, read as soon as "Follow Ember brightness" turns on

It also reads the draft knob settings, so toggling a page or a bot timing shows at once.

Animation

  • The bot uses BotBehavior with a new Tuning. .knob gives a 1 s hop at 75 % squash, every 4.5 s median, with the knob's sleepy delay. .mac is the default and stays identical to main, pinned by golden poses.
  • The bot redraws only when its pose changes: 20 fps while isAnimating, otherwise at nextEventAt, and equal quantized poses skip the Canvas.
  • Weather runs at the firmware's frame rate and the Pomodoro ticks once a second, both only while the pane is on screen.
  • Reduce Motion leaves only the blinks and holds the sky still.
  • The Pages overview has no timers.

Drift guard: macos/Sources/EmberKit/Resources/knob-theme.json holds everything the renderers share with the firmware: geometry, palette, label layout, LVGL font metrics, eye geometry, and the weather scene's sprite sizes, layout, particles and timing. The sprite outlines and the hop curve stay in code.

Snapshots: KnobFaceTests renders every face through ImageRenderer and checks pixels at firmware positions: rim colour, no glow, ring and track, sun, and the temperature centre at about y 262. KNOB_SNAPSHOT_DIR=<dir> swift test --package-path macos --filter knobFaces writes the PNGs.

knob previews

Verified

  • swift test: 794 pass.
  • The cinder parity test passes against cinder main, and fails when a theme value is changed.
  • xcodegen and an unsigned Release xcodebuild succeed, and the font and theme are in the EmberKit bundle.
  • scripts/strings.sh check passes.

This doesn't touch the Dashboard (#239) or KnobModel. Outside the new files, the app-side change is two lines in KnobDetail.swift.

The knob's C port hops for 1 s at 75 % squash every 4.5 s median and
takes its sleepy delay from the knob settings. A Tuning value lets the
Settings preview run the same behaviour; the default (.mac) keeps the
menu-bar and Dock bot bit-identical.
SwiftUI replicas of cinder's 466 px round pages (bot Head style,
Pomodoro bezel ring, procedural weather sky), ported from the firmware
so the app can preview a page the knob renders on-device. The numbers
both sides share live in one JSON resource; the tests pin them to the
firmware's values and render every face to PNG (KNOB_SNAPSHOT_DIR).
Settings › Knob › Apps shows each page's face like PanelPreview does
for the clock: live mood/host, Pomodoro, weather and brightness from
the feeds the app already polls (the view endpoint is device-token
only), dimmed with Off when the page is off. Pages shows all faces in
the knob's order.

Closes #240
The dimmed face let the bezel gradient show through as grey.
Rendered by KnobFaceTests (KNOB_SNAPSHOT_DIR) for review of #240.

@tarakanof tarakanof left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fidelity against cinder main (dc84b8e), then code. swift test --package-path macos passes (792 tests).

0. PR body is wrong. It holds cinder's provisioning PR text ("Closes #6", NVS, factory reset). #240 won't auto-close on merge, and the body describes nothing in this diff. Please replace it.

Fidelity (compared with the firmware source and docs/img/knob-previews.png)

  1. The rim has a glow the knob doesn't draw. KnobFaceRender.bot strokes the outline in a layer with .shadow(radius: rimPx*1.5, opacity 0.8). bot_view.c draws a plain 6 px AA ring; the glow was removed on purpose (header comment: "3-layer glow ~250 ms per refresh"). It's clearly visible on working/waiting/error/done in the contact sheet. Dropping it also removes the most expensive operation in each 20 fps frame (an offscreen layer plus a blur).
  2. The rim scales on the mood-change pop; the knob's doesn't. Swift maps the ring through the full pose (scaleX/scaleY), so the uniform pop scales the outline. Firmware picks an outline variant by the scale_y/scale_x ratio only ("Pop (uniform scale) is ignored"), so only the eyes pulse. Fix: build the outline transform from the squash ratio, not from the pop.
  3. Fonts. The firmware fonts are LVGL lv_font_montserrat_{14,24,48} built from Montserrat-Medium. The preview uses SF Medium at the same px sizes. Vertical placement matches within ~1 px for the Pomodoro time and the host label. The weather temperature sits about 5 px low (in 466 px space): firmware's label top is y 236 with a 52 px line and baseline 9, so the digit centre is about y 262; Swift centres the line box at 236 + 0.65×48 ≈ 267. Glyph shapes and widths differ too (SF digits are narrower, and --:-- renders as spaced dashes). Bundling Montserrat-Medium is worth it: OFL 1.1 allows embedding, it's one ~200 KB TTF registered from EmberKit's bundle (CTFontManagerRegisterFontsForURL), it needs only the OFL notice in Acknowledgements, and it makes text widths match the firmware's fixed label boxes (host 300, phase 300, time 260). If you'd rather keep SF, change the temperature offset to sky.maxY + lineHeight/2 using Montserrat metrics (≈ 26, not 31).
  4. Checked and matching: bot_shape.c (triangle table, R/sagitta/blur, ring, eye capsules, happy quadratic, REST_X 0.67), to_screen, FILL/HOP_SCALE/EYE_SCALE/RIM_PX, idle/sleepy rim gain 0.55, badge (0.98 / 0.3 / 0.22, rides with the hop), host label (Montserrat 24, centre CY + 0.8r, mood colour, waiting/error only), seed "EMBR". Pomodoro: r 222 / w 12, clockwise from 12 o'clock with round caps, track ×0.18, paused/parked ×0.45, every COL_*, label dy −6/46/−54, paused blink on odd seconds, OFFLINE / PUSH TO START / "X NEXT" / note override. Weather: every sprite size and stroke, cloud fill/outline, layout, periods, drop counts, bolt timing, still grey, the full palette, sky 200×140 at y 96. The renderers are a faithful port apart from items 1–3.
  5. BotBehavior.Tuning matches bot_behavior.h/.c: hop 1.0 s with the curve time-scaled by 0.62/1.0, squash 0.75 applied to sx/sy only (not dy), interval lognormal(4.5, 0.35, 2.5…9), sleep 300 s with 0 meaning never (→ .infinity), first waiting hop at +0.6 s.

Code

  1. The drift guard pins literals, not the firmware. knobThemePinsFirmwareConstants compares the JSON against numbers typed into the test, so nothing fails if cinder changes until cinder#34 lands. It also skips several values: hop.interval_sigma, triangle.blur_deg, badge.*, host.width_px, the pomo label dy_px/width_px, colors.other/idle_track/text/text_dim/note, and 12 of the 15 weather colours. Suggestion: pin every key, plus an opt-in test that parses the sibling ../cinder headers (#defines in bot_view.c, pomo_view.c, weather_scene.c) when the checkout exists, similar to how the knob vectors are shared.
  2. The "every shared number lives in knob-theme.json" claim (ARCHITECTURE.md, cinder#34) isn't true yet. KnobWeatherScene hard-codes sprite sizes, layout positions, star positions, drop counts, periods and bolt timing. KnobBotShape hard-codes the eye geometry. cinder#34's generator list omits the same values, so both sides can drift with no check. Either move these into the JSON (and into #34's list) or reword the docs to say only the theme numbers are shared.
  3. macTuningIsUnchanged is tautological. It compares init(seed:now:) with init(…, tuning: .mac), which run the same code, so wrong .mac values would still pass. Pin .mac == Tuning(sleepAfter: 300, hopLength: 0.62, hopSquash: 1, 15, 0.4, 8...40), or check a few poses against golden values from main. (The code paths themselves are equivalent: hop(u, squash: 1) returns hop(u), and cu == u when the lengths are equal.)
  4. Bot CPU use. TimelineView(.animation(minimumInterval: 1/20)) redraws every 50 ms even while the pose is static, which is most of the time between blinks and saccades. Each frame rebuilds the 192-point ring, the shadow layer and a Text resolve. BotBehavior already exposes isAnimating/nextEventAt (the firmware redraws only when bot_pose_same is false). Pausing when nothing is animating, or using a schedule driven by nextEventAt, plus caching the ring Path per (shape, mood), would cut most of that. Pausing on onAppear/onDisappear is fine, and Reduce Motion behaves as documented: bot blinks only, weather still.
  5. The docs say the Pages overview is static, but the Pomodoro tile still ticks. KnobPomoLive always runs .periodic(by: 1) and passes .now when animated == false, so it re-renders every second (paused blink included). Either gate the timeline on animated or reword the doc.
  6. Turning on "follow Ember" brightness waits up to 60 s. KnobPreviewFeeds sleeps 60 s per loop and checks followEmber only inside it. The firmware fetches immediately when it's turned on. Minor: key the .task(id:) on followEmber.
  7. Checked and fine: the mood/host composition from /state sessions (pickWinning priority waiting > error > running > done, newest wins; peers that disagree blank the host, as in legacyRender/#213 and ember_host_label; printable ASCII, 10 characters, uppercase). One small difference: when Ember is unreachable the knob keeps its last mood while the preview falls back to idle. Accessibility (one element with the title as label, caption as value, "Off." prefix, .isImage) and the string catalog entries (positional %1$@/%2$@, comments) look good.

Review of #242: the outline had a glow and scaled with the pop, while
the firmware draws a plain 6 px ring and picks a squash variant by
sy/sx. Labels now use Montserrat Medium (OFL, bundled) placed by LVGL's
line box, which also puts the temperature at the firmware's y. Eye
geometry and the weather scene's sizes, layout and timing move into
knob-theme.json; every key is pinned, and a test checks each against
cinder's source when a checkout is next to this repo.
A 20 fps timeline redrew between blinks too. The schedule now follows
BotBehavior: frames while it animates, else the next scheduled event,
and equal quantized poses skip the Canvas. The Pomodoro tile only ticks
when animated, so the Pages overview has no timers.
Follow Ember brightness is read as soon as it turns on instead of after
up to a minute, and a failed /state keeps the last mood instead of
falling back to idle, as the firmware does.
@tarakanof

Copy link
Copy Markdown
Owner Author

Review fixes, pushed in bcba5b2..1ce7c30:

  • 0: Replaced the PR body; it now describes this PR and says Closes feat(app): round display preview for every knob app (like the TC001 panel previews) #240.
  • 1: The rim glow is gone. The outline is a plain 6 px stroke, and a test checks that the pixels just outside the rim are black.
  • 2: The outline uses the firmware's 11 squash/stretch variants, picked by sy/sx (rimVariant). The pop no longer scales the rim; only the eyes pulse. The paths are precomputed per shape and variant (KnobFaceRender.rimPaths), and the hop offset is rounded to even pixels like the firmware's.
  • 3: Montserrat Medium is now bundled, with the OFL file next to it and a note in macos/README.md. Fonts are registered via Core Text. Labels are placed by LVGL's line box (metrics 14: 16/3, 24: 27/5, 48: 52/9, all in the theme). A test checks the temperature digits centre at about y 262. --:-- now renders with Montserrat's glyphs.
  • 6: KnobThemeTests pins all 193 keys. themeMatchesCinderSource parses the cinder source (when ../cinder exists or CINDER_DIR is set; it skips otherwise) and fails if a theme key has no firmware check. It passes against cinder main, and fails when a value is changed.
  • 7: Eye geometry and the weather scene's sprite sizes, fps, drops, alphas, particles, stars, bolt and layout are now in the JSON. ARCHITECTURE now names what stays in code: the sprite outlines and the hop curve. The full key list is posted on feat(faces): rain, wind, sun, air and next-days faces, bot weather moments cinder#34.
  • 8: .mac is pinned to literal values, plus 6 golden poses computed from BotBehavior.swift on main.
  • 9: The bot runs on a TimelineSchedule driven by isAnimating/nextEventAt, and KnobFaceView is Equatable with quantized poses.
  • 10: The Pomodoro only ticks when animated, so the overview has no timers. The docs say it redraws on polled changes.
  • 11: The brightness task is keyed on followEmber and fetches as soon as it turns on.
  • 12: The last good mood is kept while /state fails.

Verified: swift test (794 pass), an unsigned Release build (the font and theme are in the EmberKit bundle), and strings.sh check. The contact sheet has been re-rendered.

@tarakanof
tarakanof merged commit 1b4b978 into main Oct 4, 2026
3 checks passed
@tarakanof
tarakanof deleted the feat/240-knob-previews branch October 4, 2026 20:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(app): round display preview for every knob app (like the TC001 panel previews)

1 participant