diff --git a/movement_faces.h b/movement_faces.h index fc43716d..81ca394f 100644 --- a/movement_faces.h +++ b/movement_faces.h @@ -73,4 +73,5 @@ #include "wareki_face.h" #include "deadline_face.h" #include "wordle_face.h" +#include "tide_face.h" // New includes go above this line. diff --git a/watch-faces.mk b/watch-faces.mk index 22e262cf..6c8000d7 100644 --- a/watch-faces.mk +++ b/watch-faces.mk @@ -48,4 +48,5 @@ SRCS += \ ./watch-faces/sensor/lis2dw_monitor_face.c \ ./watch-faces/complication/wareki_face.c \ ./watch-faces/complication/deadline_face.c \ + ./watch-faces/complication/tide_face.c \ # New watch faces go above this line. diff --git a/watch-faces/complication/tide_face.c b/watch-faces/complication/tide_face.c new file mode 100644 index 00000000..e3cd9577 --- /dev/null +++ b/watch-faces/complication/tide_face.c @@ -0,0 +1,402 @@ +/* + * MIT License + * + * Copyright (c) 2025 Mathias Kende + * + * 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 +#include "tide_face.h" +#include "watch.h" +#include "watch_common_display.h" +#include "watch_utility.h" + +// Parameters taken from the moon_phase_face.c file. +#define LUNAR_DAYS 29.53058770576 +#define FIRST_MOON 947182440 // Saturday, 6 January 2000 18:14:00 in unix epoch time +#define SEMI_DIURNAL_TIDAL_PERIOD (LUNAR_DAYS / (LUNAR_DAYS - 1) * 12 * 3600) // 12h25m in seconds + +#ifndef M_PI +#define M_PI 3.14159265358979 +#endif + +typedef enum { + spring_tide, // Less than 1.8 days away from a full or new moon. + neap_tide, // Less than 1.8 days away from a first or third quarter moon. + medium_tide, // The rest +} tide_amplitude_t; + +static tide_amplitude_t _get_tide_amplitude(uint32_t time) { + // Moon age in days, looped over beetween new and full moon (so age is 14.7 days at most). + double moon_age = fmod(((double)(time - FIRST_MOON)) / 86400, LUNAR_DAYS / 2); + + if (moon_age <= LUNAR_DAYS / 16 || moon_age >= LUNAR_DAYS * 7 / 16) { + return spring_tide; + } else if (moon_age > LUNAR_DAYS * 3 / 16 && moon_age < LUNAR_DAYS * 5 / 16) { + return neap_tide; + } else { + return medium_tide; + } +} + +typedef enum { + empty, // No tide data set. + current, // Default screen, showing the current tide. + future, // Screen showing the time of future high and low tides. + setting_hour, // Setting screen, setting the hour of the next high tide. + setting_min, // Setting screen, setting the minute of the next high tide. +} tide_mode_t; + +typedef enum { + high_tide, + low_tide, +} tide_type_t; + +typedef struct { + tide_mode_t mode; + bool start_setting; // we entered the setting mode but did not yet changed any value. + uint32_t next_high_tide; + uint32_t last_current_update_time; + uint32_t future_tide_time; + tide_type_t future_tide_type; +} tide_state_t; + +void tide_face_setup(uint8_t watch_face_index, void** state_ptr) { + if (*state_ptr == NULL) { + // Boot time initialization. + *state_ptr = malloc(sizeof(tide_state_t)); + tide_state_t* state = (tide_state_t*)*state_ptr; + state->mode = empty; + } +} + +uint32_t _get_current_unix_time() { + return watch_utility_date_time_to_unix_time(movement_get_utc_date_time(), 0); +} + +void _move_next_high_tide(tide_state_t* state, uint32_t now) { + while (state->next_high_tide > now + SEMI_DIURNAL_TIDAL_PERIOD) { + state->next_high_tide -= SEMI_DIURNAL_TIDAL_PERIOD; + } + while (state->next_high_tide < now) { + state->next_high_tide += SEMI_DIURNAL_TIDAL_PERIOD; + } +} + +void tide_face_activate(void* context) { + tide_state_t* state = (tide_state_t*)context; + if (state->mode != empty) { + state->mode = current; + } + // int64 so that the substraction below works (we need a signed number and int32 will overflow soon). + // using int64 everywhere for the unix time would probably be better. + int64_t now = _get_current_unix_time(); + if (llabs(now - state->next_high_tide) > 60 * 86400) { + // We revert to the empty mode if the next high tide is more than 2 + // months from now, to avoid accumulating too much errors. + state->mode = empty; + return; + } + _move_next_high_tide(state, now); +} + +static void _set_pixel(segment_mapping_t mapping) { + watch_set_pixel(mapping.address.com, mapping.address.seg); +} + +static void _draw_tide_amplitude(uint32_t time) { + const digit_mapping_t* digit_mapping = + watch_get_lcd_type() == WATCH_LCD_TYPE_CLASSIC ? Classic_LCD_Display_Mapping : Custom_LCD_Display_Mapping; + switch (_get_tide_amplitude(time)) { + case spring_tide: + _set_pixel(digit_mapping[9].segment[0]); // top horizontal bar on the bottom-right character. + case medium_tide: + _set_pixel(digit_mapping[9].segment[6]); // mid horizontal bar on the bottom-right character. + case neap_tide: + _set_pixel(digit_mapping[9].segment[3]); // bottom horizontal bar on the bottom-right character. + break; + } +} + +static void _draw_day_and_time(uint32_t time, bool show_day, bool show_hour, bool show_minute) { + watch_date_time_t date_time = + watch_utility_date_time_from_unix_time(time, movement_get_current_timezone_offset()); + bool pm = false; + if (movement_clock_mode_24h() == MOVEMENT_CLOCK_MODE_12H) { + pm = watch_utility_convert_to_12_hour(&date_time); + } else { + watch_set_indicator(WATCH_INDICATOR_24H); + } + if (pm) { + watch_set_indicator(WATCH_INDICATOR_PM); + } + + if (show_hour) { + char tide_hour[3]; + sprintf(tide_hour, "%2u", date_time.unit.hour); + watch_display_text(WATCH_POSITION_HOURS, tide_hour); + } + if (show_minute) { + char tide_minute[3]; + sprintf(tide_minute, "%02u", date_time.unit.minute); + watch_display_text(WATCH_POSITION_MINUTES, tide_minute); + } + if (show_day) { + char tide_day[3]; + sprintf(tide_day, "%2u", date_time.unit.day); + watch_display_text(WATCH_POSITION_TOP_RIGHT, tide_day); + } + + watch_set_colon(); +} + +static void _draw(tide_state_t *state, uint32_t now, uint8_t subsecond) { + watch_clear_display(); + switch (state->mode) { + case empty: + watch_display_text_with_fallback(WATCH_POSITION_TOP, "TIDE", "TI"); + watch_display_text(WATCH_POSITION_BOTTOM, "----"); + break; + case current: { + double tide_age = state->next_high_tide - now; + _draw_tide_amplitude(now); + double tide_percent = (cos(tide_age / SEMI_DIURNAL_TIDAL_PERIOD * M_PI * 2) + 1) * 50; + if (tide_percent < 5) { + watch_display_text_with_fallback(WATCH_POSITION_TOP, "LOW", "LO"); + } else if (tide_percent > 95) { + watch_display_text_with_fallback(WATCH_POSITION_TOP, "HIGH", "HI"); + } else { + if (state->next_high_tide - now < SEMI_DIURNAL_TIDAL_PERIOD / 2) { + watch_display_text_with_fallback(WATCH_POSITION_TOP, "FLOOd", "FL"); + } else { + watch_display_text_with_fallback(WATCH_POSITION_TOP, "EBB", "EB"); + } + if (watch_get_lcd_type() == WATCH_LCD_TYPE_CLASSIC) { + uint8_t tide_upercent = tide_percent; + char hour[2]; + char minute[2]; + hour[0] = minute[1] = ' '; + hour[1] = '0' + tide_upercent / 10; + minute[0] = '0' + tide_upercent % 10; + // We use the second hour digit for our first digit, as it’s + // more capable than the first hour or minute digits. + watch_display_text(WATCH_POSITION_HOURS, hour); + watch_display_text(WATCH_POSITION_MINUTES, minute); + } else { + char tide_text[7]; + uint8_t tide_upercent = tide_percent; + sprintf(tide_text, "%2hhu", tide_upercent); + watch_display_text(WATCH_POSITION_HOURS, tide_text); + watch_display_text(WATCH_POSITION_MINUTES, "o#"); // # is rendered as °, o° looks like a percent sign, maybe... + } + } + break; + } + case future: + if (state->future_tide_type == low_tide) { + watch_display_text_with_fallback(WATCH_POSITION_TOP_LEFT, "LOW", "LO"); + } else { + watch_display_text_with_fallback(WATCH_POSITION_TOP_LEFT, "HIG", "HI"); + } + _draw_day_and_time(state->future_tide_time, true, true, true); + _draw_tide_amplitude(state->future_tide_time); + break; + case setting_hour: + case setting_min: + if (state->start_setting) { + watch_display_text_with_fallback(WATCH_POSITION_TOP, "HIGH", "HI"); + } else { + watch_display_text_with_fallback(WATCH_POSITION_TOP_LEFT, "HIG", "HI"); + } + _draw_day_and_time(state->next_high_tide, !state->start_setting, + (state->mode != setting_hour || subsecond % 2), (state->mode != setting_min || subsecond % 2)); + break; + } +} + +static void _offset_next_high_tide(tide_state_t* state, int16_t offset) { + state->next_high_tide += offset; + if (state->next_high_tide % 60) { + state->next_high_tide -= state->next_high_tide % 60; + } + state->start_setting = false; +} + +bool tide_face_loop(movement_event_t event, void* context) { + tide_state_t* state = (tide_state_t*)context; + uint32_t now = _get_current_unix_time(); + + // TODO: handle long press in setting mode. + switch (event.event_type) { + case EVENT_ACTIVATE: + _draw(state, now, event.subsecond); + if (state->mode == current) { + state->last_current_update_time = now; + } + break; + case EVENT_TICK: + switch (state->mode) { + case current: + if (now - state->last_current_update_time >= 60) { + _move_next_high_tide(state, now); + _draw(state, now, event.subsecond); + state->last_current_update_time = now; + } + break; + case setting_hour: + case setting_min: + _draw(state, now, event.subsecond); + break; + default: + break; + } + break; + case EVENT_LOW_ENERGY_UPDATE: + _draw(state, now, event.subsecond); + if (watch_get_lcd_type() == WATCH_LCD_TYPE_CLASSIC) { + watch_start_sleep_animation(500); + } else { + watch_set_indicator(WATCH_INDICATOR_SLEEP); + } + break; + case EVENT_LIGHT_BUTTON_DOWN: + switch (state->mode) { + case setting_hour: + state->mode = setting_min; + _draw(state, now, event.subsecond); + break; + case setting_min: + state->mode = current; + _move_next_high_tide(state, _get_current_unix_time()); + movement_request_tick_frequency(1); + _draw(state, now, event.subsecond); + break; + default: + movement_illuminate_led(); + break; + } + break; + case EVENT_LIGHT_LONG_PRESS: + if (state->mode == future) { + state->mode = current; + _draw(state, now, event.subsecond); + state->last_current_update_time = now; + } + break; + case EVENT_ALARM_BUTTON_DOWN: + switch(state->mode) { + case setting_hour: + _offset_next_high_tide(state, 3600); + break; + case setting_min: + _offset_next_high_tide(state, 60); + break; + default: + break; + } + _draw(state, now, event.subsecond); + break; + case EVENT_ALARM_BUTTON_UP: + // We react to UP event only so that we don’t switch to a future day at the beginning of a long press. + switch(state->mode) { + case current: + if (state->next_high_tide - now > SEMI_DIURNAL_TIDAL_PERIOD / 2) { + state->future_tide_time = state->next_high_tide - SEMI_DIURNAL_TIDAL_PERIOD / 2; + state->future_tide_type = low_tide; + } else { + state->future_tide_time = state->next_high_tide; + state->future_tide_type = high_tide; + } + state->mode = future; + break; + case future: + state->future_tide_time += SEMI_DIURNAL_TIDAL_PERIOD / 2; + state->future_tide_type = state->future_tide_type == low_tide ? high_tide : low_tide; + break; + default: + break; + } + _draw(state, now, event.subsecond); + break; + case EVENT_ALARM_LONG_PRESS: + switch(state->mode) { + case empty: + state->next_high_tide = _get_current_unix_time(); + // fallthrough intended. + case current: + case future: + state->mode = setting_hour; + state->start_setting = true; + movement_request_tick_frequency(4); + break; + case setting_hour: + case setting_min: + break; + } + _draw(state, now, event.subsecond); + break; + case EVENT_MODE_BUTTON_DOWN: + switch(state->mode) { + case setting_hour: + _offset_next_high_tide(state, -3600); + break; + case setting_min: + _offset_next_high_tide(state, -60); + break; + default: + return movement_default_loop_handler(event); + } + _draw(state, now, event.subsecond); + break; + case EVENT_MODE_BUTTON_UP: + case EVENT_MODE_LONG_PRESS: + switch(state->mode) { + case setting_hour: + case setting_min: + break; + default: + return movement_default_loop_handler(event); + } + break; + case EVENT_TIMEOUT: + if (state->mode == setting_min || state->mode == setting_hour) { + state->mode = current; + _draw(state, now, event.subsecond); + } + // Passthrough intended: + // Delegate the resign behavior to the default loop handler. + default: + return movement_default_loop_handler(event); + } + + return true; +} + +void tide_face_resign(void* context) { + // Any cleanup needed before the watch face goes off-screen. + tide_state_t* state = (tide_state_t*)context; + if (state->mode == setting_hour || state->mode == setting_min) { + // Not strictly needed because it will be done upon re-entering the + // watch face. But let’s leave a clean state. + _move_next_high_tide(state, _get_current_unix_time()); + } +} diff --git a/watch-faces/complication/tide_face.h b/watch-faces/complication/tide_face.h new file mode 100644 index 00000000..648d99a4 --- /dev/null +++ b/watch-faces/complication/tide_face.h @@ -0,0 +1,80 @@ +/* + * MIT License + * + * Copyright (c) 2025 Mathias Kende + * + * 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 + +#include "movement.h" + +/* + * TIDE COMPUTATION face + * + * Computes the time of the next high and low tides in your areas as well as + * their magnitudes and gives an approximation of the current tide. + * + * For now, this face only handle a theoretical perfect semi-diurnal tide, + * similarly to what some Casio watches are doing. In the future, it may be + * possible to handle more precise computation by sending the harmonics + * coeficients of the tides over IR. + * + * To configure the face, long press the Alarm button to enter the setting mode + * then set the time of the high tide in your area. You can move the hour using + * the Alarm and Mode button to go up and down, then press the Light button to + * set the minutes in the same way. Notice that if you overflow the minutes then + * the hour will change. Similarly, when the hours overflow, the day for the + * tide (shown in the upper right corner) is changed too (however there is no + * direct way to set the day). You must be sure to select the right day for the + * tide that you are currently entering. + * Note that, if you set the high tide value for a day with a full or new moon, + * the computation will be slightly more precise. + * Because the Mode button is used, you must first press the Alarm button once + * or twice, to exit the settings, before pressing Mode to exit the watch face. + * + * Once configured, the face start by showing the state of the current tide + * (low, flowing, high, or ebbing) at the top of the screen. If the tide is + * flowing or ebbing, the bottom left part of the screen also shows the current + * hight of the tide as a percentage of the total tide. The bottom right portion + * of the screen shows a representation of whether the current tide is a neap + * or spring tide, or an intermediate one. + * + * You can then repeatedly press the Alarm buttom to see the time of the future + * high and low tides. The bottow right corner has the same representation of + * the amplitude of the tide and the top right corner has the day of the month + * for which the tide is shown. + * + * You can long press the Light button to come back to the state of the current + * tide. Exiting and re-entering the watch face will have the same effect. + */ + +void tide_face_setup(uint8_t watch_face_index, void ** context_ptr); +void tide_face_activate(void *context); +bool tide_face_loop(movement_event_t event, void *context); +void tide_face_resign(void *context); + +#define tide_face ((const watch_face_t){ \ + tide_face_setup, \ + tide_face_activate, \ + tide_face_loop, \ + tide_face_resign, \ + NULL, \ +})