Files
Sensor-Watch/watch-faces/complication/hydration_face.c
T
Konrad Rieck 705e4be13a Hydration watch face
This watch face monitors daily hydration by tracking and logging water intake.

In tracking mode: Display current water intake and percentage
- Alarm button: +100ml (or configured glass size)
- Light button: -100ml (or configured glass size)
- Alarm long press: Display deviation from estimate
- Light long press: Switch to settings mode
- Alarm really long press: Switch to log mode

In settings mode: Configure glass size, daily goal, wake time, sleep time, alert interval
- Light button: Switch to next setting
- Alarm button: Advance current setting
- Alarm long press: Reset to default value
- Mode button: Switch to tracking mode

In log mode: Display log entries of water intake, date and deviation from goal
- Alarm button: Switch to next log entry
- Light button: Cycle through intake, date and deviation
- Mode button: Switch to tracking mode

Background tasks:
- Automatic reset at wake time
- Alert if intake below estimate at sleep time
- Alert at interval if intake below estimate, waking the watch if asleep
2026-07-27 21:47:04 +02:00

720 lines
23 KiB
C

/*
* MIT License
*
* Copyright (c) 2025 Konrad Rieck
*
* 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 "hydration_face.h"
#include "watch_slcd.h"
#include "watch_utility.h"
/* Display frequency */
#define DISPLAY_FREQUENCY 1
/* Maximum possible water intake (9.9l) */
#define MAX_WATER_INTAKE 9900
/* Settings */
#define NUM_SETTINGS 5
/* Default values */
#define DEFAULT_WATER_GLASS_ML 100
#define DEFAULT_WATER_GOAL_ML 1600
#define DEFAULT_WAKE_HOUR 7
#define DEFAULT_SLEEP_HOUR 22
#define DEFAULT_ALERT_INTERVAL 2
static void _display_water_ml(uint16_t water_ml)
{
char buf[10], *unit = "ml";
if (watch_get_lcd_type() != WATCH_LCD_TYPE_CUSTOM) {
unit = "nl"; /* nl looks more like ml on the original LCD */
}
snprintf(buf, sizeof(buf), "%4d%s", water_ml, unit);
watch_display_text_with_fallback(WATCH_POSITION_BOTTOM, buf, buf);
}
static void _settings_title_display(hydration_state_t *state, char *buf1, char *buf2)
{
char buf[10];
watch_display_text_with_fallback(WATCH_POSITION_TOP, buf1, buf2);
if (watch_get_lcd_type() != WATCH_LCD_TYPE_CUSTOM) {
snprintf(buf, sizeof(buf), "%2d", state->settings_page + 1);
watch_display_text_with_fallback(WATCH_POSITION_TOP_RIGHT, buf, buf);
}
if (state->alert_enabled) {
watch_set_indicator(WATCH_INDICATOR_SIGNAL);
} else {
watch_clear_indicator(WATCH_INDICATOR_SIGNAL);
}
}
static bool _settings_blink(uint8_t subsecond)
{
if (subsecond % 2 == 0) {
watch_display_text_with_fallback(WATCH_POSITION_BOTTOM, " ", " ");
return true;
}
return false;
}
static void _settings_water_glass_display(void *context, uint8_t subsecond)
{
hydration_state_t *state = (hydration_state_t *) context;
_settings_title_display(state, "GLASS", "GL");
if (_settings_blink(subsecond))
return;
/* First setting so clear colon and indicators */
watch_clear_colon();
watch_clear_indicator(WATCH_INDICATOR_24H);
watch_clear_indicator(WATCH_INDICATOR_PM);
_display_water_ml(state->water_glass);
}
static void _settings_water_glass_advance(void *context)
{
hydration_state_t *state = (hydration_state_t *) context;
state->water_glass += 100;
if (state->water_glass > 1000) {
state->water_glass = 100;
}
}
static void _settings_water_goal_display(void *context, uint8_t subsecond)
{
hydration_state_t *state = (hydration_state_t *) context;
_settings_title_display(state, "GOAL ", "GO");
if (_settings_blink(subsecond))
return;
_display_water_ml(state->water_goal);
}
static void _settings_water_goal_advance(void *context)
{
hydration_state_t *state = (hydration_state_t *) context;
state->water_goal += state->water_glass;
if (state->water_goal > 3000) {
state->water_goal = 100;
}
}
static void _settings_wake_time_display(void *context, uint8_t subsecond)
{
char buf[10];
hydration_state_t *state = (hydration_state_t *) context;
_settings_title_display(state, "WAKE ", "WK");
if (_settings_blink(subsecond))
return;
/* Start of time settings, set colon and check 24h indicator */
watch_set_colon();
uint8_t hour = state->wake_hour;
if (movement_clock_mode_24h()) {
watch_set_indicator(WATCH_INDICATOR_24H);
} else {
if (state->wake_hour > 12) {
watch_set_indicator(WATCH_INDICATOR_PM);
hour = hour % 12;
} else {
watch_clear_indicator(WATCH_INDICATOR_PM);
}
}
snprintf(buf, sizeof(buf), "%02d00", hour);
watch_display_text_with_fallback(WATCH_POSITION_BOTTOM, buf, buf);
}
static void _settings_wake_time_advance(void *context)
{
hydration_state_t *state = (hydration_state_t *) context;
state->wake_hour += 1;
if (state->wake_hour >= 24) {
state->wake_hour = 0;
}
}
static void _settings_sleep_time_display(void *context, uint8_t subsecond)
{
char buf[10];
hydration_state_t *state = (hydration_state_t *) context;
_settings_title_display(state, "SLEEP", "SL");
if (_settings_blink(subsecond))
return;
uint8_t hour = state->sleep_hour;
if (movement_clock_mode_24h()) {
watch_set_indicator(WATCH_INDICATOR_24H);
} else {
if (state->sleep_hour >= 12) {
watch_set_indicator(WATCH_INDICATOR_PM);
hour = hour % 12;
} else {
watch_clear_indicator(WATCH_INDICATOR_PM);
}
}
snprintf(buf, sizeof(buf), "%02d00", hour);
watch_display_text_with_fallback(WATCH_POSITION_BOTTOM, buf, buf);
}
static void _settings_sleep_time_advance(void *context)
{
hydration_state_t *state = (hydration_state_t *) context;
state->sleep_hour += 1;
if (state->sleep_hour >= 24) {
state->sleep_hour = 0;
}
}
static void _settings_alert_interval_display(void *context, uint8_t subsecond)
{
char buf[10];
hydration_state_t *state = (hydration_state_t *) context;
_settings_title_display(state, "INTER", "IN");
if (_settings_blink(subsecond))
return;
/* No time setting so clear colon and indicators */
watch_clear_colon();
watch_clear_indicator(WATCH_INDICATOR_24H);
watch_clear_indicator(WATCH_INDICATOR_PM);
if (state->alert_interval == 0) {
snprintf(buf, sizeof(buf), " off ");
} else {
snprintf(buf, sizeof(buf), " %2dh ", state->alert_interval);
}
watch_display_text_with_fallback(WATCH_POSITION_BOTTOM, buf, buf);
}
static void _settings_alert_interval_advance(void *context)
{
hydration_state_t *state = (hydration_state_t *) context;
state->alert_interval += 1;
if (state->alert_interval > 6) {
state->alert_interval = 0;
}
}
static void _hydration_start_sleep_animation(void)
{
if (!watch_sleep_animation_is_running()) {
watch_start_sleep_animation(500);
watch_start_indicator_blink_if_possible(WATCH_INDICATOR_COLON, 500);
}
}
static void _hydration_stop_sleep_animation(void)
{
if (watch_sleep_animation_is_running()) {
watch_stop_sleep_animation();
watch_stop_blink();
}
}
/* Play beep sound */
static inline void _beep()
{
if (!movement_button_should_sound())
return;
watch_buzzer_play_note(BUZZER_NOTE_C7, 50);
}
/* Calculate expected intake */
static uint16_t _get_expected_intake(hydration_state_t *state, uint8_t hours_since_wake)
{
uint8_t day_hours = (state->sleep_hour + 24 - state->wake_hour) % 24;
uint16_t expected_intake = (state->water_goal * hours_since_wake) / day_hours;
return expected_intake;
}
static void _log_intake(hydration_state_t *state, uint32_t ts)
{
if (state->water_intake == 0)
return;
state->log_head = (state->log_head + HYDRATION_LOG_ENTRIES - 1) % HYDRATION_LOG_ENTRIES;
hydration_log_t *log = &state->log[state->log_head];
/* Log entry (intake / 100) and date (ts / 86400) */
log->water_intake = state->water_intake / 100;
log->date = ts / 86400;
}
static void _tracking_display(hydration_state_t *state)
{
char buf[4];
watch_display_text_with_fallback(WATCH_POSITION_TOP_LEFT, "HYDRA", "Hy");
if (state->alert_enabled) {
watch_set_indicator(WATCH_INDICATOR_SIGNAL);
} else {
watch_clear_indicator(WATCH_INDICATOR_SIGNAL);
}
if (!state->display_deviation) {
/* Display current intake in bottom */
_display_water_ml(state->water_intake);
/* Display percentage in top right */
uint8_t percent = (state->water_intake * 10) / state->water_goal;
snprintf(buf, sizeof(buf), "%2d", percent);
watch_display_text_with_fallback(WATCH_POSITION_TOP_RIGHT, buf, buf);
} else {
watch_date_time_t now = movement_get_local_date_time();
uint16_t hours_since_wake = (now.unit.hour + 24 - state->wake_hour) % 24;
uint16_t expected_intake = _get_expected_intake(state, hours_since_wake);
int16_t deviation = state->water_intake - expected_intake;
/* Display absolute deviation from expected intake */
_display_water_ml(abs(deviation));
/* Display sign in top right */
snprintf(buf, sizeof(buf), "%s", deviation >= 0 ? " +" : " -");
watch_display_text_with_fallback(WATCH_POSITION_TOP_RIGHT, buf, buf);
}
}
#define _log_next_entry(state) _log_find_entry(state, +1)
#define _log_prev_entry(state) _log_find_entry(state, -1)
static uint8_t _log_find_entry(hydration_state_t *state, int direction)
{
uint8_t start = state->log_index;
uint8_t step = (direction > 0) ? 1 : (HYDRATION_LOG_ENTRIES - 1);
uint8_t idx = (start + step) % HYDRATION_LOG_ENTRIES;
while (idx != start) {
if (state->log[idx].date != 0)
return idx;
idx = (idx + step) % HYDRATION_LOG_ENTRIES;
}
// If no other non-empty entries found, just return current
return start;
}
static void _log_display(hydration_state_t *state)
{
char buf[10];
int16_t deviation;
watch_display_text_with_fallback(WATCH_POSITION_TOP_LEFT, "LOG", "LG");
hydration_log_t *log = &state->log[state->log_index];
if (log->date == 0) {
watch_display_text_with_fallback(WATCH_POSITION_BOTTOM, "no dat", "no dat");
return;
}
/* Calculate position in ring buffer (1 = most recent) */
uint8_t distance = (state->log_index - state->log_head + HYDRATION_LOG_ENTRIES) % HYDRATION_LOG_ENTRIES;
uint8_t index = distance + 1;
switch (state->log_type) {
case HYDRATION_LOG_INTAKE:
snprintf(buf, sizeof(buf), "%2d", index);
watch_display_text_with_fallback(WATCH_POSITION_TOP_RIGHT, buf, buf);
_display_water_ml(log->water_intake * 100);
break;
case HYDRATION_LOG_DEVIATION:
deviation = log->water_intake * 100 - state->water_goal;
snprintf(buf, sizeof(buf), "%s", deviation >= 0 ? " +" : " -");
watch_display_text_with_fallback(WATCH_POSITION_TOP_RIGHT, buf, buf);
_display_water_ml(abs(deviation));
break;
case HYDRATION_LOG_DATE:
snprintf(buf, sizeof(buf), "%2d", index);
watch_display_text_with_fallback(WATCH_POSITION_TOP_RIGHT, buf, buf);
watch_date_time_t now = watch_utility_date_time_from_unix_time(log->date * 86400, 0);
snprintf(buf, sizeof(buf), "%02d%02d%02d", now.unit.day, now.unit.month, now.unit.year + 20);
watch_display_text_with_fallback(WATCH_POSITION_BOTTOM, buf, buf);
break;
}
}
static void _switch_to_tracking(hydration_state_t *state)
{
movement_request_tick_frequency(DISPLAY_FREQUENCY);
state->page = PAGE_HYDRATION_TRACKING;
/* No colon or indicators in tracking mode */
watch_clear_colon();
watch_clear_indicator(WATCH_INDICATOR_24H);
watch_clear_indicator(WATCH_INDICATOR_PM);
/* Display tracking data */
_tracking_display(state);
}
static void _switch_to_settings(hydration_state_t *state)
{
movement_request_tick_frequency(4);
state->page = PAGE_HYDRATION_SETTINGS;
state->settings_page = HYDRATION_SETTING_WATER_GLASS;
/* Display current settings */
state->settings[state->settings_page].display(state, 0);
}
static void _switch_to_log(hydration_state_t *state)
{
movement_request_tick_frequency(DISPLAY_FREQUENCY);
state->page = PAGE_HYDRATION_LOG;
/* Set index to entry before tail */
state->log_index = state->log_head;
state->log_type = HYDRATION_LOG_INTAKE;
/* Display log data */
_log_display(state);
}
static void _check_hydration_alert(hydration_state_t *state)
{
watch_date_time_t now = movement_get_local_date_time();
/* Check if time is between sleep and wake */
uint8_t hour = now.unit.hour;
if (state->sleep_hour == state->wake_hour) {
/* Disable alerts completely */
return;
} else if (state->sleep_hour < state->wake_hour) {
/* Sleep time not over midnight */
if (hour > state->sleep_hour && hour <= state->wake_hour) {
return;
}
} else {
/* Sleep time crosses midnight */
if (hour > state->sleep_hour || hour <= state->wake_hour) {
return;
}
}
/* Check for alert at sleep time */
bool due = (hour == state->sleep_hour);
/* Check for alert at interval */
uint8_t hours_since_wake = (hour + 24 - state->wake_hour) % 24;
due |= (state->alert_interval > 0 && hours_since_wake % state->alert_interval == 0);
if (!due)
return;
uint16_t expected_intake = _get_expected_intake(state, hours_since_wake);
if (state->water_intake < expected_intake) {
/* This runs from the background task, regardless of which face is
* currently on screen, so request the wake here rather than relying
* on this face's own EVENT_LOW_ENERGY_UPDATE handler to catch it. */
movement_request_wake();
movement_move_to_face(state->face_index);
movement_play_alarm();
}
}
static bool _tracking_loop(movement_event_t event, void *context)
{
hydration_state_t *state = (hydration_state_t *) context;
switch (event.event_type) {
case EVENT_ACTIVATE:
watch_clear_colon();
_tracking_display(state);
break;
case EVENT_TICK:
if (state->display_deviation > 0) {
state->display_deviation--;
}
_tracking_display(state);
break;
case EVENT_ALARM_BUTTON_UP:
/* Prevent overflow of water intake */
if (state->water_intake + state->water_glass > MAX_WATER_INTAKE) {
state->water_intake = MAX_WATER_INTAKE;
} else {
state->water_intake += state->water_glass;
}
_tracking_display(state);
_beep();
break;
case EVENT_LIGHT_BUTTON_UP:
/* Prevent underflow of water intake */
if (state->water_intake - state->water_glass < 0) {
state->water_intake = 0;
} else {
state->water_intake -= state->water_glass;
}
_tracking_display(state);
_beep();
break;
case EVENT_LIGHT_BUTTON_DOWN:
/* Do nothing. No light */
break;
case EVENT_LIGHT_LONG_PRESS:
_switch_to_settings(state);
_beep();
break;
case EVENT_ALARM_LONG_PRESS:
/* Display intake deviation for 2 seconds */
state->display_deviation = 2;
_tracking_display(state);
break;
case EVENT_ALARM_REALLY_LONG_PRESS:
state->display_deviation = 0;
_switch_to_log(state);
_beep();
break;
case EVENT_LOW_ENERGY_UPDATE:
_hydration_start_sleep_animation();
break;
case EVENT_BACKGROUND_TASK:
_check_hydration_alert(state);
break;
case EVENT_TIMEOUT:
movement_move_to_face(0);
break;
default:
movement_default_loop_handler(event);
break;
}
return true;
}
static bool _log_loop(movement_event_t event, void *context)
{
hydration_state_t *state = (hydration_state_t *) context;
switch (event.event_type) {
case EVENT_ACTIVATE:
case EVENT_TICK:
_log_display(state);
break;
case EVENT_ALARM_BUTTON_UP:
state->log_index = _log_next_entry(state);
_log_display(state);
break;
case EVENT_LIGHT_BUTTON_UP:
state->log_type = (state->log_type + 1) % HYDRATION_LOG_TYPES;
_log_display(state);
_beep();
break;
case EVENT_LIGHT_BUTTON_DOWN:
/* Do nothing. No light */
break;
case EVENT_MODE_BUTTON_UP:
_switch_to_tracking(state);
_beep();
break;
case EVENT_BACKGROUND_TASK:
_check_hydration_alert(state);
break;
case EVENT_TIMEOUT:
movement_move_to_face(0);
break;
default:
movement_default_loop_handler(event);
break;
}
return true;
}
static bool _settings_loop(movement_event_t event, void *context)
{
hydration_state_t *state = (hydration_state_t *) context;
switch (event.event_type) {
case EVENT_ACTIVATE:
case EVENT_TICK:
state->settings[state->settings_page].display(context, event.subsecond);
break;
case EVENT_LIGHT_BUTTON_UP:
state->settings_page = (state->settings_page + 1) % NUM_SETTINGS;
state->settings[state->settings_page].display(context, event.subsecond);
break;
case EVENT_MODE_BUTTON_UP:
_switch_to_tracking(state);
_beep();
break;
case EVENT_LIGHT_BUTTON_DOWN:
/* Do nothing. No light */
break;
case EVENT_ALARM_BUTTON_UP:
/* Advance current settings */
state->settings[state->settings_page].advance(context);
state->settings[state->settings_page].display(context, event.subsecond);
break;
case EVENT_ALARM_LONG_PRESS:
/* Reset to default value */
switch (state->settings_page) {
case HYDRATION_SETTING_WATER_GLASS:
state->water_glass = DEFAULT_WATER_GLASS_ML;
break;
case HYDRATION_SETTING_WATER_GOAL:
state->water_goal = DEFAULT_WATER_GOAL_ML;
break;
case HYDRATION_SETTING_WAKE_TIME:
state->wake_hour = DEFAULT_WAKE_HOUR;
break;
case HYDRATION_SETTING_SLEEP_TIME:
state->sleep_hour = DEFAULT_SLEEP_HOUR;
break;
case HYDRATION_SETTING_ALERT_INTERVAL:
state->alert_interval = DEFAULT_ALERT_INTERVAL;
break;
}
state->settings[state->settings_page].display(context, event.subsecond);
break;
case EVENT_LIGHT_LONG_PRESS:
state->alert_enabled = !state->alert_enabled;
state->settings[state->settings_page].display(context, event.subsecond);
_beep();
break;
case EVENT_BACKGROUND_TASK:
_check_hydration_alert(state);
break;
case EVENT_TIMEOUT:
movement_move_to_face(0);
break;
default:
movement_default_loop_handler(event);
break;
}
return true;
}
void hydration_face_setup(uint8_t watch_face_index, void **context_ptr)
{
(void) watch_face_index;
if (*context_ptr != NULL)
return; /* Skip setup if context available */
*context_ptr = malloc(sizeof(hydration_state_t));
memset(*context_ptr, 0, sizeof(hydration_state_t));
hydration_state_t *state = (hydration_state_t *) * context_ptr;
/* Default setup */
state->water_glass = DEFAULT_WATER_GLASS_ML;
state->water_goal = DEFAULT_WATER_GOAL_ML;
state->wake_hour = DEFAULT_WAKE_HOUR;
state->sleep_hour = DEFAULT_SLEEP_HOUR;
state->alert_interval = DEFAULT_ALERT_INTERVAL;
/* Initialize settings */
uint8_t settings_page = 0;
state->settings = malloc(NUM_SETTINGS * sizeof(hydration_settings_t));
state->settings[settings_page].display = _settings_water_glass_display;
state->settings[settings_page].advance = _settings_water_glass_advance;
settings_page++;
state->settings[settings_page].display = _settings_water_goal_display;
state->settings[settings_page].advance = _settings_water_goal_advance;
settings_page++;
state->settings[settings_page].display = _settings_wake_time_display;
state->settings[settings_page].advance = _settings_wake_time_advance;
settings_page++;
state->settings[settings_page].display = _settings_sleep_time_display;
state->settings[settings_page].advance = _settings_sleep_time_advance;
settings_page++;
state->settings[settings_page].display = _settings_alert_interval_display;
state->settings[settings_page].advance = _settings_alert_interval_advance;
settings_page++;
/* Store face index for background tasks */
state->face_index = watch_face_index;
}
void hydration_face_activate(void *context)
{
hydration_state_t *state = (hydration_state_t *) context;
_hydration_stop_sleep_animation();
/* Switch to tracking page. */
_switch_to_tracking(state);
}
bool hydration_face_loop(movement_event_t event, void *context)
{
hydration_state_t *state = (hydration_state_t *) context;
switch (state->page) {
default:
case PAGE_HYDRATION_TRACKING:
return _tracking_loop(event, context);
case PAGE_HYDRATION_SETTINGS:
return _settings_loop(event, context);
case PAGE_HYDRATION_LOG:
return _log_loop(event, context);
}
}
void hydration_face_resign(void *context)
{
(void) context;
}
movement_watch_face_advisory_t hydration_face_advise(void *context)
{
hydration_state_t *state = (hydration_state_t *) context;
movement_watch_face_advisory_t retval = { 0 };
watch_date_time_t now = movement_get_local_date_time();
/* Daily reset at wake time and log entry */
if (now.unit.hour == state->wake_hour && now.unit.minute == 0) {
uint32_t ts = watch_utility_date_time_to_unix_time(now, 0);
/* Correct date if midnight between sleep and wake time */
if (state->wake_hour < state->sleep_hour)
ts -= 24 * 60 * 60;
_log_intake(state, ts);
/* Reset water intake */
state->water_intake = 0;
}
/* Check for alert at every hour */
if (state->alert_enabled && now.unit.minute == 0) {
retval.wants_background_task = true;
}
return retval;
}