From: rush Date: Tue, 4 Aug 2026 20:18:18 +0000 (-0400) Subject: Add the emom_face X-Git-Url: https://git.earman.xyz/?a=commitdiff_plain;h=refs%2Fheads%2Fmain;p=sensor-watch.git Add the emom_face --- diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9308445 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,99 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +Second Movement is a refactor of the Movement firmware for [Sensor Watch](https://www.sensorwatch.net), an ARM Cortex-M0+/M4 based digital watch platform. The firmware runs a rotation of "watch faces" (clock, timer, sensor readouts, games) on top of a shared event loop called Movement. + +## Setup + +Fetch submodules before building: + +``` +git submodule update --init --recursive +``` + +Submodules: `tinyusb` (USB stack), `littlefs` (on-watch filesystem), `gossamer` (board/chip definitions and the actual build rules), `utz` (timezone database). The root Makefile mostly declares includes and sources, then hands off to `gossamer/make.mk` and `gossamer/rules.mk` for compiling, linking, and packaging. + +Hardware builds also need the GNU Arm Embedded Toolchain (`gcc-arm-none-eabi`). Simulator builds need [emscripten](https://emscripten.org/). + +## Build commands + +``` +make BOARD=board_type DISPLAY=display_type +``` + +`BOARD` is one of: `sensorwatch_pro`, `sensorwatch_green`, `sensorwatch_red` (Sensor Watch Lite), `sensorwatch_blue`. Board definitions live under `gossamer/boards/`, each mapped to a chip in `gossamer/chips/` (samd11/samd21/samd51/saml21/saml22). + +`DISPLAY` is `classic` or `custom` (`autodetect` exists but is experimental and unreliable). Both `BOARD` and `DISPLAY` are required except for `make clean` and `make install`. + +Other variables: + +| Variable | Effect | +|-------------------|-------------------------------------------------------------------------| +| `TIMESET=minute` | Set default firmware time to the PC's year/month/day/hour/minute | +| `TIMESET=day` | Set default firmware time to the PC's year/month/day | +| `TIMESET=year` | Set default firmware time to the PC's year | +| `NOSLEEP=1` | Forbid low energy mode (`MOVEMENT_LOW_ENERGY_MODE_FORBIDDEN`) | +| `DFU=1` | Build a `.dfu` image instead of `.uf2`, and make `make install` use dfu-util | + +Other targets: + +- `make install` builds a UF2/DFU image and installs it (UF2: copies to the `WATCHBOOT` USB drive; DFU: uses `dfu-util`). Manual alternative: copy `build/firmware.uf2` to the watch yourself. +- `make clean` removes the `build/` directory. +- `make size` (runs automatically after `all`) prints firmware size. +- `make analyze` runs the `cobra` static analyzer over the sources. + +There is no unit test suite in this repository. CI (`.github/workflows/build.yml`) validates by building every BOARD x DISPLAY combination for both hardware and simulator targets; treat "builds cleanly across that matrix" as the correctness bar for firmware-wide changes. + +## Simulator + +``` +emmake make BOARD=sensorwatch_red DISPLAY=classic +python3 -m http.server -d build-sim +``` + +Visit `http://localhost:8000/firmware.html`. Useful for trying out a watch face change without hardware. + +## Adding or removing a watch face + +A face is enabled in three places, and generating a new one from the template touches two of them automatically: + +1. `movement_faces.h`: one `#include` per face header. +2. `watch-faces.mk`: one source file line per face `.c` file. +3. `movement_config.h`: the `watch_faces[]` array. Order here is the on-watch rotation order. `MOVEMENT_SECONDARY_FACE_INDEX` marks the cutoff: faces from that index onward are excluded from normal rotation and reachable only via long-press of Mode from the first face (this is manual; you edit it yourself). + +To scaffold a new face from `template/template.c` / `template/template.h`: + +``` +cd template +python3 watch_face.py [--author-name Your Name] +``` + +This writes `watch-faces//_face.{c,h}` from the template, and inserts the include line into `movement_faces.h` and the source line into `watch-faces.mk`. You still have to add the new face to `watch_faces[]` in `movement_config.h` by hand. + +## Architecture + +**Movement (`movement.c`/`movement.h`)** is the app framework every watch face runs under. A face is a `watch_face_t`: a struct of five function pointers (`setup`, `activate`, `loop`, `resign`, and the optional `advise`). Movement calls `loop` with a `movement_event_t` (tick, button up/down/long-press per button, activate, timeout, low energy update, background task, accelerometer/tap events). A face's `loop` handler is expected to fall through unhandled events to `movement_default_loop_handler`, which supplies default Mode/Light button behavior. Movement also owns global state persisted across sleep in the RTC backup registers: `movement_settings_t` (clock mode, button sound/volume, LED color/duration, timeouts, timezone index), plus reserved registers for location and future use. The public `movement_*` API (in `movement.h`) is what a face uses to request tick frequency, schedule background tasks, play sounds/LED, and read/write settings and timezone/date-time. + +**`movement_config.h`** is the user-facing configuration point: the face rotation list, default settings values, and the custom hourly-chime tune selection (see `movement_custom_signal_tunes.h`). + +**`watch-faces/`** holds every face implementation, one `.c`/`.h` pair per face, grouped by category: `clock/`, `complication/` (by far the largest, most third-party features land here), `demo/`, `io/`, `sensor/`, `settings/`. + +**`watch-library/`** is the hardware abstraction layer, split three ways: +- `shared/`: code common to every build (buzzer/display helpers, `watch_utility`). +- `hardware/`: real SAMD peripheral drivers (RTC, ADC, I2C, SPI, GPIO, USB, deep sleep) used in normal firmware builds. +- `simulator/`: Emscripten/JS-backed equivalents used only when building with `emmake` for the browser simulator. + +**`gossamer/`** (submodule) supplies board and chip definitions and the low-level peripheral drivers, plus the actual `make.mk`/`rules.mk` rules the root Makefile includes to do the compiling, linking, and image packaging. + +**`filesystem/`** wraps `littlefs` for on-watch storage. **`utz/`** provides the timezone database used by `movement_get_timezone_offset*`. **`shell/`** is a USB CDC serial shell (`shell.c`, `shell_cmd_list.c`) for interacting with the watch over a terminal. + +**`lib/`** holds small vendored libraries used by individual faces: `sunriset` (sunrise/sunset calc), `TOTP` (sha1/sha256/sha512 + TOTP one-time codes), `base32`, `base64`, `chirpy_tx` (data-over-audio encoding, used by `chirpy_demo_face`). + +**`legacy/`** holds pre-refactor watch faces, alternate firmware variants (`alt_fw/`), and vendored libraries (`smallchesslib`, `vsop87`, `morsecalc`, `astrolib`) that have not been ported to the current `watch_face_t`/gossamer structure. Nothing in `legacy/` is referenced by `watch-faces.mk`, so it is not built by default; treat it as reference material for future porting, not live code. + +## Docs + +`Doxyfile` at the repo root configures Doxygen API doc generation. There is no `make` target for it; the `gh-pages` GitHub Actions workflow runs Doxygen directly and publishes `docs/` to the `gh-pages` branch on every push to `main`. diff --git a/movement_config.h b/movement_config.h index cf51319..ef2d469 100644 --- a/movement_config.h +++ b/movement_config.h @@ -31,6 +31,7 @@ const watch_face_t watch_faces[] = { clock_face, fast_stopwatch_face, countdown_face, + emom_face, alarm_face, voltage_face, totp_face, diff --git a/movement_faces.h b/movement_faces.h index 3c2f5b7..d4feee7 100644 --- a/movement_faces.h +++ b/movement_faces.h @@ -83,4 +83,5 @@ #include "tomato_face.h" #include "solar_time_face.h" #include "tide_face.h" +#include "emom_face.h" // New includes go above this line. diff --git a/watch-faces.mk b/watch-faces.mk index c9e4a4f..09620bd 100644 --- a/watch-faces.mk +++ b/watch-faces.mk @@ -7,6 +7,7 @@ SRCS += \ ./watch-faces/complication/alarm_face.c \ ./watch-faces/complication/advanced_alarm_face.c \ ./watch-faces/complication/countdown_face.c \ + ./watch-faces/complication/emom_face.c \ ./watch-faces/complication/stopwatch_face.c \ ./watch-faces/complication/fast_stopwatch_face.c \ ./watch-faces/complication/sunrise_sunset_face.c \ diff --git a/watch-faces/complication/emom_face.c b/watch-faces/complication/emom_face.c new file mode 100644 index 0000000..0089629 --- /dev/null +++ b/watch-faces/complication/emom_face.c @@ -0,0 +1,240 @@ +/* + * MIT License + * + * Copyright (c) 2024 Joseph Bryant + * Copyright (c) 2023 Konrad Rieck + * Copyright (c) 2022 Wesley Ellis + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#include +#include +#include "emom_face.h" +#include "watch.h" +#include "watch_utility.h" + +#define EMOM_DEFAULT_CYCLES 1 +#define EMOM_MAX_CYCLES 60 +#define EMOM_START_DELAY_SECONDS 5 +#define EMOM_CYCLE_SECONDS 60 + +static bool quick_ticks_running; + +static const int8_t emom_final_tune[] = { + BUZZER_NOTE_C7, 8, BUZZER_NOTE_REST, 2, + BUZZER_NOTE_E7, 8, BUZZER_NOTE_REST, 2, + BUZZER_NOTE_G7, 8, BUZZER_NOTE_REST, 2, + BUZZER_NOTE_C8, 16, + 0 +}; + +static void abort_quick_ticks(emom_state_t *state) { + if (quick_ticks_running) { + quick_ticks_running = false; + movement_request_tick_frequency(state->state == emom_state_setting ? 4 : 1); + } +} + +static inline void button_beep() { + // play a beep as confirmation for a button press (if applicable) + if (movement_button_should_sound()) watch_buzzer_play_note_with_volume(BUZZER_NOTE_C7, 50, movement_button_volume()); +} + +static void increment_total_cycles(emom_state_t *state) { + state->total_cycles = (state->total_cycles % EMOM_MAX_CYCLES) + 1; +} + +static void schedule_next_chime(emom_state_t *state, uint32_t seconds_from_now) { + uint32_t now = watch_utility_date_time_to_unix_time(movement_get_utc_date_time(), movement_get_current_timezone_offset()); + state->target_ts = now + seconds_from_now; + state->seconds_remaining = seconds_from_now; + watch_date_time_t target_dt = watch_utility_date_time_from_unix_time(state->target_ts, movement_get_current_timezone_offset()); + movement_schedule_background_task_for_face(state->watch_face_index, target_dt); +} + +static void start(emom_state_t *state) { + state->state = emom_state_running; + state->current_cycle = 0; + schedule_next_chime(state, EMOM_START_DELAY_SECONDS); +} + +static void abort_emom(emom_state_t *state) { + state->state = emom_state_reset; + state->current_cycle = 0; + movement_cancel_background_task_for_face(state->watch_face_index); +} + +static void draw(emom_state_t *state, uint8_t subsecond) { + char cycle_buf[4], total_buf[4], secs_buf[4]; + + // current cycle (HOURS slot) : total cycles (MINUTES slot), reusing the + // clock's hour:minute colon segment; SECONDS slot counts down to the + // next chime (60 down to 0 each cycle, 5 down to 0 before the first) + if (state->state == emom_state_running) { + sprintf(cycle_buf, "%2d", state->current_cycle); + sprintf(secs_buf, "%2d", state->seconds_remaining); + } else { + sprintf(cycle_buf, " "); + sprintf(secs_buf, " "); + } + sprintf(total_buf, "%-2d", state->total_cycles); + if (state->state == emom_state_setting && !quick_ticks_running && subsecond % 2) { + sprintf(total_buf, " "); + } + + watch_display_text(WATCH_POSITION_HOURS, cycle_buf); + watch_display_text(WATCH_POSITION_MINUTES, total_buf); + watch_display_text(WATCH_POSITION_SECONDS, secs_buf); +} + +void emom_face_setup(uint8_t watch_face_index, void ** context_ptr) { + (void) watch_face_index; + + if (*context_ptr == NULL) { + *context_ptr = malloc(sizeof(emom_state_t)); + emom_state_t *state = (emom_state_t *)*context_ptr; + memset(*context_ptr, 0, sizeof(emom_state_t)); + state->total_cycles = EMOM_DEFAULT_CYCLES; + state->state = emom_state_reset; + state->watch_face_index = watch_face_index; + } +} + +void emom_face_activate(void *context) { + (void) context; + watch_set_colon(); + movement_request_tick_frequency(1); + quick_ticks_running = false; +} + +bool emom_face_loop(movement_event_t event, void *context) { + emom_state_t *state = (emom_state_t *)context; + + switch (event.event_type) { + case EVENT_ACTIVATE: + watch_display_text_with_fallback(WATCH_POSITION_TOP, "EMM", "EM"); + draw(state, event.subsecond); + break; + case EVENT_TICK: + if (quick_ticks_running) { + if (HAL_GPIO_BTN_ALARM_read()) + increment_total_cycles(state); + else + abort_quick_ticks(state); + } + if (state->state == emom_state_running && state->seconds_remaining > 0) { + state->seconds_remaining--; + } + draw(state, event.subsecond); + break; + case EVENT_MODE_BUTTON_UP: + abort_quick_ticks(state); + movement_move_to_next_face(); + break; + case EVENT_LIGHT_BUTTON_UP: + switch (state->state) { + case emom_state_reset: + case emom_state_running: + movement_illuminate_led(); + break; + case emom_state_setting: + state->state = emom_state_reset; + movement_request_tick_frequency(1); + button_beep(); + break; + } + draw(state, event.subsecond); + break; + case EVENT_ALARM_BUTTON_UP: + switch (state->state) { + case emom_state_reset: + start(state); + button_beep(); + break; + case emom_state_running: + abort_emom(state); + button_beep(); + break; + case emom_state_setting: + increment_total_cycles(state); + button_beep(); + break; + } + draw(state, event.subsecond); + break; + case EVENT_ALARM_LONG_PRESS: + switch (state->state) { + case emom_state_reset: + // long press in reset mode enters settings + state->state = emom_state_setting; + movement_request_tick_frequency(4); + button_beep(); + break; + case emom_state_setting: + // long press in settings mode starts quick ticks for adjusting the cycle count + quick_ticks_running = true; + movement_request_tick_frequency(8); + break; + case emom_state_running: + // do nothing + break; + } + break; + case EVENT_ALARM_LONG_UP: + abort_quick_ticks(state); + break; + case EVENT_BACKGROUND_TASK: + state->current_cycle++; + if (state->current_cycle <= state->total_cycles) { + movement_play_alarm(); + schedule_next_chime(state, EMOM_CYCLE_SECONDS); + } else { + movement_play_sequence((int8_t *)emom_final_tune, BUZZER_PRIORITY_ALARM); + abort_emom(state); + } + draw(state, 0); + break; + case EVENT_TIMEOUT: + if (state->state == emom_state_setting) { + state->state = emom_state_reset; + movement_request_tick_frequency(1); + } + if (state->state != emom_state_running) { + movement_move_to_face(0); + } + break; + case EVENT_LOW_ENERGY_UPDATE: + // we will only get this if the timer is stopped. + if (!watch_sleep_animation_is_running()) watch_start_sleep_animation(1000); + break; + default: + movement_default_loop_handler(event); + break; + } + + return true; +} + +void emom_face_resign(void *context) { + emom_state_t *state = (emom_state_t *)context; + if (state->state == emom_state_setting) { + state->state = emom_state_reset; + } +} diff --git a/watch-faces/complication/emom_face.h b/watch-faces/complication/emom_face.h new file mode 100644 index 0000000..abc56d2 --- /dev/null +++ b/watch-faces/complication/emom_face.h @@ -0,0 +1,83 @@ +/* + * MIT License + * + * Copyright (c) 2024 Joseph Bryant + * Copyright (c) 2023 Konrad Rieck + * Copyright (c) 2022 Wesley Ellis + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#ifndef EMOM_FACE_H_ +#define EMOM_FACE_H_ + +/* + * EMOM TIMER face + * + * An "every minute, on the minute" interval timer. The only setting is the + * number of cycles (rounds) to run. + * + * - Press the alarm button (short press) to start. The first chime + * sounds 5 seconds later; after that, a chime sounds every 60 seconds, + * one per cycle. The final chime, after the last cycle, is a distinct + * sound from the regular per-cycle chimes. + * - Press the alarm button (short press) again while running to abort + * back to idle. There is no pause/resume. + * - Press and hold the alarm button while idle to enter setting mode. + * Short-press the alarm button to increment the cycle count (wraps + * 1-60); press and hold to fast-increment. Short-press the light + * button to commit and exit setting mode. + * - While running, the display shows "current cycle:total cycles", + * e.g. "2:4" when on the second of four cycles. + * + * Note: like countdown_face, this uses movement_schedule_background_task() + * to keep chiming while asleep or while another face is on-screen. + */ + +#include "movement.h" + +typedef enum { + emom_state_reset, + emom_state_setting, + emom_state_running, +} emom_face_state_t; + +typedef struct { + uint8_t total_cycles; // the only setting: number of EMOM rounds (1-60) + uint8_t current_cycle; // 0 = not started; 1..total_cycles while running + uint32_t target_ts; // unix timestamp of the next scheduled chime + uint8_t seconds_remaining; // seconds left until target_ts, ticked down for display + emom_face_state_t state; + uint8_t watch_face_index; +} emom_state_t; + +void emom_face_setup(uint8_t watch_face_index, void ** context_ptr); +void emom_face_activate(void *context); +bool emom_face_loop(movement_event_t event, void *context); +void emom_face_resign(void *context); + +#define emom_face ((const watch_face_t){ \ + emom_face_setup, \ + emom_face_activate, \ + emom_face_loop, \ + emom_face_resign, \ + NULL, \ +}) + +#endif // EMOM_FACE_H_