From 3d86e14f0540008d165e8bdb3311e33bd2c5b606 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Ny=C3=A9ki?= <676249+gn0@users.noreply.github.com> Date: Sun, 3 Aug 2025 13:59:14 -0400 Subject: [PATCH] add Baby kicks face (#70) --- movement_faces.h | 1 + watch-faces.mk | 1 + watch-faces/complication/baby_kicks_face.c | 439 +++++++++++++++++++++ watch-faces/complication/baby_kicks_face.h | 132 +++++++ 4 files changed, 573 insertions(+) create mode 100644 watch-faces/complication/baby_kicks_face.c create mode 100644 watch-faces/complication/baby_kicks_face.h diff --git a/movement_faces.h b/movement_faces.h index 230eae46..fc43716d 100644 --- a/movement_faces.h +++ b/movement_faces.h @@ -63,6 +63,7 @@ #include "tally_face.h" #include "probability_face.h" #include "ke_decimal_time_face.h" +#include "baby_kicks_face.h" #include "counter_face.h" #include "pulsometer_face.h" #include "interval_face.h" diff --git a/watch-faces.mk b/watch-faces.mk index c0ed9bf3..906806d9 100644 --- a/watch-faces.mk +++ b/watch-faces.mk @@ -38,6 +38,7 @@ SRCS += \ ./watch-faces/complication/kitchen_conversions_face.c \ ./watch-faces/complication/periodic_table_face.c \ ./watch-faces/clock/ke_decimal_time_face.c \ + ./watch-faces/complication/baby_kicks_face.c \ ./watch-faces/complication/counter_face.c \ ./watch-faces/complication/pulsometer_face.c \ ./watch-faces/complication/interval_face.c \ diff --git a/watch-faces/complication/baby_kicks_face.c b/watch-faces/complication/baby_kicks_face.c new file mode 100644 index 00000000..bfbc0756 --- /dev/null +++ b/watch-faces/complication/baby_kicks_face.c @@ -0,0 +1,439 @@ +/* + * MIT License + * + * Copyright (c) 2025 Gábor Nyéki + * + * 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 "baby_kicks_face.h" +#include "watch.h" +#include "watch_utility.h" + +static inline void _play_failure_sound_if_beep_is_on() { + if (movement_button_should_sound()) { + watch_buzzer_play_note(BUZZER_NOTE_E7, 10); + } +} + +static inline void _play_successful_increment_sound_if_beep_is_on() { + if (movement_button_should_sound()) { + watch_buzzer_play_note(BUZZER_NOTE_E6, 10); + } +} + +static inline void _play_successful_decrement_sound_if_beep_is_on() { + if (movement_button_should_sound()) { + watch_buzzer_play_note(BUZZER_NOTE_D6, 10); + } +} + +static inline void _play_button_sound_if_beep_is_on() { + if (movement_button_should_sound()) { + watch_buzzer_play_note(BUZZER_NOTE_C7, 10); + } +} + +/** @brief Predicate for whether the counter has been started. + */ +static inline bool _is_running(baby_kicks_state_t *state) { + return state->start > 0; +} + +/** @brief Gets the current time, and caches it for re-use. + */ +static inline watch_date_time_t *_get_now(baby_kicks_state_t *state) { + if (state->now.unit.year == 0) { + state->now = movement_get_local_date_time(); + } + + return &state->now; +} + +/** @brief Clears the current time. Should only be called at the end of + * `baby_kicks_face_loop`. + */ +static inline void _clear_now(baby_kicks_state_t *state) { + if (state->now.unit.year > 0) { + memset(&state->now, 0, sizeof(state->now)); + } +} + +/** @brief Calculates the number of minutes since the timer was started. + * @return If the counter has been started, then the number of full + * minutes that have elapsed. If it has not been started, then + * 255. + */ +static inline uint32_t _elapsed_minutes(baby_kicks_state_t *state) { + if (!_is_running(state)) { + return 0xff; + } + + watch_date_time_t *now = _get_now(state); + + return ( + watch_utility_date_time_to_unix_time(*now, 0) - state->start + ) / 60; +} + +/** @brief Predicate for whether the counter has started but run for too + * long. + */ +static inline bool _has_timed_out(baby_kicks_state_t *state) { + return _elapsed_minutes(state) > BABY_KICKS_TIMEOUT; +} + +/** @brief Determines what we should display based on `state`. Should + * only be called from `baby_kicks_face_loop`. + */ +static void _update_display_mode(baby_kicks_state_t *state) { + if (watch_sleep_animation_is_running()) { + state->mode = BABY_KICKS_MODE_LE_MODE; + } else if (!_is_running(state)) { + state->mode = BABY_KICKS_MODE_SPLASH; + } else if (_has_timed_out(state)) { + state->mode = BABY_KICKS_MODE_TIMED_OUT; + } else { + state->mode = BABY_KICKS_MODE_ACTIVE; + } +} + +/** @brief Starts the counter. + * @details Sets the start time which will be used to calculate the + * elapsed minutes. + */ +static inline void _start(baby_kicks_state_t *state) { + watch_date_time_t *now = _get_now(state); + uint32_t now_unix = watch_utility_date_time_to_unix_time(*now, 0); + + state->start = now_unix; +} + +/** @brief Resets the counter. + * @details Zeros out the watch face state and clears the undo ring + * buffer. Effectively sets `state->mode` to + * `BABY_KICKS_MODE_SPLASH`. + */ +static void _reset(baby_kicks_state_t *state) { + memset(state, 0, sizeof(baby_kicks_state_t)); + memset( + state->undo_buffer.stretches, + 0xff, + sizeof(state->undo_buffer.stretches) + ); +} + +/** @brief Records a movement. + * @details Increments the movement counter, and along with it, if + * necessary, the counter of one-minute stretches. Also adds + * the movement to the undo buffer. + */ +static inline void _increment_counts(baby_kicks_state_t *state) { + watch_date_time_t *now = _get_now(state); + uint32_t now_unix = watch_utility_date_time_to_unix_time(*now, 0); + + /* Add movement to the undo ring buffer. */ + state->undo_buffer.stretches[state->undo_buffer.head] = + state->stretch_count; + state->undo_buffer.head++; + state->undo_buffer.head %= sizeof(state->undo_buffer.stretches); + + state->movement_count++; + + if (state->stretch_count == 0 + || state->latest_stretch_start + 60 < now_unix) { + /* Start new stretch. */ + state->latest_stretch_start = now_unix; + state->stretch_count++; + } +} + +/** @brief Undoes the last movement. + * @details Decrements the movement counter and, if necessary, the + * counter of one-minute stretches. + * @return True if and only if there was a movement to undo. + */ +static inline bool _successfully_undo(baby_kicks_state_t *state) { + uint8_t latest_mvmt, pre_undo_stretch_count; + + /* The latest movement is stored one position before `.head`. */ + if (state->undo_buffer.head == 0) { + latest_mvmt = sizeof(state->undo_buffer.stretches) - 1; + } else { + latest_mvmt = state->undo_buffer.head - 1; + } + + pre_undo_stretch_count = + state->undo_buffer.stretches[latest_mvmt]; + + if (pre_undo_stretch_count == 0xff) { + /* Nothing to undo. */ + return false; + } else if (pre_undo_stretch_count < state->stretch_count) { + state->latest_stretch_start = 0; + state->stretch_count--; + } + + state->movement_count--; + + state->undo_buffer.stretches[latest_mvmt] = 0xff; + state->undo_buffer.head = latest_mvmt; + + return true; +} + +/** @brief Updates the display with the movement counts if the counter + * has been started. + */ +static inline void _display_counts(baby_kicks_state_t *state) { + if (!_is_running(state)) { + watch_display_text(WATCH_POSITION_BOTTOM, "baby "); + watch_clear_colon(); + } else { + char buf[7]; + + snprintf( + buf, + sizeof(buf), + "%2d%4d", + state->stretch_count, + state->movement_count + ); + + watch_display_text(WATCH_POSITION_BOTTOM, buf); + watch_set_colon(); + } +} + +/** @brief Updates the display with the number of minutes since the + * timer was started. + * @details If more than `BABY_KICKS_TIMEOUT` minutes have elapsed, + * then it displays "TO". + */ +static void _display_elapsed_minutes(baby_kicks_state_t *state) { + if (!_is_running(state)) { + watch_display_text(WATCH_POSITION_TOP_LEFT, " "); + watch_display_text(WATCH_POSITION_TOP_RIGHT, " "); + } else if (_has_timed_out(state)) { + watch_display_text(WATCH_POSITION_TOP_LEFT, "TO"); + watch_display_text(WATCH_POSITION_TOP_RIGHT, " "); + } else { + /* NOTE We display the elapsed minutes in two parts. This is + * because on the classic LCD, neither the "weekday digits" nor + * the "day digits" position is suitable to display the elapsed + * minutes: + * + * - The classic LCD cannot display 2, 4, 5, 6, or 9 as the last + * digit in the "weekday digits" position. + * - It cannot display any number greater than 3 as the first + * digit in the "day digits" position. + * + * As a workaround, we split the elapsed minutes into 30-minute + * "laps." The elapsed minutes in the current "lap" are shown + * in the "day digits" position. This is any number between 0 + * and 29. The elapsed minutes in past "laps" are shown in the + * "weekday digits" position. This is either nothing, 30, 60, + * or 90. + * + * The sum of the numbers shown in the two positions is equal to + * the total elapsed minutes. + */ + + char buf[3]; + uint32_t elapsed_minutes = _elapsed_minutes(state); + uint8_t multiple = elapsed_minutes / 30; + uint8_t remainder = elapsed_minutes % 30; + + if (multiple == 0) { + watch_display_text(WATCH_POSITION_TOP_LEFT, " "); + } else { + snprintf(buf, sizeof(buf), "%2d", multiple * 30); + watch_display_text(WATCH_POSITION_TOP_LEFT, buf); + } + + snprintf(buf, sizeof(buf), "%2d", remainder); + watch_display_text(WATCH_POSITION_TOP_RIGHT, buf); + } +} + +static void _update_display(baby_kicks_state_t *state) { + _display_counts(state); + _display_elapsed_minutes(state); +} + +static inline void _start_sleep_face() { + if (!watch_sleep_animation_is_running()) { + watch_display_text(WATCH_POSITION_TOP_LEFT, " "); + watch_display_text(WATCH_POSITION_TOP_RIGHT, " "); + watch_display_text(WATCH_POSITION_BOTTOM, "baby "); + + watch_clear_colon(); + + watch_start_sleep_animation(500); + } +} + +static inline void _stop_sleep_face() { + if (watch_sleep_animation_is_running()) { + watch_stop_sleep_animation(); + } +} + +void baby_kicks_face_setup(uint8_t watch_face_index, + void **context_ptr) { + (void) watch_face_index; + + if (*context_ptr == NULL) { + *context_ptr = malloc(sizeof(baby_kicks_state_t)); + _reset(*context_ptr); + } +} + +void baby_kicks_face_activate(void *context) { + (void) context; + + _stop_sleep_face(); +} + +void baby_kicks_face_resign(void *context) { + baby_kicks_state_t *state = (baby_kicks_state_t *)context; + + state->currently_displayed = false; +} + +bool baby_kicks_face_loop(movement_event_t event, void *context) { + baby_kicks_state_t *state = (baby_kicks_state_t *)context; + + switch (event.event_type) { + case EVENT_ACTIVATE: + state->currently_displayed = true; + _update_display_mode(state); + _update_display(state); + break; + case EVENT_ALARM_BUTTON_UP: /* Start or increment. */ + /* Update `state->mode` in case we have a running counter + * that has just timed out. */ + _update_display_mode(state); + + switch (state->mode) { + case BABY_KICKS_MODE_SPLASH: + _start(state); + _update_display_mode(state); + _update_display(state); + _play_button_sound_if_beep_is_on(); + break; + case BABY_KICKS_MODE_ACTIVE: + _increment_counts(state); + _update_display(state); + _play_successful_increment_sound_if_beep_is_on(); + break; + case BABY_KICKS_MODE_TIMED_OUT: + _play_failure_sound_if_beep_is_on(); + break; + case BABY_KICKS_MODE_LE_MODE: /* fallthrough */ + default: + break; + } + break; + case EVENT_ALARM_LONG_PRESS: /* Undo. */ + _update_display_mode(state); + + switch (state->mode) { + case BABY_KICKS_MODE_ACTIVE: + if (!_successfully_undo(state)) { + _play_failure_sound_if_beep_is_on(); + } else { + _update_display(state); + _play_successful_decrement_sound_if_beep_is_on(); + } + break; + case BABY_KICKS_MODE_SPLASH: /* fallthrough */ + case BABY_KICKS_MODE_TIMED_OUT: + _play_failure_sound_if_beep_is_on(); + break; + case BABY_KICKS_MODE_LE_MODE: /* fallthrough */ + default: + break; + } + break; + case EVENT_MODE_LONG_PRESS: /* Reset. */ + _update_display_mode(state); + + switch (state->mode) { + case BABY_KICKS_MODE_ACTIVE: /* fallthrough */ + case BABY_KICKS_MODE_TIMED_OUT: + _reset(state); + + /* This shows the splash screen because `_reset` + * sets `state->mode` to `BABY_KICKS_MODE_SPLASH`. + */ + _update_display(state); + + _play_button_sound_if_beep_is_on(); + break; + case BABY_KICKS_MODE_SPLASH: + _play_failure_sound_if_beep_is_on(); + break; + case BABY_KICKS_MODE_LE_MODE: /* fallthrough */ + default: + break; + } + break; + case EVENT_BACKGROUND_TASK: /* Update minute display. */ + _update_display_mode(state); + + switch (state->mode) { + case BABY_KICKS_MODE_ACTIVE: /* fallthrough */ + case BABY_KICKS_MODE_TIMED_OUT: + if (state->currently_displayed) { + _display_elapsed_minutes(state); + } + break; + case BABY_KICKS_MODE_LE_MODE: /* fallthrough */ + case BABY_KICKS_MODE_SPLASH: /* fallthrough */ + default: + break; + } + break; + case EVENT_LOW_ENERGY_UPDATE: + _start_sleep_face(); + break; + default: + movement_default_loop_handler(event); + break; + } + + _clear_now(state); + + return true; +} + +movement_watch_face_advisory_t baby_kicks_face_advise(void *context) { + movement_watch_face_advisory_t retval = { 0 }; + baby_kicks_state_t *state = (baby_kicks_state_t *)context; + + retval.wants_background_task = + state->mode == BABY_KICKS_MODE_ACTIVE; + + return retval; +} diff --git a/watch-faces/complication/baby_kicks_face.h b/watch-faces/complication/baby_kicks_face.h new file mode 100644 index 00000000..c20a082a --- /dev/null +++ b/watch-faces/complication/baby_kicks_face.h @@ -0,0 +1,132 @@ +/* + * MIT License + * + * Copyright (c) 2025 Gábor Nyéki + * + * 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. + */ + +#pragma once + +/* + * Baby kicks face + * + * Count the movements of your in-utero baby. + * + * Background: + * + * This practice is recommended particularly in the third trimester + * (from week 28 onwards). The exact recommendations vary as to how to + * count the baby's movements. Some recommend drawing a chart with the + * number of "kicks" within a 12-hour period: + * + * - https://en.wikipedia.org/wiki/Kick_chart + * + * Others recommend measuring the time that it takes for the baby to + * "kick" 10 times: + * + * - https://my.clevelandclinic.org/health/articles/23497-kick-counts + * - https://healthy.kaiserpermanente.org/health-wellness/health-encyclopedia/he.pregnancy-kick-counts.aa107042 + * + * (Of course, not every movement that the baby makes is a kick, and we + * are interested in all movements, not only kicks.) + * + * This watch face follows the latter set of recommendations. When you + * start the counter, it measures the number of elapsed minutes, and it + * tracks the number of movements as you increment the counter. Since + * some consecutive movements made by the baby are actually part of a + * longer maneuver, the watch face also displays the number of + * one-minute stretches in which the baby moved at least once. + * + * Usage: + * + * - ALARM button, short press: + * * start the counter if it isn't running + * * increment the count otherwise + * - ALARM button, long press: undo the last count + * - MODE button, long press: reset the count to zero + * + * The watch face displays two numbers in the "clock digits" positions: + * + * 1. Count of movements (in the "second" and "minute" positions). + * 2. Count of one-minute stretches in which at least one movement + * occurred (in the "hour" position). + * + * The number of elapsed minutes, up to and including 29, is shown in + * the "day digits" position. Due to the limitations of the classic LCD + * display, completed 30-minute intervals are shown in the "weekday + * digits" position. The total number of elapsed minutes is the sum of + * these two numbers. + * + * The watch face times out after 99 minutes, since it cannot display + * more than 99 one-minute stretches in the "hour" position. When this + * happens, the "weekday digits" position shows "TO". + */ + +#include "movement.h" + +typedef enum { + BABY_KICKS_MODE_SPLASH = 0, + BABY_KICKS_MODE_ACTIVE, + BABY_KICKS_MODE_TIMED_OUT, + BABY_KICKS_MODE_LE_MODE, +} baby_kicks_mode_t; + +/* Stop counting after 99 minutes. The classic LCD cannot display any + * larger number in the "weekday digits" position. */ +#define BABY_KICKS_TIMEOUT 99 + +/* Ring buffer to store and allow undoing up to 10 movements. */ +typedef struct { + /* For each movement in the undo buffer, this array stores the value + * of `state->stretch_count` right before the movement was + * recorded. This is used for decrementing `state->stretch_count` + * as part of the undo operation if necessary. */ + uint8_t stretches[10]; + + /* Index of the next available slot in `.stretches`. */ + uint8_t head; +} baby_kicks_undo_buffer_t; + +typedef struct { + bool currently_displayed; + baby_kicks_mode_t mode; + watch_date_time_t now; + uint32_t start; + uint32_t latest_stretch_start; + uint8_t stretch_count; /* Between 0 and `BABY_KICKS_TIMEOUT`. */ + uint16_t movement_count; /* Between 0 and 9999. */ + baby_kicks_undo_buffer_t undo_buffer; +} baby_kicks_state_t; + +void baby_kicks_face_setup(uint8_t watch_face_index, void **context_ptr); +void baby_kicks_face_activate(void *context); +bool baby_kicks_face_loop(movement_event_t event, void *context); +void baby_kicks_face_resign(void *context); +movement_watch_face_advisory_t baby_kicks_face_advise(void *context); + +#define baby_kicks_face ((const watch_face_t) { \ + baby_kicks_face_setup, \ + baby_kicks_face_activate, \ + baby_kicks_face_loop, \ + baby_kicks_face_resign, \ + baby_kicks_face_advise, \ +})