--- /dev/null
+# 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`.
--- /dev/null
+/*
+ * 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;
+ }
+}
--- /dev/null
+/*
+ * 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_