]> git.earman.xyz Git - sensor-watch.git/commitdiff
Add the emom_face main
authorrush <iv@earman.xyz>
Tue, 4 Aug 2026 20:18:18 +0000 (16:18 -0400)
committerrush <iv@earman.xyz>
Tue, 4 Aug 2026 20:55:08 +0000 (16:55 -0400)
CLAUDE.md [new file with mode: 0644]
movement_config.h
movement_faces.h
watch-faces.mk
watch-faces/complication/emom_face.c [new file with mode: 0644]
watch-faces/complication/emom_face.h [new file with mode: 0644]

diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644 (file)
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 <clock|complication|demo|sensor|settings> <face_name> [--author-name Your Name]
+```
+
+This writes `watch-faces/<type>/<face_name>_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`.
index cf51319559c15c640a2bb00ccec18db21d400aec..ef2d4697849e4611716c6a39e5b62a03082a1f4a 100644 (file)
@@ -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,
index 3c2f5b715cdf8cd989e0bd797093db54d630bce4..d4feee78299d66187e07869dc40b832e07e66c6d 100644 (file)
@@ -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.
index c9e4a4f1bfb7d100b91f3424df867c2dfc8aa0ea..09620bd3a8c2fe3c037cf7008c6edbd51504392b 100644 (file)
@@ -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 (file)
index 0000000..0089629
--- /dev/null
@@ -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 <stdlib.h>
+#include <string.h>
+#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 (file)
index 0000000..abc56d2
--- /dev/null
@@ -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_