]> git.earman.xyz Git - sensor-watch.git/commitdiff
add Baby kicks face (#70)
authorGábor Nyéki <676249+gn0@users.noreply.github.com>
Sun, 3 Aug 2025 17:59:14 +0000 (13:59 -0400)
committerGitHub <noreply@github.com>
Sun, 3 Aug 2025 17:59:14 +0000 (13:59 -0400)
movement_faces.h
watch-faces.mk
watch-faces/complication/baby_kicks_face.c [new file with mode: 0644]
watch-faces/complication/baby_kicks_face.h [new file with mode: 0644]

index 230eae4645f139a7826230461de16d2f768a9658..fc43716d9f27621630ddcb169e373afe1214ddd2 100644 (file)
@@ -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"
index c0ed9bf3e53817b3e179821623cea02bfefd1d5b..906806d91859b110315e108f3f433584cbcb15eb 100644 (file)
@@ -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 (file)
index 0000000..bfbc075
--- /dev/null
@@ -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 <stdlib.h>
+#include <string.h>
+#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 (file)
index 0000000..c20a082
--- /dev/null
@@ -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, \
+})