diff --git a/generated/Kconfig.kv_keys b/generated/Kconfig.kv_keys index a13480142..5fcfc948d 100644 --- a/generated/Kconfig.kv_keys +++ b/generated/Kconfig.kv_keys @@ -146,6 +146,11 @@ config KV_STORE_KEY_BLUETOOTH_THROUGHPUT_LIMIT help Request connected Bluetooth peers to limit throughtput +config KV_STORE_KEY_LED_DISABLE_DAILY_TIME_RANGE + bool "Enable KV key LED_DISABLE_DAILY_TIME_RANGE" + help + Disable LEDs between two UTC times daily + config KV_STORE_KEY_GRAVITY_REFERENCE bool "Enable KV key GRAVITY_REFERENCE" help diff --git a/generated/include/infuse/fs/kv_types.h b/generated/include/infuse/fs/kv_types.h index d80dc6559..4787e7a1b 100644 --- a/generated/include/infuse/fs/kv_types.h +++ b/generated/include/infuse/fs/kv_types.h @@ -69,6 +69,13 @@ struct kv_range_u8 { uint8_t upper; } __packed; +/** UTC Hour-Minute-Second */ +struct kv_utc_hms { + uint8_t hour; + uint8_t minute; + uint8_t second; +} __packed; + /** * @} */ @@ -333,6 +340,14 @@ struct kv_bluetooth_throughput_limit { uint16_t limit_kbps; } __packed; +/** Disable LEDs between two UTC times daily */ +struct kv_led_disable_daily_time_range { + /** Disable LEDs at this time */ + struct kv_utc_hms disable_start; + /** Re-enable LEDs at this time */ + struct kv_utc_hms disable_end; +} __packed; + /** Reference gravity vector for tilt calculations */ struct kv_gravity_reference { /** X axis component of gravity vector */ @@ -466,6 +481,8 @@ enum kv_builtin_id { KV_KEY_LORA_CONFIG = 51, /** Request connected Bluetooth peers to limit throughtput */ KV_KEY_BLUETOOTH_THROUGHPUT_LIMIT = 52, + /** Disable LEDs between two UTC times daily */ + KV_KEY_LED_DISABLE_DAILY_TIME_RANGE = 53, /** Reference gravity vector for tilt calculations */ KV_KEY_GRAVITY_REFERENCE = 60, /** Array of points defining a closed polygon */ @@ -514,6 +531,7 @@ enum kv_builtin_size { _KV_KEY_BLUETOOTH_PEER_SIZE = sizeof(struct kv_bluetooth_peer), _KV_KEY_LORA_CONFIG_SIZE = sizeof(struct kv_lora_config), _KV_KEY_BLUETOOTH_THROUGHPUT_LIMIT_SIZE = sizeof(struct kv_bluetooth_throughput_limit), + _KV_KEY_LED_DISABLE_DAILY_TIME_RANGE_SIZE = sizeof(struct kv_led_disable_daily_time_range), _KV_KEY_GRAVITY_REFERENCE_SIZE = sizeof(struct kv_gravity_reference), _KV_KEY_TASK_SCHEDULES_DEFAULT_ID_SIZE = sizeof(struct kv_task_schedules_default_id), }; @@ -544,6 +562,7 @@ enum kv_builtin_size { #define _KV_KEY_BLUETOOTH_PEER_TYPE struct kv_bluetooth_peer #define _KV_KEY_LORA_CONFIG_TYPE struct kv_lora_config #define _KV_KEY_BLUETOOTH_THROUGHPUT_LIMIT_TYPE struct kv_bluetooth_throughput_limit +#define _KV_KEY_LED_DISABLE_DAILY_TIME_RANGE_TYPE struct kv_led_disable_daily_time_range #define _KV_KEY_GRAVITY_REFERENCE_TYPE struct kv_gravity_reference #define _KV_KEY_GEOFENCE_TYPE struct kv_geofence #define _KV_KEY_TASK_SCHEDULES_DEFAULT_ID_TYPE struct kv_task_schedules_default_id @@ -598,6 +617,8 @@ enum kv_builtin_size { (1 +)) \ IF_ENABLED(CONFIG_KV_STORE_KEY_BLUETOOTH_THROUGHPUT_LIMIT, \ (1 +)) \ + IF_ENABLED(CONFIG_KV_STORE_KEY_LED_DISABLE_DAILY_TIME_RANGE, \ + (1 +)) \ IF_ENABLED(CONFIG_KV_STORE_KEY_GRAVITY_REFERENCE, \ (1 +)) \ IF_ENABLED(CONFIG_KV_STORE_KEY_GEOFENCE, \ @@ -810,6 +831,13 @@ static struct key_value_slot_definition _KV_SLOTS_ARRAY_DEFINE[] = { .flags = KV_FLAGS_REFLECT, }, #endif /* CONFIG_KV_STORE_KEY_BLUETOOTH_THROUGHPUT_LIMIT */ +#ifdef CONFIG_KV_STORE_KEY_LED_DISABLE_DAILY_TIME_RANGE + { + .key = KV_KEY_LED_DISABLE_DAILY_TIME_RANGE, + .range = 1, + .flags = KV_FLAGS_REFLECT, + }, +#endif /* CONFIG_KV_STORE_KEY_LED_DISABLE_DAILY_TIME_RANGE */ #ifdef CONFIG_KV_STORE_KEY_GRAVITY_REFERENCE { .key = KV_KEY_GRAVITY_REFERENCE, diff --git a/include/infuse/states.h b/include/infuse/states.h index 5c59411f2..73304f94b 100644 --- a/include/infuse/states.h +++ b/include/infuse/states.h @@ -46,6 +46,8 @@ enum infuse_state { INFUSE_STATE_DEVICE_STARTED_MOVING = 6, /* Device stopped moving */ INFUSE_STATE_DEVICE_STOPPED_MOVING = 7, + /* Suppress LED activity */ + INFUSE_STATE_LED_SUPPRESS = 8, /* Start of application-specific state range */ INFUSE_STATES_APP_START = 128, INFUSE_STATES_END = UINT8_MAX diff --git a/lib/auto/CMakeLists.txt b/lib/auto/CMakeLists.txt index 404a84cc9..e827e7740 100644 --- a/lib/auto/CMakeLists.txt +++ b/lib/auto/CMakeLists.txt @@ -3,5 +3,6 @@ zephyr_sources_ifdef(CONFIG_INFUSE_AUTO_BATTERY_CHARGE_ACCUMULATOR charge_accumulator.c) zephyr_sources_ifdef(CONFIG_INFUSE_AUTO_BLUETOOTH_CONN_LOG bluetooth_conn_log.c) zephyr_sources_ifdef(CONFIG_INFUSE_AUTO_CHARGER_TEMPERATURE_CONTROL charger_control.c) +zephyr_sources_ifdef(CONFIG_INFUSE_AUTO_KV_STATE_OBSERVER kv_state_observer.c) zephyr_sources_ifdef(CONFIG_INFUSE_AUTO_TIME_SYNC_LOG time_sync_log.c) zephyr_sources_ifdef(CONFIG_INFUSE_AUTO_WIFI_CONN_LOG wifi_conn_log.c) diff --git a/lib/auto/Kconfig b/lib/auto/Kconfig index 29a8c3c4c..ff4d9a1b1 100644 --- a/lib/auto/Kconfig +++ b/lib/auto/Kconfig @@ -12,6 +12,13 @@ config INFUSE_AUTO_TIME_SYNC_LOG depends on TDF_DATA_LOGGER default y +config INFUSE_AUTO_KV_STATE_OBSERVER + bool "Automatically set application states based on KV" + depends on INFUSE_EPOCH_TIME + depends on INFUSE_APPLICATION_STATES + depends on KV_STORE_KEY_LED_DISABLE_DAILY_TIME_RANGE + default y + config INFUSE_AUTO_WIFI_CONN_LOG bool "Automatically log WiFi connection events" depends on WIFI diff --git a/lib/auto/kv_state_observer.c b/lib/auto/kv_state_observer.c new file mode 100644 index 000000000..6e7184f92 --- /dev/null +++ b/lib/auto/kv_state_observer.c @@ -0,0 +1,147 @@ +/** + * @file + * @copyright 2025 Embeint Holdings Pty Ltd + * @author Jordan Yates + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +#include +#include + +#include +#include +#include +#include + +static void kv_state_obs_value_changed(uint16_t key, const void *data, size_t data_len, + void *user_ctx); +static void reference_time_updated(enum epoch_time_source source, struct timeutil_sync_instant old, + struct timeutil_sync_instant new, void *user_ctx); + +static struct k_work_delayable led_delayable; +static struct kv_store_cb kv_observer_cb = { + .value_changed = kv_state_obs_value_changed, +}; +static struct epoch_time_cb epoch_observer_cb = { + .reference_time_updated = reference_time_updated, +}; +static uint32_t disable_daily_seconds_start; +static uint32_t disable_daily_seconds_end; +static bool has_disable_daily; + +static uint32_t utc_seconds_from_hms(const struct kv_utc_hms *hms) +{ + return (hms->hour * SEC_PER_HOUR) + (hms->minute * SEC_PER_MIN) + hms->second; +} + +static void led_disable_delayable(struct k_work *work) +{ + uint32_t utc_seconds; + struct kv_utc_hms hms; + uint32_t boundary; + uint64_t now; + struct tm c; + + /* If we don't know the time or have a KV, we can't suppress */ + if (!has_disable_daily || !epoch_time_trusted_source(epoch_time_get_source(), true)) { + infuse_state_clear(INFUSE_STATE_LED_SUPPRESS); + return; + } + + /* Get current time */ + now = epoch_time_now(); + epoch_time_unix_calendar(now, &c); + hms.hour = c.tm_hour; + hms.minute = c.tm_min; + hms.second = c.tm_sec; + utc_seconds = utc_seconds_from_hms(&hms); + + /* Handle current time vs windows */ + if (disable_daily_seconds_start < disable_daily_seconds_end) { + if (utc_seconds < disable_daily_seconds_start) { + /* Before start window */ + boundary = disable_daily_seconds_start - utc_seconds; + infuse_state_clear(INFUSE_STATE_LED_SUPPRESS); + } else if (utc_seconds > disable_daily_seconds_end) { + /* After end window */ + boundary = disable_daily_seconds_start + (SEC_PER_DAY - utc_seconds); + infuse_state_clear(INFUSE_STATE_LED_SUPPRESS); + } else { + /* In range */ + boundary = disable_daily_seconds_end - utc_seconds; + infuse_state_set(INFUSE_STATE_LED_SUPPRESS); + } + } else { + if (utc_seconds <= disable_daily_seconds_end) { + /* In range */ + boundary = disable_daily_seconds_end - utc_seconds; + infuse_state_set(INFUSE_STATE_LED_SUPPRESS); + } else if (utc_seconds >= disable_daily_seconds_start) { + /* In range */ + boundary = (SEC_PER_DAY - utc_seconds) + disable_daily_seconds_end; + infuse_state_set(INFUSE_STATE_LED_SUPPRESS); + } else { + /* Before start window */ + boundary = disable_daily_seconds_start - utc_seconds; + infuse_state_clear(INFUSE_STATE_LED_SUPPRESS); + } + } + + /* Set reschedule time */ + boundary = MAX(1, boundary); + k_work_reschedule(&led_delayable, K_SECONDS(boundary)); +} + +static void kv_state_obs_value_changed(uint16_t key, const void *data, size_t data_len, + void *user_ctx) +{ + const struct kv_led_disable_daily_time_range *disable_daily; + + if (key == KV_KEY_LED_DISABLE_DAILY_TIME_RANGE) { + if (data == NULL) { + /* Slot has been deleted, cancel any suppression */ + has_disable_daily = false; + k_work_cancel_delayable(&led_delayable); + infuse_state_clear(INFUSE_STATE_LED_SUPPRESS); + } else { + disable_daily = data; + /* Cache the current value */ + disable_daily_seconds_start = + utc_seconds_from_hms(&disable_daily->disable_start); + disable_daily_seconds_end = + utc_seconds_from_hms(&disable_daily->disable_end); + has_disable_daily = true; + /* Re-evaluate immediately */ + k_work_reschedule(&led_delayable, K_NO_WAIT); + } + } +} + +static void reference_time_updated(enum epoch_time_source source, struct timeutil_sync_instant old, + struct timeutil_sync_instant new, void *user_ctx) +{ + /* Re-evaluate immediately */ + k_work_reschedule(&led_delayable, K_NO_WAIT); +} + +static int kv_state_observer_init(void) +{ + struct kv_led_disable_daily_time_range disable_daily; + + epoch_time_register_callback(&epoch_observer_cb); + kv_store_register_callback(&kv_observer_cb); + k_work_init_delayable(&led_delayable, led_disable_delayable); + /* Initialise the cached values */ + if (KV_STORE_READ(KV_KEY_LED_DISABLE_DAILY_TIME_RANGE, &disable_daily) == + sizeof(disable_daily)) { + disable_daily_seconds_start = utc_seconds_from_hms(&disable_daily.disable_start); + disable_daily_seconds_end = utc_seconds_from_hms(&disable_daily.disable_end); + has_disable_daily = true; + } + /* Evaluate immediately */ + k_work_schedule(&led_delayable, K_NO_WAIT); + return 0; +} + +SYS_INIT(kv_state_observer_init, APPLICATION, 0); diff --git a/scripts/west_commands/cloud_definitions/kv_store.json b/scripts/west_commands/cloud_definitions/kv_store.json index d91f38bd6..933d2827a 100644 --- a/scripts/west_commands/cloud_definitions/kv_store.json +++ b/scripts/west_commands/cloud_definitions/kv_store.json @@ -36,6 +36,14 @@ {"name": "lower", "type": "uint8_t"}, {"name": "upper", "type": "uint8_t"} ] + }, + "kv_utc_hms": { + "description": "UTC Hour-Minute-Second", + "fields": [ + {"name": "hour", "type": "uint8_t"}, + {"name": "minute", "type": "uint8_t"}, + {"name": "second", "type": "uint8_t"} + ] } }, "definitions": { @@ -272,6 +280,15 @@ {"name": "limit_kbps", "type": "uint16_t", "description": "Requested throughput limit (kbps)"} ] }, + "53": { + "name": "LED_DISABLE_DAILY_TIME_RANGE", + "description": "Disable LEDs between two UTC times daily", + "reflect": true, + "fields": [ + {"name": "disable_start", "type": "struct kv_utc_hms", "description": "Disable LEDs at this time"}, + {"name": "disable_end", "type": "struct kv_utc_hms", "description": "Re-enable LEDs at this time"} + ] + }, "60": { "name": "GRAVITY_REFERENCE", "description": "Reference gravity vector for tilt calculations", diff --git a/tests/lib/auto/kv_state_observer/CMakeLists.txt b/tests/lib/auto/kv_state_observer/CMakeLists.txt new file mode 100644 index 000000000..251334cdc --- /dev/null +++ b/tests/lib/auto/kv_state_observer/CMakeLists.txt @@ -0,0 +1,9 @@ +# SPDX-License-Identifier: Apache-2.0 + +cmake_minimum_required(VERSION 3.20.0) +find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE}) +project(kv_state_observer) + +target_sources(app PRIVATE + src/main.c +) diff --git a/tests/lib/auto/kv_state_observer/boards/native_sim.overlay b/tests/lib/auto/kv_state_observer/boards/native_sim.overlay new file mode 100644 index 000000000..88287259c --- /dev/null +++ b/tests/lib/auto/kv_state_observer/boards/native_sim.overlay @@ -0,0 +1,5 @@ +/ { + chosen { + infuse,kv-partition = &storage_partition; + }; +}; diff --git a/tests/lib/auto/kv_state_observer/prj.conf b/tests/lib/auto/kv_state_observer/prj.conf new file mode 100644 index 000000000..6f6e14d4c --- /dev/null +++ b/tests/lib/auto/kv_state_observer/prj.conf @@ -0,0 +1,11 @@ +CONFIG_ZTEST=y +CONFIG_TEST_RANDOM_GENERATOR=y +CONFIG_FLASH=y +CONFIG_FLASH_MAP=y +CONFIG_NVS=y +CONFIG_KV_STORE=y +CONFIG_INFUSE_EPOCH_TIME=y +CONFIG_INFUSE_APPLICATION_STATES=y +CONFIG_KV_STORE_KEY_LED_DISABLE_DAILY_TIME_RANGE=y +CONFIG_INFUSE_AUTO_KV_STATE_OBSERVER=y +CONFIG_INFUSE_EPOCH_TIME_PRINT_REF_ON_SYNC=n diff --git a/tests/lib/auto/kv_state_observer/src/main.c b/tests/lib/auto/kv_state_observer/src/main.c new file mode 100644 index 000000000..448157cbe --- /dev/null +++ b/tests/lib/auto/kv_state_observer/src/main.c @@ -0,0 +1,172 @@ +/** + * @file + * @copyright 2024 Embeint Holdings Pty Ltd + * @author Jordan Yates + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +#include +#include + +#include +#include + +#include +#include +#include +#include + +static void set_now(uint32_t gps_time) +{ + struct timeutil_sync_instant reference; + int rc; + + reference.local = k_uptime_ticks(); + reference.ref = epoch_time_from(gps_time, 0); + + rc = epoch_time_set_reference(TIME_SOURCE_GNSS, &reference); + zassert_equal(0, rc); + k_sleep(K_MSEC(10)); +} + +ZTEST(kv_state_observer, test_led_suppress_time_unknown) +{ + struct kv_led_disable_daily_time_range time_limits = { + .disable_start = + { + .hour = 2, + }, + .disable_end = + { + .hour = 6, + }, + }; + int rc; + + /* Write a time limit when no time is known */ + zassert_false(infuse_state_get(INFUSE_STATE_LED_SUPPRESS)); + zassert_equal(TIME_SOURCE_NONE, epoch_time_get_source()); + rc = KV_STORE_WRITE(KV_KEY_LED_DISABLE_DAILY_TIME_RANGE, &time_limits); + zassert_equal(sizeof(time_limits), rc); + k_sleep(K_MSEC(100)); + + /* State should not be set */ + zassert_false(infuse_state_get(INFUSE_STATE_LED_SUPPRESS)); + + /* Cleanup the key value */ + kv_store_delete(KV_KEY_LED_DISABLE_DAILY_TIME_RANGE); + zassert_false(infuse_state_get(INFUSE_STATE_LED_SUPPRESS)); +} + +ZTEST(kv_state_observer, test_led_suppress) +{ + struct kv_led_disable_daily_time_range time_limits = { + .disable_start = + { + .hour = 12, + .minute = 43, + .second = 20, + }, + .disable_end = + { + .hour = 12, + .minute = 43, + .second = 30, + }, + }; + int rc; + + zassert_false(infuse_state_get(INFUSE_STATE_LED_SUPPRESS)); + + /* 2024-07-02T12:43:01 UTC */ + set_now(1403959399); + rc = KV_STORE_WRITE(KV_KEY_LED_DISABLE_DAILY_TIME_RANGE, &time_limits); + zassert_equal(sizeof(time_limits), rc); + + /* Time is outside the supression window (just) */ + zassert_false(infuse_state_get(INFUSE_STATE_LED_SUPPRESS)); + + /* Naturally rolls over into suppression window */ + k_sleep(K_SECONDS(20)); + zassert_true(infuse_state_get(INFUSE_STATE_LED_SUPPRESS)); + + /* State only stays set for a short period */ + k_sleep(K_SECONDS(9)); + zassert_true(infuse_state_get(INFUSE_STATE_LED_SUPPRESS)); + k_sleep(K_SECONDS(2)); + zassert_false(infuse_state_get(INFUSE_STATE_LED_SUPPRESS)); + + /* Set time to the middle of the window immediately */ + set_now(1403959399 + 25); + zassert_true(infuse_state_get(INFUSE_STATE_LED_SUPPRESS)); + k_sleep(K_SECONDS(6)); + zassert_false(infuse_state_get(INFUSE_STATE_LED_SUPPRESS)); + + /* KV value updated */ + k_sleep(K_SECONDS(30)); + time_limits.disable_start.minute += 1; + time_limits.disable_end.minute += 1; + rc = KV_STORE_WRITE(KV_KEY_LED_DISABLE_DAILY_TIME_RANGE, &time_limits); + zassert_equal(sizeof(time_limits), rc); + k_sleep(K_MSEC(10)); + zassert_false(infuse_state_get(INFUSE_STATE_LED_SUPPRESS)); + k_sleep(K_SECONDS(17)); + zassert_false(infuse_state_get(INFUSE_STATE_LED_SUPPRESS)); + k_sleep(K_SECONDS(2)); + zassert_true(infuse_state_get(INFUSE_STATE_LED_SUPPRESS)); + + /* Delete KV, state immediately cleared */ + kv_store_delete(KV_KEY_LED_DISABLE_DAILY_TIME_RANGE); + k_sleep(K_MSEC(10)); + zassert_false(infuse_state_get(INFUSE_STATE_LED_SUPPRESS)); +} + +ZTEST(kv_state_observer, test_led_suppress_overflow) +{ + struct kv_led_disable_daily_time_range time_limits = { + .disable_start = + { + .hour = 23, + .minute = 59, + .second = 45, + }, + .disable_end = + { + .hour = 0, + .minute = 0, + .second = 15, + }, + }; + int rc; + + zassert_false(infuse_state_get(INFUSE_STATE_LED_SUPPRESS)); + + /* 2024-07-03T23:59:01 UTC */ + set_now(1404086359); + rc = KV_STORE_WRITE(KV_KEY_LED_DISABLE_DAILY_TIME_RANGE, &time_limits); + zassert_equal(sizeof(time_limits), rc); + + zassert_false(infuse_state_get(INFUSE_STATE_LED_SUPPRESS)); + k_sleep(K_SECONDS(43)); + zassert_false(infuse_state_get(INFUSE_STATE_LED_SUPPRESS)); + k_sleep(K_SECONDS(2)); + zassert_true(infuse_state_get(INFUSE_STATE_LED_SUPPRESS)); + k_sleep(K_SECONDS(28)); + zassert_true(infuse_state_get(INFUSE_STATE_LED_SUPPRESS)); + k_sleep(K_SECONDS(4)); + zassert_false(infuse_state_get(INFUSE_STATE_LED_SUPPRESS)); +} + +void test_init(void *fixture) +{ + struct timeutil_sync_instant reference = { + .local = k_uptime_ticks(), + .ref = 1, + }; + + kv_store_delete(KV_KEY_LED_DISABLE_DAILY_TIME_RANGE); + epoch_time_set_reference(TIME_SOURCE_NONE, &reference); +} + +ZTEST_SUITE(kv_state_observer, NULL, NULL, test_init, NULL, NULL); diff --git a/tests/lib/auto/kv_state_observer/testcase.yaml b/tests/lib/auto/kv_state_observer/testcase.yaml new file mode 100644 index 000000000..e32a5201d --- /dev/null +++ b/tests/lib/auto/kv_state_observer/testcase.yaml @@ -0,0 +1,9 @@ +tests: + libraries.auto.kv_state_observer: + platform_allow: + - native_sim + integration_platforms: + - native_sim + tags: infuse + min_flash: 64 + min_ram: 32