From c6137f71c0c013d2ea854a7fd93b7d16716c0774 Mon Sep 17 00:00:00 2001 From: ltloopy Date: Tue, 7 Jul 2026 08:52:32 -0500 Subject: [PATCH 1/4] lib(ArduinoHA): add HAText device type + single-entity discovery publish Vendored-library groundwork for the Timer app's Home Assistant surface: - HAText: new device type (text entity with command/state topics), registered in ArduinoHA.h. Guarded by EX_ARDUINOHA_TEXT like the library's other optional types. - HAMqtt::publishConfigForDeviceType(): publish discovery config for a single already-registered device type, so an entity created after the broker connection exists doesn't need a full reconnect. - HAMqtt::getDevicesTypesNb() moved out of the ARDUINOHA_TEST guard so the firmware can guard entity registration against the device-type cap. - HASelect::resetOptions(): release the option list so a dynamic, discovery-driven select can rebuild its options at runtime. - HASelect/HASensor: optional JSON-attributes topic (json_attr_t) with retained publishJsonAttributes(); HADictionary entry to match. Co-Authored-By: Claude Fable 5 --- .../src/ArduinoHA.h | 1 + lib/home-assistant-integration/src/HAMqtt.cpp | 8 + lib/home-assistant-integration/src/HAMqtt.h | 19 +- .../src/device-types/HASelect.cpp | 37 +- .../src/device-types/HASelect.h | 37 +- .../src/device-types/HASensor.cpp | 18 +- .../src/device-types/HASensor.h | 26 + .../src/device-types/HAText.cpp | 92 ++ .../src/device-types/HAText.h | 68 ++ .../src/utils/HADictionary.cpp | 2 + .../src/utils/HADictionary.h | 2 + src/Apps.cpp | 135 +++ src/Apps.h | 2 + src/Dictionary.cpp | 1 + src/DisplayManager.cpp | 20 + src/Globals.cpp | 37 + src/Globals.h | 21 + src/MQTTManager.cpp | 108 +- src/MQTTManager.h | 25 + src/MatrixDisplayUi.cpp | 2 +- src/MenuManager.cpp | 188 ++++ src/MenuManager.h | 5 + src/Overlays.cpp | 56 +- src/PeerRegistry.cpp | 85 ++ src/PeerRegistry.h | 60 ++ src/PeripheryManager.cpp | 86 +- src/PeripheryManager.h | 1 + src/ServerManager.cpp | 52 + src/ServerManager.h | 3 + src/SyncEnvelope.cpp | 79 ++ src/SyncEnvelope.h | 78 ++ src/SyncSeenCache.cpp | 37 + src/SyncSeenCache.h | 45 + src/SyncTargetsDebounce.cpp | 42 + src/SyncTargetsDebounce.h | 59 ++ src/TimerCommand.cpp | 126 +++ src/TimerCommand.h | 90 ++ src/TimerConfigEditor.cpp | 96 ++ src/TimerConfigEditor.h | 84 ++ src/TimerEnums.cpp | 58 ++ src/TimerEnums.h | 58 ++ src/TimerHa.cpp | 161 +++ src/TimerHa.h | 116 +++ src/TimerHaHost.cpp | 436 ++++++++ src/TimerHaHost.h | 56 ++ src/TimerManager.cpp | 942 ++++++++++++++++++ src/TimerManager.h | 393 ++++++++ src/TimerMenu.cpp | 126 +++ src/TimerMenu.h | 69 ++ src/TimerMenuNav.cpp | 100 ++ src/TimerMenuNav.h | 93 ++ src/TimerRuntime.cpp | 181 ++++ src/TimerRuntime.h | 129 +++ src/TimerSettings.cpp | 591 +++++++++++ src/TimerSettings.h | 281 ++++++ src/TimerSettingsApply.cpp | 178 ++++ src/TimerView.cpp | 89 ++ src/TimerView.h | 88 ++ src/icons.h | 1 + src/main.cpp | 7 + 60 files changed, 6064 insertions(+), 22 deletions(-) create mode 100644 lib/home-assistant-integration/src/device-types/HAText.cpp create mode 100644 lib/home-assistant-integration/src/device-types/HAText.h create mode 100644 src/PeerRegistry.cpp create mode 100644 src/PeerRegistry.h create mode 100644 src/SyncEnvelope.cpp create mode 100644 src/SyncEnvelope.h create mode 100644 src/SyncSeenCache.cpp create mode 100644 src/SyncSeenCache.h create mode 100644 src/SyncTargetsDebounce.cpp create mode 100644 src/SyncTargetsDebounce.h create mode 100644 src/TimerCommand.cpp create mode 100644 src/TimerCommand.h create mode 100644 src/TimerConfigEditor.cpp create mode 100644 src/TimerConfigEditor.h create mode 100644 src/TimerEnums.cpp create mode 100644 src/TimerEnums.h create mode 100644 src/TimerHa.cpp create mode 100644 src/TimerHa.h create mode 100644 src/TimerHaHost.cpp create mode 100644 src/TimerHaHost.h create mode 100644 src/TimerManager.cpp create mode 100644 src/TimerManager.h create mode 100644 src/TimerMenu.cpp create mode 100644 src/TimerMenu.h create mode 100644 src/TimerMenuNav.cpp create mode 100644 src/TimerMenuNav.h create mode 100644 src/TimerRuntime.cpp create mode 100644 src/TimerRuntime.h create mode 100644 src/TimerSettings.cpp create mode 100644 src/TimerSettings.h create mode 100644 src/TimerSettingsApply.cpp create mode 100644 src/TimerView.cpp create mode 100644 src/TimerView.h diff --git a/lib/home-assistant-integration/src/ArduinoHA.h b/lib/home-assistant-integration/src/ArduinoHA.h index d7acb702..0db725d5 100644 --- a/lib/home-assistant-integration/src/ArduinoHA.h +++ b/lib/home-assistant-integration/src/ArduinoHA.h @@ -20,6 +20,7 @@ #include "device-types/HASensorNumber.h" #include "device-types/HASwitch.h" //#include "device-types/HATagScanner.h" +#include "device-types/HAText.h" #include "utils/HAUtils.h" #include "utils/HANumeric.h" diff --git a/lib/home-assistant-integration/src/HAMqtt.cpp b/lib/home-assistant-integration/src/HAMqtt.cpp index 7535d92f..cdb280ee 100644 --- a/lib/home-assistant-integration/src/HAMqtt.cpp +++ b/lib/home-assistant-integration/src/HAMqtt.cpp @@ -191,6 +191,14 @@ void HAMqtt::addDeviceType(HABaseDeviceType *deviceType) _devicesTypes[_devicesTypesNb++] = deviceType; } +void HAMqtt::publishConfigForDeviceType(HABaseDeviceType *deviceType) +{ + if (deviceType && isConnected()) + { + deviceType->onMqttConnected(); + } +} + bool HAMqtt::publish(const char *topic, const char *payload, bool retained) { if (!isConnected()) diff --git a/lib/home-assistant-integration/src/HAMqtt.h b/lib/home-assistant-integration/src/HAMqtt.h index 774c30f8..a57eb3eb 100644 --- a/lib/home-assistant-integration/src/HAMqtt.h +++ b/lib/home-assistant-integration/src/HAMqtt.h @@ -227,6 +227,17 @@ class HAMqtt */ void addDeviceType(HABaseDeviceType *deviceType); + /** + * Publishes the discovery configuration (and availability/subscriptions) for a single + * already-registered device type. Normally every device type's configuration is + * published by onConnectedLogic() on each (re)connect; this lets a device type that + * was created after the connection was established publish its configuration without + * forcing a full reconnect. No-op if the pointer is null or the broker is disconnected. + * + * @param deviceType Instance of the device's type to publish. + */ + void publishConfigForDeviceType(HABaseDeviceType *deviceType); + /** * Publishes the MQTT message with given topic and payload. * Message won't be published if the connection with the MQTT broker is not established. @@ -324,12 +335,18 @@ class HAMqtt */ void processMessage(const char *topic, const uint8_t *payload, uint16_t length); -#ifdef ARDUINOHA_TEST + /** + * Number of device types currently registered. Note ArduinoHA's effective + * capacity is one less than the max passed to the constructor (addDeviceType + * rejects once _devicesTypesNb + 1 >= _maxDevicesTypesNb). Used by the firmware + * to log/guard HA entity registration against the cap. + */ inline uint8_t getDevicesTypesNb() const { return _devicesTypesNb; } +#ifdef ARDUINOHA_TEST inline HABaseDeviceType **getDevicesTypes() const { return _devicesTypes; diff --git a/lib/home-assistant-integration/src/device-types/HASelect.cpp b/lib/home-assistant-integration/src/device-types/HASelect.cpp index 975f0d43..6a5c3258 100644 --- a/lib/home-assistant-integration/src/device-types/HASelect.cpp +++ b/lib/home-assistant-integration/src/device-types/HASelect.cpp @@ -11,6 +11,7 @@ HASelect::HASelect(const char* uniqueId) : _icon(nullptr), _retain(false), _optimistic(false), + _jsonAttributes(false), _commandCallback(nullptr) { @@ -71,6 +72,27 @@ void HASelect::setOptions(const char* options) } } +void HASelect::resetOptions() +{ + if (_options) { + // Mirror the destructor's option-array teardown: each option string is heap + // allocated only when there is more than one option (see setOptions). + const uint8_t optionsNb = _options->getItemsNb(); + const HASerializerArray::ItemType* options = _options->getItems(); + + if (optionsNb > 1) { + for (uint8_t i = 0; i < optionsNb; i++) { + delete options[i]; + } + } + + delete _options; + _options = nullptr; + } + + _currentState = -1; // no option selected until the new list is applied +} + bool HASelect::setState(const int8_t state, const bool force) { if (!force && _currentState == state) { @@ -91,7 +113,7 @@ void HASelect::buildSerializer() return; } - _serializer = new HASerializer(this, 10); // 10 - max properties nb + _serializer = new HASerializer(this, 11); // 11 - max properties nb (incl. json_attr_t) _serializer->set(AHATOFSTR(HANameProperty), _name); _serializer->set(AHATOFSTR(HAUniqueIdProperty), _uniqueId); _serializer->set(AHATOFSTR(HAIconProperty), _icon); @@ -121,6 +143,10 @@ void HASelect::buildSerializer() _serializer->set(HASerializer::WithAvailability); _serializer->topic(AHATOFSTR(HAStateTopic)); _serializer->topic(AHATOFSTR(HACommandTopic)); + + if (_jsonAttributes) { + _serializer->topic(AHATOFSTR(HAJsonAttributesTopic)); + } } void HASelect::onMqttConnected() @@ -176,6 +202,15 @@ bool HASelect::publishState(const int8_t state) return publishOnDataTopic(AHATOFSTR(HAStateTopic), item, true); } +bool HASelect::publishJsonAttributes(const char* json) +{ + if (!json) { + return false; + } + + return publishOnDataTopic(AHATOFSTR(HAJsonAttributesTopic), json, true); +} + uint8_t HASelect::countOptionsInString(const char* options) const { // the given string is treated as a single option if there are no semicolons diff --git a/lib/home-assistant-integration/src/device-types/HASelect.h b/lib/home-assistant-integration/src/device-types/HASelect.h index e92599be..6f78cbb8 100644 --- a/lib/home-assistant-integration/src/device-types/HASelect.h +++ b/lib/home-assistant-integration/src/device-types/HASelect.h @@ -31,10 +31,19 @@ class HASelect : public HABaseDeviceType * For example: `setOptions("Option A;Option B;Option C"); * * @param options The list of options that are separated by semicolons. - * @note The options list can be set only once. + * @note The options list can be set only once. Call resetOptions() first to + * replace an already-set list (e.g. a dynamic, discovery-driven select). */ void setOptions(const char* options); + /** + * Releases the current option list so setOptions() can be called again, and + * resets the current state to "no option selected" (-1). Useful for a select + * whose options change at runtime: call resetOptions(), setOptions(newList), + * then re-publish the discovery config so Home Assistant sees the new options. + */ + void resetOptions(); + /** * Changes state of the select and publishes MQTT message. * State represents the index of the option that was set using the setOptions method. @@ -104,6 +113,29 @@ class HASelect : public HABaseDeviceType inline void onCommand(HASELECT_CALLBACK(callback)) { _commandCallback = callback; } + /** + * Enables or disables publishing of JSON attributes for this select. + * When enabled, the discovery config advertises a `json_attributes_topic` + * (`json_attr_t`) so Home Assistant reads extra attributes from that topic, + * and publishJsonAttributes() publishes a retained JSON object onto it. + * Disabled by default; while disabled the discovery payload is unchanged. + * + * @param enabled `true` to advertise the JSON attributes topic. + */ + inline void setJsonAttributes(const bool enabled) + { _jsonAttributes = enabled; } + + /** + * Publishes the given JSON object as this select's attributes. + * The message is retained and rides the same data-topic machinery as the + * select's state, so Home Assistant repopulates the attributes after an + * HA or broker restart with no extra code. + * + * @param json A valid JSON object (e.g. `{"key":1}`). + * @returns Returns `true` if the MQTT message has been published successfully. + */ + bool publishJsonAttributes(const char* json); + #ifdef ARDUINOHA_TEST inline HASerializerArray* getOptions() const { return _options; } @@ -147,6 +179,9 @@ class HASelect : public HABaseDeviceType /// The optimistic mode of the select (`true` - enabled, `false` - disabled). bool _optimistic; + /// Whether the JSON attributes topic is advertised in the discovery config. + bool _jsonAttributes; + /// The command callback that will be called when option is changed via the HA panel. HASELECT_CALLBACK(_commandCallback); }; diff --git a/lib/home-assistant-integration/src/device-types/HASensor.cpp b/lib/home-assistant-integration/src/device-types/HASensor.cpp index 7642a30d..d877f6a6 100644 --- a/lib/home-assistant-integration/src/device-types/HASensor.cpp +++ b/lib/home-assistant-integration/src/device-types/HASensor.cpp @@ -9,7 +9,8 @@ HASensor::HASensor(const char* uniqueId) : _deviceClass(nullptr), _forceUpdate(false), _icon(nullptr), - _unitOfMeasurement(nullptr) + _unitOfMeasurement(nullptr), + _jsonAttributes(false) { } @@ -19,13 +20,22 @@ bool HASensor::setValue(const char* value) return publishOnDataTopic(AHATOFSTR(HAStateTopic), value, true); } +bool HASensor::publishJsonAttributes(const char* json) +{ + if (!json) { + return false; + } + + return publishOnDataTopic(AHATOFSTR(HAJsonAttributesTopic), json, true); +} + void HASensor::buildSerializer() { if (_serializer || !uniqueId()) { return; } - _serializer = new HASerializer(this, 9); // 9 - max properties nb + _serializer = new HASerializer(this, 10); // 10 - max properties nb (incl. json_attr_t) _serializer->set(AHATOFSTR(HANameProperty), _name); _serializer->set(AHATOFSTR(HAUniqueIdProperty), _uniqueId); _serializer->set(AHATOFSTR(HADeviceClassProperty), _deviceClass); @@ -44,6 +54,10 @@ void HASensor::buildSerializer() _serializer->set(HASerializer::WithDevice); _serializer->set(HASerializer::WithAvailability); _serializer->topic(AHATOFSTR(HAStateTopic)); + + if (_jsonAttributes) { + _serializer->topic(AHATOFSTR(HAJsonAttributesTopic)); + } } void HASensor::onMqttConnected() diff --git a/lib/home-assistant-integration/src/device-types/HASensor.h b/lib/home-assistant-integration/src/device-types/HASensor.h index 74a290e1..b81606e6 100644 --- a/lib/home-assistant-integration/src/device-types/HASensor.h +++ b/lib/home-assistant-integration/src/device-types/HASensor.h @@ -67,6 +67,29 @@ class HASensor : public HABaseDeviceType inline void setUnitOfMeasurement(const char* unitOfMeasurement) { _unitOfMeasurement = unitOfMeasurement; } + /** + * Enables or disables publishing of JSON attributes for this sensor. + * When enabled, the discovery config advertises a `json_attributes_topic` + * (`json_attr_t`) so Home Assistant reads extra attributes from that topic, + * and publishJsonAttributes() publishes a retained JSON object onto it. + * Disabled by default; while disabled the discovery payload is unchanged. + * + * @param enabled `true` to advertise the JSON attributes topic. + */ + inline void setJsonAttributes(const bool enabled) + { _jsonAttributes = enabled; } + + /** + * Publishes the given JSON object as this sensor's attributes. + * The message is retained and rides the same data-topic machinery as the + * sensor's value, so Home Assistant repopulates the attributes after an + * HA or broker restart with no extra code. + * + * @param json A valid JSON object (e.g. `{"key":1}`). + * @returns Returns `true` if the MQTT message has been published successfully. + */ + bool publishJsonAttributes(const char* json); + protected: virtual void buildSerializer() override final; virtual void onMqttConnected() override; @@ -83,6 +106,9 @@ class HASensor : public HABaseDeviceType /// The unit of measurement for the sensor. It can be nullptr. const char* _unitOfMeasurement; + + /// Whether the JSON attributes topic is advertised in the discovery config. + bool _jsonAttributes; }; #endif diff --git a/lib/home-assistant-integration/src/device-types/HAText.cpp b/lib/home-assistant-integration/src/device-types/HAText.cpp new file mode 100644 index 00000000..33e69797 --- /dev/null +++ b/lib/home-assistant-integration/src/device-types/HAText.cpp @@ -0,0 +1,92 @@ +#include "HAText.h" +#ifndef EX_ARDUINOHA_TEXT + +#include "../HAMqtt.h" +#include "../utils/HASerializer.h" + +HAText::HAText(const char* uniqueId) : + HABaseDeviceType(AHATOFSTR(HAComponentText), uniqueId), + _icon(nullptr), + _retain(false), + _messageCallback(nullptr), + _jsonAttributes(false) +{ + +} + +bool HAText::setState(const char* value, bool force) +{ + return publishOnDataTopic(AHATOFSTR(HAStateTopic), value, true); +} + +bool HAText::publishJsonAttributes(const char* json) +{ + if (!json) { + return false; + } + + return publishOnDataTopic(AHATOFSTR(HAJsonAttributesTopic), json, true); +} + +void HAText::buildSerializer() +{ + if (_serializer || !uniqueId()) { + return; + } + + _serializer = new HASerializer(this, 9); // 9 - max properties nb (incl. json_attr_t) + _serializer->set(AHATOFSTR(HANameProperty), _name); + _serializer->set(AHATOFSTR(HAUniqueIdProperty), _uniqueId); + _serializer->set(AHATOFSTR(HAIconProperty), _icon); + + if (_retain) { + _serializer->set( + AHATOFSTR(HARetainProperty), + &_retain, + HASerializer::BoolPropertyType + ); + } + + _serializer->set(HASerializer::WithDevice); + _serializer->set(HASerializer::WithAvailability); + _serializer->topic(AHATOFSTR(HAStateTopic)); + _serializer->topic(AHATOFSTR(HACommandTopic)); + + if (_jsonAttributes) { + _serializer->topic(AHATOFSTR(HAJsonAttributesTopic)); + } +} + +void HAText::onMqttConnected() +{ + if (!uniqueId()) { + return; + } + + publishConfig(); + publishAvailability(); + setState("", true); + subscribeTopic(uniqueId(), AHATOFSTR(HACommandTopic)); +} + +void HAText::onMqttMessage( + const char* topic, + const uint8_t* payload, + const uint16_t length +) +{ + if (_messageCallback && HASerializer::compareDataTopics( + topic, + uniqueId(), + AHATOFSTR(HACommandTopic) + )) { + static constexpr uint16_t kMaxMsg = 255; + char buf[kMaxMsg + 1]; + uint16_t copyLen = length > kMaxMsg ? kMaxMsg : length; + memcpy(buf, payload, copyLen); + buf[copyLen] = '\0'; + _messageCallback(buf, copyLen, this); + } +} + +#endif diff --git a/lib/home-assistant-integration/src/device-types/HAText.h b/lib/home-assistant-integration/src/device-types/HAText.h new file mode 100644 index 00000000..597564fe --- /dev/null +++ b/lib/home-assistant-integration/src/device-types/HAText.h @@ -0,0 +1,68 @@ +#ifndef AHA_HATEXT_H +#define AHA_HATEXT_H + +#include "HABaseDeviceType.h" + +#ifndef EX_ARDUINOHA_TEXT + +#define HATEXT_CALLBACK(name) void (*name)(const char* message, uint16_t length, HAText* sender) + +class HAText : public HABaseDeviceType +{ +public: + HAText(const char* uniqueId); + + inline void setIcon(const char* icon) + { _icon = icon; } + + inline void setRetain(const bool retain) + { _retain = retain; } + + inline void onMessage(HATEXT_CALLBACK(callback)) + { _messageCallback = callback; } + + bool setState(const char* value, bool force = false); + + /** + * Enables or disables publishing of JSON attributes for this text entity. + * When enabled, the discovery config advertises a `json_attributes_topic` + * (`json_attr_t`) so Home Assistant reads extra attributes from that topic, + * and publishJsonAttributes() publishes a retained JSON object onto it. + * Disabled by default; while disabled the discovery payload is unchanged. + * + * @param enabled `true` to advertise the JSON attributes topic. + */ + inline void setJsonAttributes(const bool enabled) + { _jsonAttributes = enabled; } + + /** + * Publishes the given JSON object as this text entity's attributes. + * The message is retained and rides the same data-topic machinery as the + * entity's state, so Home Assistant repopulates the attributes after an + * HA or broker restart with no extra code. + * + * @param json A valid JSON object (e.g. `{"key":1}`). + * @returns Returns `true` if the MQTT message has been published successfully. + */ + bool publishJsonAttributes(const char* json); + +protected: + virtual void buildSerializer() override; + virtual void onMqttConnected() override; + virtual void onMqttMessage( + const char* topic, + const uint8_t* payload, + const uint16_t length + ) override; + +private: + const char* _icon; + bool _retain; + HATEXT_CALLBACK(_messageCallback); + + /// Whether the JSON attributes topic is advertised in the discovery config. + bool _jsonAttributes; +}; + +#endif +#endif diff --git a/lib/home-assistant-integration/src/utils/HADictionary.cpp b/lib/home-assistant-integration/src/utils/HADictionary.cpp index 8fd8024b..61a9f259 100644 --- a/lib/home-assistant-integration/src/utils/HADictionary.cpp +++ b/lib/home-assistant-integration/src/utils/HADictionary.cpp @@ -18,6 +18,7 @@ const char HAComponentScene[] PROGMEM = {"scene"}; const char HAComponentFan[] PROGMEM = {"fan"}; const char HAComponentLight[] PROGMEM = {"light"}; const char HAComponentClimate[] PROGMEM = {"climate"}; +const char HAComponentText[] PROGMEM = {"text"}; // decorators const char HASerializerSlash[] PROGMEM = {"/"}; @@ -102,6 +103,7 @@ const char HATemperatureCommandTopic[] PROGMEM = {"temp_cmd_t"}; const char HATemperatureStateTopic[] PROGMEM = {"temp_stat_t"}; const char HARGBCommandTopic[] PROGMEM = {"rgb_cmd_t"}; const char HARGBStateTopic[] PROGMEM = {"rgb_stat_t"}; +const char HAJsonAttributesTopic[] PROGMEM = {"json_attr_t"}; // misc const char HAOnline[] PROGMEM = {"online"}; diff --git a/lib/home-assistant-integration/src/utils/HADictionary.h b/lib/home-assistant-integration/src/utils/HADictionary.h index 9ca13e94..2d1bb4ca 100644 --- a/lib/home-assistant-integration/src/utils/HADictionary.h +++ b/lib/home-assistant-integration/src/utils/HADictionary.h @@ -18,6 +18,7 @@ extern const char HAComponentScene[]; extern const char HAComponentFan[]; extern const char HAComponentLight[]; extern const char HAComponentClimate[]; +extern const char HAComponentText[]; // decorators extern const char HASerializerSlash[]; @@ -102,6 +103,7 @@ extern const char HATemperatureCommandTopic[]; extern const char HATemperatureStateTopic[]; extern const char HARGBCommandTopic[]; extern const char HARGBStateTopic[]; +extern const char HAJsonAttributesTopic[]; // misc extern const char HAOnline[]; diff --git a/src/Apps.cpp b/src/Apps.cpp index 378915e7..01db3b25 100644 --- a/src/Apps.cpp +++ b/src/Apps.cpp @@ -11,6 +11,10 @@ #include "MQTTManager.h" #include "Overlays.h" #include "timer.h" +#include "TimerManager.h" +#include "TimerView.h" +#include "Globals.h" +#include "DisplayManager.h" const uint8_t bigdigits_mask[12][7] = { {132, 48, 48, 48, 48, 48, 132}, // 0 @@ -413,6 +417,137 @@ void BatApp(FastLED_NeoMatrix *matrix, MatrixDisplayUiState *state, int16_t x, i } #endif +namespace { + // Painter-side layout (font-/draw-dependent). The view-model owns the rest + // of the timer geometry (text region, progress bar) — see src/TimerView.cpp. + constexpr int16_t kTimerTextY = 6; // text baseline row + constexpr int16_t kTimerScreenH = 8; // panel height (bottom row = H-1) + constexpr int kTimerConfigUnderlineStep = 10; // px between config fields +} + +static void drawTimerIcon(FastLED_NeoMatrix *matrix, int16_t x, int16_t y, TimerState state, GifPlayer *gifPlayer) +{ + static String cachedName = "\x01"; + static uint32_t cachedEpoch = 0; + static File icon; + static bool isGif = false; + static uint8_t currentFrame = 0; + static GifPlayer *lastPlayer = nullptr; + + const String &name = TimerManager.getIconForState(state); + + if (name != cachedName || cachedEpoch != g_littlefsMountEpoch) + { + cachedEpoch = g_littlefsMountEpoch; + cachedName = name; + if (icon) icon.close(); + isGif = false; + currentFrame = 0; + lastPlayer = nullptr; + + if (name.length() > 0) + { + const char *extensions[] = {".jpg", ".gif"}; + for (int i = 0; i < 2; i++) + { + String filePath = "/ICONS/" + name + extensions[i]; + if (LittleFS.exists(filePath)) + { + isGif = (i == 1); + icon = LittleFS.open(filePath); + break; + } + } + } + } + + if (icon) + { + if (isGif && gifPlayer != nullptr) + { + if (gifPlayer != lastPlayer) + { + icon.seek(0); + currentFrame = 0; + lastPlayer = gifPlayer; + } + gifPlayer->playGif(x, y, &icon, currentFrame); + currentFrame = gifPlayer->getFrame(); + return; + } + if (!isGif) + { + DisplayManager.drawJPG(x, y, icon); + return; + } + } + + // No configured/resolvable icon for this state: fall back to the built-in + // colour hourglass bitmap (src/icons.h), the same glyph the on-device menu + // uses for the Timer entry. Drawn at logical (x, y) via drawRGBBitmap to + // match the JPG path's coordinate convention (jpg_output in DisplayManager). + matrix->drawRGBBitmap(x, y, icon_timer, 8, 8); +} + +void TimerApp(FastLED_NeoMatrix *matrix, MatrixDisplayUiState *state, int16_t x, int16_t y, GifPlayer *gifPlayer) +{ + if (notifyFlag) + return; + + CURRENT_APP = "Timer"; + currentCustomApp = ""; + + // What to draw is decided by the display-free view-model; this function is + // its painter (font-dependent centering + the actual draws). See TimerView.h. + // Capture the live TimerManager state into an explicit snapshot once; this is + // the only place that maps manager state into the display-free view input. + const TimerSnapshot snap{ + TimerManager.getState(), TimerManager.getDuration(), TimerManager.getRemaining(), + TimerManager.getRunDuration(), TIMER_ICON_ENABLED, millis()}; + const TimerView view = TimerViewModel::compute(snap); + + // Config and Finished pin the app in the rotation while they're on screen. + if (view.screen == TimerView::Screen::Config || view.screen == TimerView::Screen::Finished) + state->ticksSinceLastStateSwitch = 0; + + DisplayManager.getInstance().resetTextColor(); + + // Icon on every screen except Config (which centers text over the full panel), + // and only when icon_enabled is on. view.showIcon folds both conditions in. + if (view.showIcon) + drawTimerIcon(matrix, x, y, TimerManager.getState(), gifPlayer); + + // Text, centered within the view's region. getTextWidth (font metrics) is + // the one display dependency that stays painter-side. + int16_t textX = view.textRegionX0; + if (view.showText) + { + textX = view.textRegionX0 + ((view.textRegionW - (int)getTextWidth(view.text, 0)) / 2); + DisplayManager.setTextColor(TEXTCOLOR_888); + DisplayManager.printText(textX + x, kTimerTextY + y, view.text, false, 0); + } + + // Config-mode field underline, offset from the centered text. + if (view.showUnderline) + { + int underlineX = textX + (view.underlineField * kTimerConfigUnderlineStep); + matrix->drawFastHLine(underlineX + x, (kTimerScreenH - 1) + y, 8, TEXTCOLOR_888); + } + + // Background track behind the bar (ADR-0020): the full trough, painted first so + // the foreground draws over it. Persists while the bar is active (even when the + // foreground has drained to nothing). A black bar_bg_color (the default) is off + // pixels, so it is skipped -- the bar then looks exactly as it did pre-ADR-0020. + if (view.showBarTrack && TIMER_BAR_ENABLED && TIMER_BAR_BG_COLOR) + matrix->drawFastHLine(view.barTrackStartX + x, (kTimerScreenH - 1) + y, + view.barTrackLen, TIMER_BAR_BG_COLOR); + + // Progress bar (right-anchored, drains from the left). + if (view.showBar && TIMER_BAR_ENABLED) + matrix->drawFastHLine(view.barStartX + x, (kTimerScreenH - 1) + y, view.barLen, + TIMER_BAR_COLOR ? TIMER_BAR_COLOR : TEXTCOLOR_888); +} + String replacePlaceholders(String text) { int start = 0; diff --git a/src/Apps.h b/src/Apps.h index 9e44bb9a..2e9c33d8 100644 --- a/src/Apps.h +++ b/src/Apps.h @@ -85,6 +85,8 @@ void HumApp(FastLED_NeoMatrix *matrix, MatrixDisplayUiState *state, int16_t x, i void BatApp(FastLED_NeoMatrix *matrix, MatrixDisplayUiState *state, int16_t x, int16_t y, GifPlayer *gifPlayer); #endif +void TimerApp(FastLED_NeoMatrix *matrix, MatrixDisplayUiState *state, int16_t x, int16_t y, GifPlayer *gifPlayer); + void ShowCustomApp(String name, FastLED_NeoMatrix *matrix, MatrixDisplayUiState *state, int16_t x, int16_t y, GifPlayer *gifPlayer); // Unattractive to have a function for every customapp which does the same, but currently still no other option found TODO diff --git a/src/Dictionary.cpp b/src/Dictionary.cpp index e48bce0e..082abb24 100644 --- a/src/Dictionary.cpp +++ b/src/Dictionary.cpp @@ -143,6 +143,7 @@ const char HAipAddrIcon[] PROGMEM = {"mdi:wifi"}; + #ifndef awtrix2_upgrade const char BatKey[] PROGMEM = {"bat"}; const char BatRawKey[] PROGMEM = {"bat_raw"}; diff --git a/src/DisplayManager.cpp b/src/DisplayManager.cpp index b151f207..55682958 100644 --- a/src/DisplayManager.cpp +++ b/src/DisplayManager.cpp @@ -5,6 +5,7 @@ #include "Globals.h" #include "PeripheryManager.h" #include "MQTTManager.h" +#include "TimerHaHost.h" #include "GifPlayer.h" #include #include "timer.h" @@ -22,6 +23,7 @@ #include #include "base64.hpp" #include "Games/GameManager.h" +#include "TimerManager.h" unsigned long lastArtnetStatusTime = 0; const int numberOfChannels = 256 * 3; @@ -1113,6 +1115,7 @@ void DisplayManager_::loadNativeApps() #ifdef ULANZI updateApp("Battery", BatApp, SHOW_BAT, 4); #endif + updateApp("Timer", TimerApp, SHOW_TIMER, Apps.size()); ui->setApps(Apps); setAutoTransition(true); @@ -2059,6 +2062,7 @@ String DisplayManager_::getSettings() doc["HUM"] = SHOW_HUM; doc["TEMP"] = SHOW_TEMP; doc["BAT"] = SHOW_BAT; + doc["TIMER"] = SHOW_TIMER; doc["VOL"] = SOUND_VOLUME; doc["OVERLAY"] = getOverlayName(); String jsonString; @@ -2128,11 +2132,13 @@ void DisplayManager_::setNewSettings(const char *json) UPPERCASE_LETTERS = doc.containsKey("UPPERCASE") ? doc["UPPERCASE"].as() : UPPERCASE_LETTERS; SHOW_WEEKDAY = doc.containsKey("WD") ? doc["WD"].as() : SHOW_WEEKDAY; BLOCK_NAVIGATION = doc.containsKey("BLOCKN") ? doc["BLOCKN"].as() : BLOCK_NAVIGATION; + bool prevShowTimer = SHOW_TIMER; SHOW_TIME = doc.containsKey("TIM") ? doc["TIM"].as() : SHOW_TIME; SHOW_DATE = doc.containsKey("DAT") ? doc["DAT"].as() : SHOW_DATE; SHOW_HUM = doc.containsKey("HUM") ? doc["HUM"].as() : SHOW_HUM; SHOW_TEMP = doc.containsKey("TEMP") ? doc["TEMP"].as() : SHOW_TEMP; SHOW_BAT = doc.containsKey("BAT") ? doc["BAT"].as() : SHOW_BAT; + SHOW_TIMER = doc.containsKey("TIMER") ? doc["TIMER"].as() : SHOW_TIMER; SOUND_ACTIVE = doc.containsKey("SOUND") ? doc["SOUND"].as() : SOUND_ACTIVE; if (doc.containsKey("VOL")) @@ -2251,6 +2257,20 @@ void DisplayManager_::setNewSettings(const char *json) } doc.clear(); applyAllSettings(); + TimerManager.onShowTimerChange(prevShowTimer, SHOW_TIMER); + if (prevShowTimer && !SHOW_TIMER) + { + TimerHaHost.remove(); + } + else if (!prevShowTimer && SHOW_TIMER) + { + TimerHaHost.enable(); + } + if (prevShowTimer != SHOW_TIMER) + { + SHOW_TIMER_HA_PREV = SHOW_TIMER; + } + loadNativeApps(); saveSettings(); if (DEBUG_MODE) DEBUG_PRINTLN("Settings loaded"); diff --git a/src/Globals.cpp b/src/Globals.cpp index 4390b762..3ac9877d 100644 --- a/src/Globals.cpp +++ b/src/Globals.cpp @@ -1,5 +1,6 @@ #include "Globals.h" #include "Preferences.h" +#include "TimerSettings.h" #include #include #include @@ -206,6 +207,23 @@ void loadDevSettings() BUTTON_CALLBACK = doc["button_callback"].as(); } + if (doc.containsKey("show_timer")) + { + SHOW_TIMER = doc["show_timer"].as(); + } + + // Timer value-config keys: validated + applied per-key best-effort from the + // single TIMER_SETTINGS_DESCS table (ranges live there, once). dev.json is a + // boot override layer, so an invalid key is skipped, not atomic-rejected. + timerSettingsLoadDevJson(doc.as()); + + // Timer state icons stay member-backed (B1, ADR-0007); their dev.json shadows + // seed the TimerManager members at setup(). + if (doc.containsKey("timer_icon_idle")) TIMER_ICON_IDLE = doc["timer_icon_idle"].as(); + if (doc.containsKey("timer_icon_running")) TIMER_ICON_RUNNING = doc["timer_icon_running"].as(); + if (doc.containsKey("timer_icon_paused")) TIMER_ICON_PAUSED = doc["timer_icon_paused"].as(); + if (doc.containsKey("timer_icon_finished")) TIMER_ICON_FINISHED = doc["timer_icon_finished"].as(); + if (doc.containsKey("color_correction")) { auto correction = doc["color_correction"]; @@ -283,6 +301,10 @@ void loadSettings() SHOW_DATE = Settings.getBool("DAT", false); SHOW_TEMP = Settings.getBool("TEMP", true); SHOW_HUM = Settings.getBool("HUM", true); + SHOW_TIMER = Settings.getBool("TIMER", true); + SHOW_TIMER_HA_PREV = Settings.getBool("TIMERPREV", true); + Settings.remove("TSTEP"); // removed timer_step feature; clean orphaned NVS key + timerSettingsLoadNvs(Settings); // value-config table keys (TFHOLD..TSYNT); defaults live in TIMER_SETTINGS_DESCS MATRIX_LAYOUT = Settings.getUInt("MAT", 0); SCROLL_SPEED = Settings.getUInt("SSPEED", 100); #ifdef ULANZI @@ -333,6 +355,9 @@ void saveSettings() Settings.putBool("DAT", SHOW_DATE); Settings.putBool("TEMP", SHOW_TEMP); Settings.putBool("HUM", SHOW_HUM); + Settings.putBool("TIMER", SHOW_TIMER); + Settings.putBool("TIMERPREV", SHOW_TIMER_HA_PREV); + timerSettingsSaveNvs(Settings); // value-config table keys (TFHOLD..TSYNT) Settings.putUInt("SSPEED", SCROLL_SPEED); #ifdef ULANZI Settings.putBool("BAT", SHOW_BAT); @@ -354,6 +379,7 @@ uint16_t MQTT_PORT = 1883; String MQTT_USER; String MQTT_PASS; String MQTT_PREFIX; +uint32_t g_littlefsMountEpoch = 0; bool IO_BROKER = false; bool NET_STATIC = false; bool SHOW_TIME = true; @@ -446,6 +472,17 @@ bool MOODLIGHT_MODE; long STATS_INTERVAL = 10000; bool DEBUG_MODE = true; uint8_t MIN_BRIGHTNESS = 2; +bool SHOW_TIMER = true; +bool SHOW_TIMER_HA_PREV = true; +// The 13 table-backed timer settings (max_duration, melodies, bar/sync, etc.) are +// DEFINED in TimerSettings.cpp beside the descriptor table whose storage pointers +// reference them (#143). The extern decls stay in Globals.h. These four icon_ +// values are member-backed (owned by TimerManager's setters, not the table), so they +// stay here. +String TIMER_ICON_IDLE = ""; +String TIMER_ICON_RUNNING = ""; +String TIMER_ICON_PAUSED = ""; +String TIMER_ICON_FINISHED = ""; uint8_t MAX_BRIGHTNESS = 160; double movementFactor = 0.5; int8_t TRANS_EFFECT = 1; diff --git a/src/Globals.h b/src/Globals.h index 87b344a4..6421752d 100644 --- a/src/Globals.h +++ b/src/Globals.h @@ -150,4 +150,25 @@ extern OverlayEffect GLOBAL_OVERLAY; extern String HOSTNAME; extern int WEB_PORT; extern bool BUZ_VOL; +extern bool SHOW_TIMER; +extern bool SHOW_TIMER_HA_PREV; +extern uint32_t TIMER_MAX_DURATION; +extern uint16_t TIMER_PUBLISH_INTERVAL; +extern uint16_t TIMER_FINISHED_HOLD; +extern uint16_t TIMER_REALERT_INTERVAL; +extern uint16_t TIMER_COUNTDOWN_SECONDS; +extern String TIMER_ICON_IDLE; +extern String TIMER_ICON_RUNNING; +extern String TIMER_ICON_PAUSED; +extern String TIMER_ICON_FINISHED; +extern String TIMER_MELODY_TICK; +extern String TIMER_MELODY_END; +extern bool TIMER_BAR_ENABLED; +extern bool TIMER_ICON_ENABLED; +extern uint32_t TIMER_BAR_COLOR; +extern uint32_t TIMER_BAR_BG_COLOR; +extern bool TIMER_SYNC_FOLLOW; +extern String TIMER_SYNC_TARGETS; + +extern uint32_t g_littlefsMountEpoch; #endif // Globals_H diff --git a/src/MQTTManager.cpp b/src/MQTTManager.cpp index 6dba2c97..8cc12672 100644 --- a/src/MQTTManager.cpp +++ b/src/MQTTManager.cpp @@ -9,12 +9,18 @@ #include "PeripheryManager.h" #include "UpdateManager.h" #include "PowerManager.h" +#include "TimerManager.h" +#include "TimerHa.h" +#include "TimerHaHost.h" const uint16_t PORT = 1883; +// kMaxHAEntities (the HA entity registration cap) now lives in MQTTManager.h so +// TimerHaHost's carrier build can share it for the haRegistrationAtCap guard. + WiFiClient espClient; HADevice device; -HAMqtt mqtt(espClient, device, 26); +HAMqtt mqtt(espClient, device, kMaxHAEntities); HALight *Matrix, *Indikator1, *Indikator2, *Indikator3 = nullptr; HASelect *BriMode, *transEffect = nullptr; @@ -25,8 +31,20 @@ HASensor *battery = nullptr; #endif HASensor *temperature, *humidity, *illuminance, *uptime, *strength, *version, *ram, *curApp, *myOwnID, *ipAddr = nullptr; HABinarySensor *btnleft, *btnmid, *btnright = nullptr; +// The ten Timer HA carrier pointers and their resolved id buffers moved to +// TimerHaHost (issue #193 / PRD #191); the wire seam reaches ids via TimerHaHost.entityId(). bool connected; char matID[40], ind1ID[40], ind2ID[40], ind3ID[40], briID[40], btnAID[40], btnBID[40], btnCID[40], appID[40], tempID[40], humID[40], luxID[40], verID[40], ramID[40], upID[40], sigID[40], btnLID[40], btnMID[40], btnRID[40], transID[40], doUpdateID[40], batID[40], myID[40], sSpeed[40], effectID[40], ipAddrID[40]; +// The SHOW_TIMER reconcile bookkeeping and its pending-cleanup latch moved to +// TimerHaHost (issue #195); MQTTManager now holds zero Timer-HA-specific state. + +// Forward declarations: the shared HA command callbacks are defined further down; +// the base entities in setup() wire them, and each now delegates its Timer branch +// to TimerHaHost via a leading tryHandle* guard. +void onButtonCommand(HAButton *sender); +void onSelectCommand(int8_t index, HASelect *sender); +void onSwitchCommand(bool state, HASwitch *sender); + long previousMillis_Stats; std::map mqttValues; std::vector topicsToSubscribe; @@ -86,6 +104,18 @@ void processMqttMessage(const String &strTopic, const String &payloadCopy) return; } + { + size_t plen = MQTT_PREFIX.length(); + const char *t = strTopic.c_str(); + if (strTopic.length() > plen + && strncmp(t, MQTT_PREFIX.c_str(), plen) == 0 + && strcmp(t + plen, "/timer") == 0) + { + TimerManager.parseCommand(payloadCopy.c_str()); + return; + } + } + if (strTopic.equals(MQTT_PREFIX + "/sendscreen")) { MQTTManager.getInstance().publish("screen", DisplayManager.ledsAsJson().c_str()); @@ -224,6 +254,7 @@ void processMqttMessage(const String &strTopic, const String &payloadCopy) void onButtonCommand(HAButton *sender) { + if (TimerHaHost.tryHandleButton(sender)) return; // Timer carrier: routed by the host if (sender == dismiss) { DisplayManager.dismissNotify(); @@ -247,6 +278,7 @@ void onButtonCommand(HAButton *sender) void onSwitchCommand(bool state, HASwitch *sender) { + if (TimerHaHost.tryHandleSwitch(state, sender)) return; // Timer carrier: routed by the host AUTO_TRANSITION = state; DisplayManager.setAutoTransition(state); saveSettings(); @@ -255,6 +287,7 @@ void onSwitchCommand(bool state, HASwitch *sender) void onSelectCommand(int8_t index, HASelect *sender) { + if (TimerHaHost.tryHandleSelect(sender, index)) return; // Timer carrier: routed by the host if (sender == BriMode) { switch (index) @@ -330,6 +363,9 @@ void onBrightnessCommand(uint8_t brightness, HALight *sender) DisplayManager.setBrightness(brightness); } +// onTimerDurationMessage moved to TimerHaHost (issue #193): the dedicated Timer +// duration text callback is registered on the Duration carrier by the host. + void onNumberCommand(HANumeric number, HANumber *sender) { if (!number.isSet()) @@ -379,6 +415,15 @@ void onMqttConnected() if (DEBUG_MODE) DEBUG_PRINTLN(F("MQTT Connected")); + + // Command topics must never carry a retained payload. A retained + // {prefix}/timer command (e.g. {"action":"start"}) is re-delivered by the + // broker on every (re)connect and would auto-start the timer on boot, which + // breaks the "a reboot returns the device to Idle" contract (docs/timer.md). + // PubSubClient doesn't surface the retain flag to the receive callback, so we + // purge the retained command at the source: clear it before subscribing. + mqtt.publish((MQTT_PREFIX + "/timer").c_str(), "", true); + const char *topics[] PROGMEM = { "/brightness", "/notify/dismiss", @@ -403,7 +448,8 @@ void onMqttConnected() "/sound", "/rtttl", "/sendscreen", - "/r2d2"}; + "/r2d2", + "/timer"}; for (const char *topic : topics) { if (DEBUG_MODE) @@ -424,6 +470,10 @@ void onMqttConnected() { myOwnID->setValue(MQTT_PREFIX.c_str()); version->setValue(VERSION); + + // onConnected() also flushes the pending discovery cleanup reconcile() latched + // when SHOW_TIMER went off across a reboot (issue #195). + TimerHaHost.onConnected(); // Timer wire + attribute groups when SHOW_TIMER (issue #193) } MQTTManager.publish("stats/effects", DisplayManager.getEffectNames().c_str()); @@ -729,6 +779,12 @@ void MQTTManager_::setup() ipAddr = new HASensor(ipAddrID); ipAddr->setName(HAipAddrName); ipAddr->setIcon(HAipAddrIcon); + + // Resolve the Timer carrier ids and (when SHOW_TIMER) construct/register the + // carriers — at the same sequence point as before (after the device's own + // entities register), so the ArduinoHA registration/entity-cap drop order is + // unchanged. The carrier lifecycle now lives in TimerHaHost (issue #193). + TimerHaHost.setup(); } else { @@ -753,6 +809,54 @@ void MQTTManager_::tick() } } +// The Timer wire seam (issue #31): publishes the exact (topic, payload) the +// caller hands over — retained, like the HASensor::setValue path it replaces. +// Gated on the Timer HA carriers existing (TimerHaHost.carriersReady() mirrors the +// old timerDuration creation-sentinel check), which preserves the per-entity +// null-check behaviour of the retired one-liner publish methods; mqtt.publish +// itself no-ops while disconnected, as beginPublish did before. +void MQTTManager_::publishTimerWire(const char *topic, const char *payload) +{ + if (!TimerHaHost.carriersReady()) return; + mqtt.publish(topic, payload, true); +} + +// Canonical full data topic for a Timer HA entity slot, from the same inputs +// ArduinoHA's HASerializer::generateDataTopic uses: the data prefix installed +// in setup() (MQTT_PREFIX), the device unique id, and the entity id buffer +// resolved through formatTimerHaEntityId (owned by TimerHaHost, read via +// entityId()). Byte-identity is pinned by test W1. +String MQTTManager_::timerWireTopic(TimerHaEntity slot) +{ + const char *deviceUniqueId = device.getUniqueId(); + if (!deviceUniqueId) return String(); + char topic[160]; + formatTimerHaDataTopic(MQTT_PREFIX.c_str(), deviceUniqueId, TimerHaHost.entityId(slot), topic, sizeof(topic)); + return String(topic); +} + +// A carrier entity's JSON-attributes topic (PRD #57, generalizing the issue-#51 +// finished-only topic): same inputs as timerWireTopic but the json_attr_t suffix, +// so it matches the topic the carrier's HASelect advertised in discovery via +// setJsonAttributes. The retained attribute value rides the wire seam to here. +String MQTTManager_::timerWireAttrTopic(TimerHaEntity slot) +{ + const char *deviceUniqueId = device.getUniqueId(); + if (!deviceUniqueId) return String(); + char topic[160]; + formatTimerHaAttrTopic(MQTT_PREFIX.c_str(), deviceUniqueId, + TimerHaHost.entityId(slot), topic, sizeof(topic)); + return String(topic); +} + +// Canonical topic for the aggregate icons JSON (issue #34) — the one published +// Timer topic that is NOT an HA entity data topic. The host-test stub mirrors +// this from its fixture prefix, so tests pin the same spelling. +String MQTTManager_::timerIconsTopic() +{ + return MQTT_PREFIX + "/timer/icons"; +} + void MQTTManager_::publish(const char *topic, const char *payload) { char result[100]; diff --git a/src/MQTTManager.h b/src/MQTTManager.h index 4ac9c4fe..e5deb903 100644 --- a/src/MQTTManager.h +++ b/src/MQTTManager.h @@ -4,6 +4,19 @@ #include #include +#include "TimerHa.h" + +// HA entity registration cap. ArduinoHA's HAMqtt::addDeviceType has an off-by-one +// (`_devicesTypesNb + 1 >= _maxDevicesTypesNb`), so the EFFECTIVE capacity is +// kMaxHAEntities - 1 (= 39 here). Inventory: 25 base entities (incl. battery on +// ulanzi) + 10 Timer entities (TIMER_HA_DESCRIPTOR_COUNT) = 35, leaving 4 spare +// slots. Raised from 34 (issue #125): at 34 the effective cap of 33 silently +// dropped the two Timer sync-control entities (the last to register). No clean +// compile-time guard — the base count is build-flag conditional — so the runtime +// guard is the DEBUG_MODE warning in TimerHaHost's carrier build via haRegistrationAtCap(). +// Shared with TimerHaHost (the Timer carrier build reads it for that guard). +constexpr uint8_t kMaxHAEntities = 40; + class MQTTManager_ { private: @@ -27,7 +40,19 @@ class MQTTManager_ bool isConnected(); String getValueForTopic(const String &topic); + // The Timer wire seam (issue #31 / PRD #28): the single chokepoint through + // which Timer MQTT output flows as (topic, payload) strings. On device it + // reaches the broker (retained, like the HA setValue path it replaces); the + // host-test stub records the pair. timerWireTopic() sources an entity's + // canonical data topic — byte-identical to what ArduinoHA emits. + void publishTimerWire(const char *topic, const char *payload); + String timerWireTopic(TimerHaEntity slot); + String timerWireAttrTopic(TimerHaEntity slot); + String timerIconsTopic(); }; + +void reconcileTimerHAState(); + extern MQTTManager_ &MQTTManager; #endif \ No newline at end of file diff --git a/src/MatrixDisplayUi.cpp b/src/MatrixDisplayUi.cpp index 0c460ae4..258d2021 100644 --- a/src/MatrixDisplayUi.cpp +++ b/src/MatrixDisplayUi.cpp @@ -188,7 +188,7 @@ int8_t MatrixDisplayUi::update() if (timeBudget <= 0) { // Implement frame skipping to ensure time budget is kept - if (this->setAutoTransition && this->state.lastUpdate != 0) + if (this->state.lastUpdate != 0) this->state.ticksSinceLastStateSwitch += ceil(-timeBudget / this->updateInterval); this->state.lastUpdate = appStart; diff --git a/src/MenuManager.cpp b/src/MenuManager.cpp index 6544eb7f..221837b2 100644 --- a/src/MenuManager.cpp +++ b/src/MenuManager.cpp @@ -5,8 +5,15 @@ #include #include #include "timer.h" +#include "TimerManager.h" +#include "TimerMenu.h" +#include "TimerMenuNav.h" +#include "TimerConfigEditor.h" +#include "MQTTManager.h" +#include "TimerHaHost.h" #include #include +#include "Functions.h" // getTextWidth (centering the duration leaf + its underline) enum MenuState { @@ -20,6 +27,7 @@ enum MenuState DateFormatMenu, WeekdayMenu, TempMenu, + TimerConfigMenu, Appmenu, SoundMenu, VolumeMenu, @@ -37,6 +45,7 @@ const char *menuItems[] PROGMEM = { "DATE", "WEEKDAY", "TEMP", + "TIMER", "APPS", "SOUND", "VOLUME", @@ -73,7 +82,44 @@ int8_t dateFormatIndex; uint8_t dateFormatCount = 9; int8_t appsIndex; +#ifndef awtrix2_upgrade +uint8_t appsCount = 6; +#else uint8_t appsCount = 5; +#endif + +uint8_t timerConfigCount = TIMER_MENU_SLOT_COUNT; +// The TIMER menu's drill-in navigation state machine (PRD #83 / issue #85). MAIN +// is the last slot (a Navigation row); the device keeps only drawing + the commit. +TimerMenuNav timerNav(TIMER_MENU_SLOT_COUNT, TIMER_MENU_SLOT_COUNT - 1); + +// The DURATION leaf reuses the existing display-free duration edit engine (#86), +// a menu-owned instance. The editor has no auto-apply timeout (#88); the menu only +// drives its hold-to-repeat, and the edited duration commits via setDuration on +// leaf back-out (run-state, not the list -> main config batch). +TimerConfigEditor timerDurationEditor; + +// Is the cursor on the DURATION row? +static bool timerNavOnDuration() +{ + return TIMER_MENU_SLOTS[timerNav.index()].kind == TimerMenuKind::Duration; +} + +// The one-shot TIMER-menu commit: a single PersistBatch window (enum edits +// deferred during scroll in "timer" ns + table-row knob/toggle keys in "awtrix" +// ns), then the HA attribute republish. Fires once on the list -> main-menu +// transition (long-press out of the list, or selecting MAIN). A config edit no +// longer propagates to peers (run-scoped config mirror, ADR-0018 superseding +// ADR-0006): config travels only bundled with a `start`. See ADR-0008/0015 and +// PRD #29/#45/#60. +static void commitTimerMenu() +{ + { + TimerManager_::PersistBatch batch(TimerManager); + batch.markTableDirty(); + } + TimerManager.publishAllAttributeGroups(); +} MenuState currentState = MainMenu; @@ -192,7 +238,12 @@ String MenuManager_::menutext() case 4: DisplayManager.drawBMP(0, 0, icon_1486, 8, 8); return SHOW_BAT ? "ON" : "OFF"; + case 5: +#else + case 4: #endif + DisplayManager.drawBMP(0, 0, icon_timer, 8, 8); + return SHOW_TIMER ? "ON" : "OFF"; default: break; } @@ -206,6 +257,43 @@ String MenuManager_::menutext() { return String(SOUND_VOLUME); } + case TimerConfigMenu: + // List focus: walk the named items (indicator over the list). Leaf focus: + // show the bare value only, no indicator (PRD #83). + if (timerNav.focus() == TimerNavFocus::List) + { + DisplayManager.drawMenuIndicator(timerNav.index(), timerConfigCount, 0xFBC000); + return timerMenuName(timerNav.index()); + } + // DURATION leaf: HH:MM:SS wheel with the active-field underline when + // editable; the static value (no underline) when read-only (#86). + if (timerNavOnDuration()) + { + if (timerDurationEditor.isActive()) + { + // Drive the editor's hold-to-repeat from the raw button reads each + // frame. The menu is timeout-free and the editor no longer has an + // auto-apply timeout (#88), so there is nothing else to handle. + EasyButton *bL = PeripheryManager.buttonL; + EasyButton *bR = PeripheryManager.buttonR; + TimerConfigEditor::ButtonState buttons{bL && bL->isPressed(), + bR && bR->isPressed()}; + timerDurationEditor.tick(millis(), buttons); + + snprintf(t, sizeof(t), "%02u:%02u:%02u", + (unsigned)timerDurationEditor.hh(), + (unsigned)timerDurationEditor.mm(), + (unsigned)timerDurationEditor.ss()); + // Active-field underline, aligned under the centered HH:MM:SS (the + // same step/geometry the Timer-app config screen uses). + int16_t textX = (32 - (int)getTextWidth(t, 2)) / 2; + int16_t ux = textX + timerDurationEditor.field() * 10; + DisplayManager.drawLine(ux, 7, ux + 7, 7, TEXTCOLOR_888); + return String(t); + } + return timerMenuValue(timerNav.index()); // read-only: current value + } + return timerMenuValue(timerNav.index()); default: break; } @@ -267,6 +355,17 @@ void MenuManager_::rightButton() else SOUND_VOLUME++; break; + case TimerConfigMenu: + { + // List focus walks the list; a value leaf steps its value live; the + // DURATION leaf steps the active H/M/S field; a read-only leaf is a no-op. + TimerNavOutcome o = timerNav.navigate(+1); + if (o == TimerNavOutcome::AdjustValue) + timerMenuAdjust(timerNav.index(), +1); + else if (o == TimerNavOutcome::AdjustField) + timerDurationEditor.adjust(+1); + break; + } default: break; } @@ -328,6 +427,16 @@ void MenuManager_::leftButton() SOUND_VOLUME = 30; else SOUND_VOLUME--; + break; + case TimerConfigMenu: + { + TimerNavOutcome o = timerNav.navigate(-1); + if (o == TimerNavOutcome::AdjustValue) + timerMenuAdjust(timerNav.index(), -1); + else if (o == TimerNavOutcome::AdjustField) + timerDurationEditor.adjust(-1); + break; + } default: break; } @@ -362,6 +471,11 @@ void MenuManager_::selectButton() UpdateManager.updateFirmware(); } break; + case TimerConfigMenu: + // Open the TIMER menu at the top of the list (origin = main menu). + timerNav.enter(TIMER_MENU_SLOT_COUNT, TIMER_MENU_SLOT_COUNT - 1, + TimerNavOrigin::Menu); + break; } break; case BrightnessMenu: @@ -372,6 +486,29 @@ void MenuManager_::selectButton() DisplayManager.setBrightness(BRIGHTNESS); } break; + case TimerConfigMenu: + // Short press. + if (timerNav.focus() == TimerNavFocus::List) + { + // Drill in (passing how the target leaf behaves), commit + leave on MAIN. + TimerNavLeaf leaf = timerMenuLeafKind(timerNav.index(), TimerManager.getState()); + TimerNavOutcome o = timerNav.select(leaf); + if (o == TimerNavOutcome::GoToMainMenu) + { + commitTimerMenu(); + currentState = MainMenu; + } + else if (o == TimerNavOutcome::EnterLeaf && leaf == TimerNavLeaf::DurationEditable) + { + timerDurationEditor.enter(TimerManager.getDuration()); + } + } + else if (timerNav.select() == TimerNavOutcome::CycleField) + { + // DURATION leaf: cycle H -> M -> S (value leaves just confirm back). + timerDurationEditor.cycleField(); + } + break; case Appmenu: switch (appsIndex) { @@ -391,7 +528,20 @@ void MenuManager_::selectButton() case 4: SHOW_BAT = !SHOW_BAT; break; + case 5: +#else + case 4: #endif + { + bool prev = SHOW_TIMER; + SHOW_TIMER = !SHOW_TIMER; + TimerManager.onShowTimerChange(prev, SHOW_TIMER); + if (prev && !SHOW_TIMER) + TimerHaHost.remove(); + else if (!prev && SHOW_TIMER) + TimerHaHost.enable(); + break; + } default: break; } @@ -455,6 +605,35 @@ void MenuManager_::selectButtonLong() PeripheryManager.setVolume(SOUND_VOLUME); saveSettings(); break; + case TimerConfigMenu: + { + // Long press: in a value/read-only leaf it just steps back up to the + // list (value already live in RAM, no commit). In the DURATION leaf it + // commits the edited duration (run-state, separate from the config + // batch) then returns to the list. Out of the list it is the single + // commit seam: main menu (origin = menu) or back to the Timer app + // (origin = app, #87). + TimerNavOutcome o = timerNav.back(); + if (o == TimerNavOutcome::CommitDuration) + { + // The normal set-duration commit path. A bare duration edit + // propagates NOTHING (#126): duration rides only with a start, so an + // on-device length edit no longer moves a follower's displayed time. + TimerManager.setDuration(timerDurationEditor.exit()); + return; // stay in the TIMER menu, list focus + } + if (o == TimerNavOutcome::BackToList) + return; // stay in the TIMER menu, list focus + commitTimerMenu(); // GoToMainMenu / ExitMenu: commit once + if (o == TimerNavOutcome::ExitMenu) + { + // Entered from the Timer app: close the menu so the app reappears. + inMenu = false; + currentState = MainMenu; + return; + } + break; // GoToMainMenu: falls through to MainMenu + } default: break; } @@ -465,3 +644,12 @@ void MenuManager_::selectButtonLong() inMenu = true; } } + +void MenuManager_::openTimerMenuFromApp() +{ + // Open the TIMER menu directly at the top of the list, origin = App so a + // long-press out of the list returns to the Timer app (issue #87). + inMenu = true; + currentState = TimerConfigMenu; + timerNav.enter(TIMER_MENU_SLOT_COUNT, TIMER_MENU_SLOT_COUNT - 1, TimerNavOrigin::App); +} diff --git a/src/MenuManager.h b/src/MenuManager.h index 8e058bb4..7e6c255e 100644 --- a/src/MenuManager.h +++ b/src/MenuManager.h @@ -17,6 +17,11 @@ class MenuManager_ void leftButton(); void selectButton(); void selectButtonLong(); + + // Open the TIMER menu directly from the Timer app's idle long-press (PRD #83 / + // issue #87): list focus, first item, origin = App so a long-press out of the + // list returns to the Timer app rather than the main menu. + void openTimerMenuFromApp(); }; extern MenuManager_ &MenuManager; diff --git a/src/Overlays.cpp b/src/Overlays.cpp index b36b03d8..7cbbf926 100644 --- a/src/Overlays.cpp +++ b/src/Overlays.cpp @@ -24,14 +24,66 @@ void StatusOverlay(FastLED_NeoMatrix *matrix, MatrixDisplayUiState *state, GifPl void MenuOverlay(FastLED_NeoMatrix *matrix, MatrixDisplayUiState *state, GifPlayer *gifPlayer) { + // Marquee state for over-wide menu labels (PRD #96). The position resets to the + // left whenever the displayed string changes (navigating to another item, or a + // leaf value ticking under the cursor) and on menu (re)entry, so the readable + // hold always plays from the start and a label is never caught mid-scroll. + // This is frame-stateful device drawing, kept inline per PRD #83's boundary. + static String lastText; + static float scrollPos = 0; // x of the label's left edge while scrolling + static int scrollDelay = 0; // pre-roll hold counter (frames), as notifications + static bool wasInMenu = false; if (!MenuManager.inMenu) + { + wasInMenu = false; return; - + } matrix->fillScreen(0); DisplayManager.setTextColor(0xFFFFFF); - DisplayManager.printText(0, 6, utf8ascii(MenuManager.menutext()).c_str(), true, 2); + + String text = utf8ascii(MenuManager.menutext()); + float textWidth = getTextWidth(text.c_str(), 2); + + // Fits the 32px panel: center it and hold still -- byte-for-byte today's path. + if (textWidth <= 32) + { + lastText = text; + wasInMenu = true; + DisplayManager.printText(0, 6, text.c_str(), true, 2); + return; + } + + // Over-wide: marquee. Restart from the left on (re)entry or a label change. + if (!wasInMenu || text != lastText) + { + lastText = text; + scrollPos = 0; + scrollDelay = 0; + } + wasInMenu = true; + + // Hold briefly at the left (mirrors the notification overlay's pre-roll gate), + // then scroll left at the device's shared scroll speed, reusing movementFactor + // for frame-rate independence (the same math three other call sites use). Loops + // continuously while the item stays selected. + if (scrollDelay > MATRIX_FPS * 1.2) + { + scrollPos -= movementFactor * ((float)SCROLL_SPEED / 100); + if (scrollPos + textWidth <= 0) // fully off the left edge -> loop from left + { + scrollPos = 0; + scrollDelay = 0; + } + } + else + { + ++scrollDelay; + scrollPos = 0; + } + + DisplayManager.printText((int16_t)scrollPos, 6, text.c_str(), false, 2); } void NotifyOverlay(FastLED_NeoMatrix *matrix, MatrixDisplayUiState *state, GifPlayer *gifPlayer) diff --git a/src/PeerRegistry.cpp b/src/PeerRegistry.cpp new file mode 100644 index 00000000..3d3419fc --- /dev/null +++ b/src/PeerRegistry.cpp @@ -0,0 +1,85 @@ +#include "PeerRegistry.h" + +// A bounded set of {uniqueID, lastSeen} learned from inbound presence beacons; +// the backend the dynamic HA Targets select (#112) consumes. Own id is never +// stored; entries age out past kPeerTtlMs. See ADR-0019 / ADR-0021. + +void PeerRegistry::record(const String &src, unsigned long nowMs) +{ + if (src.length() == 0 || src == _ownId) return; // never store our own id + unsigned long seen = (nowMs == 0) ? 1 : nowMs; // 0 doubles as "never" + + // Refresh an existing entry. + for (uint8_t i = 0; i < _count; ++i) + { + if (_peers[i].uniqueID == src) + { + _peers[i].lastSeen = seen; + return; + } + } + + // New peer: append while bounded; otherwise overwrite the stalest slot so a busy + // LAN keeps the freshest peers rather than rejecting all new ones once full. + if (_count < kPeerMax) + { + _peers[_count].uniqueID = src; + _peers[_count].lastSeen = seen; + ++_count; + return; + } + uint8_t oldest = 0; + for (uint8_t i = 1; i < _count; ++i) + if (_peers[i].lastSeen < _peers[oldest].lastSeen) oldest = i; + _peers[oldest].uniqueID = src; + _peers[oldest].lastSeen = seen; +} + +void PeerRegistry::prune(unsigned long nowMs) +{ + uint8_t w = 0; + for (uint8_t i = 0; i < _count; ++i) + { + if ((nowMs - _peers[i].lastSeen) <= kPeerTtlMs) + { + if (w != i) _peers[w] = _peers[i]; + ++w; + } + } + for (uint8_t i = w; i < _count; ++i) _peers[i].uniqueID = String(); + _count = w; +} + +bool PeerRegistry::has(const String &id) const +{ + for (uint8_t i = 0; i < _count; ++i) + if (_peers[i].uniqueID == id) return true; + return false; +} + +size_t PeerRegistry::ids(String *out, size_t cap) const +{ + // Sort the FULL set first, then copy the lowest `cap`, so a small buffer still + // gets a stable prefix of the sorted list (not just the first-discovered ids). + // The registry is tiny (kPeerMax ~16) so an O(n^2) selection sort on a local + // copy is cheaper than dragging in and stays allocation-light. + String sorted[kPeerMax]; + for (uint8_t i = 0; i < _count; ++i) sorted[i] = _peers[i].uniqueID; + for (uint8_t i = 0; i + 1 < _count; ++i) + { + uint8_t lo = i; + for (uint8_t j = i + 1; j < _count; ++j) + if (sorted[j] < sorted[lo]) lo = j; + if (lo != i) { String t = sorted[i]; sorted[i] = sorted[lo]; sorted[lo] = t; } + } + + size_t n = 0; + for (uint8_t i = 0; i < _count && n < cap; ++i) out[n++] = sorted[i]; + return n; +} + +void PeerRegistry::clear() +{ + for (uint8_t i = 0; i < _count; ++i) _peers[i].uniqueID = String(); + _count = 0; +} diff --git a/src/PeerRegistry.h b/src/PeerRegistry.h new file mode 100644 index 00000000..2cac0717 --- /dev/null +++ b/src/PeerRegistry.h @@ -0,0 +1,60 @@ +#ifndef PeerRegistry_h +#define PeerRegistry_h + +#include + +// Peer presence / peer registry (#111 / ADR-0019, extracted per ADR-0021). +// +// The set of *other clocks currently on the LAN*, keyed by the stable uniqueID +// (the targeting key; the mutable hostname is unsuitable). Pure RAM/LAN-derived +// state: bounded, the clock's own id excluded, entries age out after the TTL. +// Harvested UNGATED from inbound presence beacons by TimerManager (presence is +// informational, not a command) and consumed by the dynamic HA Targets select. +// +// This module is the *set* only — record / age / query. It deliberately knows +// nothing about beacon cadence, the UDP transport, or AP mode; that scheduling + +// I/O stays on TimerManager (one place owns "when do I announce myself"). The +// methods take an injected `nowMs` and the own-id is injected via setOwnId(), so +// there is no global read — the whole contract is host-testable in isolation, +// without standing up the TimerManager singleton. See CONTEXT.md +// "Peer presence / peer registry". +class PeerRegistry +{ +public: + // The clock's own uniqueID, so record() can drop our own echoed beacons. + // Set once from TimerManager::setup(); cheap to call again on reboot. + void setOwnId(const String &id) { _ownId = id; } + + // Add a peer or refresh its lastSeen. Our own id and the full-registry case + // are guarded: when full, the stalest slot is overwritten so a busy LAN keeps + // the freshest peers rather than rejecting all new ones. + void record(const String &src, unsigned long nowMs); + + // Drop entries not seen within kPeerTtlMs (~3 missed beacons). + void prune(unsigned long nowMs); + + // Is `id` currently a known peer? + bool has(const String &id) const; + + // Copy up to `cap` current peer uniqueIDs into out[], SORTED ascending, and + // return the count written (<= min(count(), cap)). The sort makes a consumer's + // option list stable regardless of discovery order. Never writes past cap. + size_t ids(String *out, size_t cap) const; + + int count() const { return _count; } + + // Forget every peer (a reboot knows no peers; the set is repopulated by inbound + // beacons). Own id is retained. + void clear(); + +private: + struct Peer { String uniqueID; unsigned long lastSeen = 0; }; + static constexpr uint8_t kPeerMax = 16; + static constexpr unsigned long kPeerTtlMs = 100000; // ~3 missed beacons + + Peer _peers[kPeerMax]; + uint8_t _count = 0; + String _ownId; +}; + +#endif diff --git a/src/PeripheryManager.cpp b/src/PeripheryManager.cpp index 3371231d..8d134760 100644 --- a/src/PeripheryManager.cpp +++ b/src/PeripheryManager.cpp @@ -18,6 +18,8 @@ #include #include #include +#include "TimerManager.h" +#include "TimerCommand.h" // TimerCommand::Action for the runStateAction seam (#222) const int buzzerPin = 2; // Buzzer an GPIO2 const int baudRate = 50; // Nachrichtenübertragungsrate const char *message = "HELLO"; // Die Nachricht, die gesendet werden soll @@ -174,6 +176,22 @@ void select_button_pressed() if (DFPLAYER_ACTIVE) PeripheryManager.playFromFile(DFMINI_MP3_CLICK); + if (!MenuManager.inMenu) + { + TimerState ts = TimerManager.getState(); + if (ts == TimerState::Finished) + { + TimerManager.runStateAction(TimerCommand::Action::Reset); + return; + } + if (CURRENT_APP == "Timer") + { + if (ts == TimerState::Running) { TimerManager.runStateAction(TimerCommand::Action::Pause); } + else { TimerManager.runStateAction(TimerCommand::Action::Start); } + return; + } + } + DisplayManager.selectButton(); MenuManager.selectButton(); if (DEBUG_MODE) @@ -206,6 +224,27 @@ void select_button_pressed_long() } else if (!BLOCK_NAVIGATION) { + if (!MenuManager.inMenu) + { + TimerState ts = TimerManager.getState(); + if (ts == TimerState::Finished) + { + TimerManager.runStateAction(TimerCommand::Action::Start); + return; + } + if (CURRENT_APP == "Timer" && ts == TimerState::Idle) + { + // Open the TIMER menu (origin = App) instead of the bare duration + // wheel; the wheel is now reachable only as the DURATION leaf (#87). + MenuManager.openTimerMenuFromApp(); + return; + } + if (CURRENT_APP == "Timer") + { + TimerManager.runStateAction(TimerCommand::Action::Reset); + return; + } + } MenuManager.selectButtonLong(); DisplayManager.selectButtonLong(); if (DEBUG_MODE) @@ -323,6 +362,38 @@ const char *PeripheryManager_::playRTTTLString(String rtttl) return nullptr; // RTTTL not supported with DFPlayer } +String PeripheryManager_::resolveRtttl(const String &name, const char *fallback) +{ + File root = LittleFS.open("/MELODIES"); + if (root && root.isDirectory()) + { + File file = root.openNextFile(); + while (file) + { + if (!file.isDirectory()) + { + String fn = file.name(); + int slash = fn.lastIndexOf('/'); + int dot = fn.lastIndexOf('.'); + int end = dot > slash ? dot : fn.length(); + String base = fn.substring(slash + 1, end); + if (base == name) + { + String s; + s.reserve(file.size()); + while (file.available()) s += (char)file.read(); + file.close(); + s.trim(); + if (s.length() > 0) return s; + break; // matched but empty -> fall through to fallback + } + } + file = root.openNextFile(); + } + } + return fallback ? String(fallback) : String(); +} + const char *PeripheryManager_::playFromFile(String file) { if (!SOUND_ACTIVE) @@ -344,19 +415,10 @@ const char *PeripheryManager_::playFromFile(String file) { if (DEBUG_MODE) DEBUG_PRINTLN(F("Playing RTTTL sound file")); - if (LittleFS.exists("/MELODIES/" + String(file) + ".txt")) - { - static char melodyName[64]; - Melody melody = MelodyFactory.loadRtttlFile("/MELODIES/" + String(file) + ".txt"); - player.playAsync(melody); - strncpy(melodyName, melody.getTitle().c_str(), sizeof(melodyName)); - melodyName[sizeof(melodyName) - 1] = '\0'; - return melodyName; - } - else - { + String rtttl = resolveRtttl(file); + if (rtttl.length() == 0) return NULL; - } + return playRTTTLString(rtttl); } } diff --git a/src/PeripheryManager.h b/src/PeripheryManager.h index b43dd3a8..c0b972b5 100644 --- a/src/PeripheryManager.h +++ b/src/PeripheryManager.h @@ -37,6 +37,7 @@ class PeripheryManager_ void tick(); void playBootSound(); const char *playFromFile(String file); + String resolveRtttl(const String &name, const char *fallback = nullptr); const char *playRTTTLString(String rtttl); bool parseSound(const char *json); bool isPlaying(); diff --git a/src/ServerManager.cpp b/src/ServerManager.cpp index 0da6d87f..40d3e68f 100644 --- a/src/ServerManager.cpp +++ b/src/ServerManager.cpp @@ -14,6 +14,7 @@ #include #include #include "Games/GameManager.h" +#include "TimerManager.h" #include WiFiUDP udp; @@ -21,6 +22,13 @@ WiFiUDP udp; unsigned int localUdpPort = 4210; char incomingPacket[255]; +// Propagation surface: dedicated UDP socket for device-to-device timer sync. A +// separate port (and buffer) from discovery so a full config snapshot fits and the +// FIND_AWTRIX traffic is never parsed as JSON. See docs/adr/0006. +WiFiUDP syncUdp; +const uint16_t kTimerSyncPort = 4212; +char syncBuffer[1024]; + // Pufferdefinition #define BUFFER_SIZE 64 char dataBuffer[BUFFER_SIZE]; @@ -57,6 +65,7 @@ void ServerManager_::erase() memset(&conf, 0, sizeof(conf)); // Set all the bytes in the structure to 0 esp_wifi_set_config(WIFI_IF_STA, &conf); LittleFS.format(); + g_littlefsMountEpoch++; delay(200); formatSettings(); delay(200); @@ -115,6 +124,19 @@ void addHandler() }else{ mws.webserver->send(500, F("text/plain"), F("ErrorParsingJson")); } }); + mws.addHandler("/api/timer", HTTP_POST, []() + { + switch (TimerManager.parseCommand(mws.webserver->arg("plain").c_str())) + { + case TimerCmdResult::Ok: mws.webserver->send(200, F("text/plain"), F("OK")); break; + case TimerCmdResult::Disabled: mws.webserver->send(409, F("text/plain"), F("TimerDisabled")); break; + case TimerCmdResult::BadJson: mws.webserver->send(400, F("text/plain"), F("ErrorParsingJson")); break; + case TimerCmdResult::BadField: mws.webserver->send(400, F("text/plain"), F("InvalidValue")); break; + } + }); + // Observation surface (read-only): always 200; `enabled` carries SHOW_TIMER. + mws.addHandler("/api/timer", HTTP_GET, []() + { mws.webserver->send(200, F("application/json"), TimerManager.getStateJson().c_str()); }); mws.addHandler("/api/nextapp", HTTP_ANY, []() { DisplayManager.nextApp(); mws.webserver->send(200,F("text/plain"),F("OK")); }); mws.addHandler("/fullscreen", HTTP_GET, []() @@ -250,6 +272,7 @@ void ServerManager_::setup() mws.addHandler("/save", HTTP_POST, saveHandler); addHandler(); udp.begin(localUdpPort); + syncUdp.begin(kTimerSyncPort); if (DEBUG_MODE) DEBUG_PRINTLN(F("Webserver loaded")); } @@ -308,6 +331,19 @@ void ServerManager_::tick() udp.endPacket(); } } + + // Propagation surface: inbound timer-sync packets (echo/follow/target/dedup + // gating happens inside applySyncCommand). + int syncSize = syncUdp.parsePacket(); + if (syncSize > 0) + { + int len = syncUdp.read(syncBuffer, sizeof(syncBuffer) - 1); + if (len > 0) + { + syncBuffer[len] = 0; + TimerManager.applySyncCommand(syncBuffer); + } + } } if (!currentClient || !currentClient.connected()) { @@ -348,6 +384,22 @@ void ServerManager_::sendTCP(String message) } } +void ServerManager_::sendTimerSync(const String &payload) +{ + if (AP_MODE) return; + IPAddress bcast = WiFi.broadcastIP(); + if ((uint32_t)bcast == 0) bcast = IPAddress(255, 255, 255, 255); + // Redundant best-effort send; receivers dedup by (src,seq). Spacing rides out + // transient congestion without acks/per-target state (ADR-0006). + for (uint8_t i = 0; i < 3; ++i) + { + syncUdp.beginPacket(bcast, kTimerSyncPort); + syncUdp.write((const uint8_t *)payload.c_str(), payload.length()); + syncUdp.endPacket(); + if (i < 2) delay(15); + } +} + void ServerManager_::loadSettings() { if (LittleFS.exists("/DoNotTouch.json")) diff --git a/src/ServerManager.h b/src/ServerManager.h index 8b6be341..0ed8c359 100644 --- a/src/ServerManager.h +++ b/src/ServerManager.h @@ -16,6 +16,9 @@ class ServerManager_ void sendButton(byte btn, bool state); void erase(); void sendTCP(String message); + // Broadcast a timer-sync packet on the LAN (propagation surface). Sent 3x for + // best-effort delivery; receivers dedup by (src,seq). See docs/adr/0006. + void sendTimerSync(const String &payload); bool isConnected; IPAddress myIP; }; diff --git a/src/SyncEnvelope.cpp b/src/SyncEnvelope.cpp new file mode 100644 index 00000000..e6e8958a --- /dev/null +++ b/src/SyncEnvelope.cpp @@ -0,0 +1,79 @@ +#include "SyncEnvelope.h" + +// The Timer-sync wire envelope + pure inbound receive gate. See SyncEnvelope.h and +// CONTEXT.md "Propagation surface". Extracted from TimerManager per ADR-0023. + +namespace SyncEnvelope +{ + bool targetsMe(JsonVariantConst tgt, const String &ownId) + { + // A string tgt is the broadcast spelling: only the literal "all" covers us. + if (tgt.is()) + { + String s = tgt.as(); + s.trim(); + return s == "all"; + } + // An array tgt is an explicit id list: membership by uniqueID. + if (tgt.is()) + { + for (JsonVariantConst v : tgt.as()) + { + String id = v.as(); + id.trim(); + if (id == ownId) return true; + } + } + return false; + } + + Decision classify(JsonVariantConst packet, const Context &ctx) + { + JsonVariantConst sync = packet["_sync"]; + if (sync.isNull()) return {Decision::Ignore, String(), 0}; // not a sync packet + + String src = sync["src"].as(); + if (src.length() == 0 || src == ctx.ownId) + return {Decision::Ignore, String(), 0}; // malformed / own echo + + // Presence beacon (#111 / ADR-0019): harvest the sender's uniqueID UNGATED — + // presence is informational, not a command, so it bypasses the follow/target + // gate. It carries no action/duration/config; short-circuit here. + if (packet["presence"].as()) + return {Decision::HarvestPresence, src, 0}; + + if (!ctx.follow) return {Decision::Ignore, String(), 0}; // consent gate + if (!targetsMe(sync["tgt"], ctx.ownId)) + return {Decision::Ignore, String(), 0}; // not addressed here + + return {Decision::Apply, src, sync["seq"].as()}; + } + + void build(JsonObject sync, const String &ownId, uint32_t seq) + { + sync["src"] = ownId; + sync["seq"] = seq; + } + + void build(JsonObject sync, const String &ownId, uint32_t seq, const String &targets) + { + build(sync, ownId, seq); + + String t = targets; + t.trim(); + if (t == "all") { sync["tgt"] = "all"; return; } + + JsonArray arr = sync.createNestedArray("tgt"); + int start = 0; + const int n = t.length(); + while (start < n) + { + int comma = t.indexOf(',', start); + if (comma < 0) comma = n; + String id = t.substring(start, comma); + id.trim(); + if (id.length() > 0) arr.add(id); + start = comma + 1; + } + } +} diff --git a/src/SyncEnvelope.h b/src/SyncEnvelope.h new file mode 100644 index 00000000..ced59184 --- /dev/null +++ b/src/SyncEnvelope.h @@ -0,0 +1,78 @@ +#ifndef SyncEnvelope_h +#define SyncEnvelope_h + +#include +#include + +// Timer-sync wire envelope + inbound receive gate (PRD #28, extracted from +// TimerManager per ADR-0023 — the third cut in the #131 trajectory, after +// PeerRegistry/ADR-0021 and SyncSeenCache/ADR-0022). +// +// Two halves, both stateless (free functions in a namespace — there is no set to +// own, unlike the two class siblings): +// +// * classify() — the PURE receive policy. Given a parsed packet plus this +// clock's {ownId, follow}, it returns the verdict: Ignore | HarvestPresence | +// Apply. It reads no globals, no clock, and no dedup cache. The four gates it +// owns (echo-drop / presence / follow consent / target match) mirror the old +// inline gate in applySyncCommand exactly. The DEDUP step deliberately stays +// OUT of classify: SyncSeenCache::seen() is test-and-record (stateful), so it +// cannot live in a pure function — TimerManager runs it as the single stateful +// guard before re-entry (see ADR-0023). +// +// * build() — the send-side envelope constructor. The bare overload writes +// {src,seq} (the presence beacon); the targeted overload also parses the +// CSV / "all" target list into tgt. seq is INJECTED (TimerManager owns the +// monotonic _syncSeq counter), so this half is stateless too. +// +// classify's context is {ownId, follow} ONLY — never this clock's own target +// list. A follower obeys on the SENDER's tgt + its OWN follow consent (the +// two-axis Sync-roles invariant in CONTEXT.md); the receiver's own send-target +// list has no role here. That list belongs to build() (the send axis). +// +// NOTE the wire-gate targetsMe() here is a DIFFERENT concept from TimerHa's +// timerSyncTargets* cluster, which maps the HA Targets *select* (Off/All/peer-id +// <-> option index). Same words, opposite surfaces; kept in separate files. +namespace SyncEnvelope +{ + struct Context + { + String ownId; // this clock's uniqueID (echo-drop + targetsMe) + bool follow; // this clock's receive-consent axis (TIMER_SYNC_FOLLOW) + }; + + struct Decision + { + enum Kind + { + Ignore, // drop the packet, do nothing + HarvestPresence, // record `src` into the peer registry (ungated) + Apply // re-enter parseCommand under _remoteApply (after dedup) + } kind; + + String src; // set for HarvestPresence + Apply + uint32_t seq; // set for Apply (the dedup key, with src) + }; + + // PURE receive policy. Order mirrors the old applySyncCommand gate: + // _sync null -> Ignore (not a sync packet) + // src empty / == ownId -> Ignore (malformed / own echo) + // presence == true -> HarvestPresence{src} (ungated, bypasses follow/target) + // !ctx.follow -> Ignore (consent gate) + // !targetsMe(tgt,ownId) -> Ignore (not addressed to this clock) + // else -> Apply{src, seq} + Decision classify(JsonVariantConst packet, const Context &ctx); + + // Does this packet's _sync.tgt cover ownId? Either the literal "all" string, + // or membership in the tgt id array. Anything else is false. + bool targetsMe(JsonVariantConst tgt, const String &ownId); + + // Send-side envelope construction into `sync` (a created "_sync" object). + // Bare: just {src, seq} — the presence beacon. seq is injected. + void build(JsonObject sync, const String &ownId, uint32_t seq); + // Targeted: also writes tgt from the CSV/"all" target list. "all" -> "all" + // (string); a CSV -> a tgt id array (empty/whitespace ids skipped). + void build(JsonObject sync, const String &ownId, uint32_t seq, const String &targets); +} + +#endif diff --git a/src/SyncSeenCache.cpp b/src/SyncSeenCache.cpp new file mode 100644 index 00000000..aefac5d3 --- /dev/null +++ b/src/SyncSeenCache.cpp @@ -0,0 +1,37 @@ +#include "SyncSeenCache.h" + +// A bounded (src,seq,atMs) ring learned from inbound sync commands; the backend +// for "apply each of the 3x redundant sends exactly once". Entries age out past +// kTtlMs (so a sender reboot — seq restart — self-clears), and the existing entry +// is NOT refreshed on a hit (first-seen ages out; the dedup semantic). See ADR-0022. + +bool SyncSeenCache::seen(const String &src, uint32_t seq, unsigned long nowMs) +{ + // A live, matching entry => redundant copy. Do NOT refresh it: first-seen ages + // out (opposite PeerRegistry's keep-alive). atMs==0 marks an empty/never slot. + for (uint8_t i = 0; i < kMax; ++i) + { + if (_entries[i].atMs != 0 && (nowMs - _entries[i].atMs) <= kTtlMs + && _entries[i].seq == seq && _entries[i].src == src) + return true; + } + + // Record at the FIFO ring cursor (round-robin eviction at the bound). nowMs==0 + // would collide with the empty-slot sentinel, so it folds to 1. + _entries[_writeIdx].src = src; + _entries[_writeIdx].seq = seq; + _entries[_writeIdx].atMs = (nowMs == 0) ? 1 : nowMs; + _writeIdx = (uint8_t)((_writeIdx + 1) % kMax); + return false; +} + +void SyncSeenCache::clear() +{ + for (uint8_t i = 0; i < kMax; ++i) + { + _entries[i].src = String(); + _entries[i].seq = 0; + _entries[i].atMs = 0; + } + _writeIdx = 0; +} diff --git a/src/SyncSeenCache.h b/src/SyncSeenCache.h new file mode 100644 index 00000000..a8957bf3 --- /dev/null +++ b/src/SyncSeenCache.h @@ -0,0 +1,45 @@ +#ifndef SyncSeenCache_h +#define SyncSeenCache_h + +#include + +// Timer-sync dedup set (PRD #28, extracted from TimerManager per ADR-0022 as a +// deliberate sibling of PeerRegistry/ADR-0021). +// +// The set of *recently-applied sync commands*, keyed by (src, seq): the backing +// store for "apply each of the 3x redundant sends exactly once". Pure RAM, +// bounded, entries age out after the TTL — the same shape as PeerRegistry, but +// deliberately NOT merged under a shared generic (the reasoning is recorded in +// ADR-0022). It is simpler than PeerRegistry: there is no own-id (own echoes are +// dropped earlier in applySyncCommand), no sorted export, and no separate +// consumer query — the one call site test-and-records in a single atomic op. +// +// This module is the *set* only — check + record + age. It deliberately knows +// nothing about the UDP transport or the parseCommand re-entry; that I/O stays on +// TimerManager (the cut-line mirrors ADR-0021: the algorithm leaves, the +// transport stays). The method takes an injected `nowMs`, so there is no global +// read — the whole contract is host-testable in isolation, without standing up +// the TimerManager singleton. See CONTEXT.md "Propagation surface". +class SyncSeenCache +{ +public: + // Test-and-record in one atomic op (there is exactly one call site, so a + // separate check is pointless and racy). Returns true when (src,seq) was seen + // within the TTL — a redundant copy the caller must drop — WITHOUT refreshing + // the existing entry (first-seen ages out; the dedup semantic, opposite + // PeerRegistry's keep-alive refresh). Otherwise records it and returns false. + bool seen(const String &src, uint32_t seq, unsigned long nowMs); + + // Forget every entry (a reboot has applied nothing yet; pure RAM). + void clear(); + +private: + struct Entry { String src; uint32_t seq = 0; unsigned long atMs = 0; }; + static constexpr uint8_t kMax = 8; + static constexpr unsigned long kTtlMs = 2000; + + Entry _entries[kMax]; + uint8_t _writeIdx = 0; // FIFO ring write cursor (round-robin eviction) +}; + +#endif diff --git a/src/SyncTargetsDebounce.cpp b/src/SyncTargetsDebounce.cpp new file mode 100644 index 00000000..73c36eaf --- /dev/null +++ b/src/SyncTargetsDebounce.cpp @@ -0,0 +1,42 @@ +#include "SyncTargetsDebounce.h" + +// The settle-window state machine for the dynamic Targets select's republish +// (issue #199 / PRD #192). The transition table lives in the header; the effects +// live in TimerHaHost. See test/test_synctargets for the row-for-row pinning. + +SyncTargetsDebounce::Action SyncTargetsDebounce::step(const char *currentOpts, unsigned long nowMs) +{ + // Row 1: no change against the published signature. Clearing the dirty flag + // even mid-window is the revert-cancel semantic. + if (_sig == currentOpts) + { + _dirty = false; + return Action::Unchanged; + } + // Row 2: first sighting of the change — open the settle window. sinceMs stays + // anchored here for the whole window (row 3 never rewrites it), so mid-window + // drift does not push the republish out. + if (!_dirty) + { + _dirty = true; + _sinceMs = nowMs; + return Action::StartWindow; + } + + // Row 3: inside the window — the change has not settled yet. cur is compared + // to the PUBLISHED signature (row 1 above), never the first-sighted value. + if ((nowMs - _sinceMs) < kDebounceMs) + return Action::Waiting; + + // Row 4: the change held for the whole window — adopt it in the same atomic + // step, so the caller's publish and this signature cannot drift apart. + _sig = currentOpts; + _dirty = false; + return Action::Republish; +} + +void SyncTargetsDebounce::seed(const char *opts) +{ + _sig = opts; + _dirty = false; +} diff --git a/src/SyncTargetsDebounce.h b/src/SyncTargetsDebounce.h new file mode 100644 index 00000000..55f6f648 --- /dev/null +++ b/src/SyncTargetsDebounce.h @@ -0,0 +1,59 @@ +#ifndef SyncTargetsDebounce_h +#define SyncTargetsDebounce_h + +#include + +// The Targets-select republish debounce decision (issue #199 / PRD #192), carved out +// of TimerHaHost::refreshTargets as a stateful host-testable module — the +// SyncSeenCache/ADR-0022 sibling shape: injected `nowMs`, no globals, no clock, no +// ArduinoHA. A peer-membership change must hold for the settle window before the +// Targets select's options are republished, so a burst of beacon churn yields one +// republish; this class is that decision ONLY. The effects (rebuild options, publish +// discovery config, re-apply state) and the shell guards (carrier nullptr, MQTT +// connected) stay in TimerHaHost, BEFORE step() — so window state ages across +// MQTT-down gaps and an expired window republishes on the first connected tick. +// +// Transition table (byte-for-byte the TimerHaHost debounce it was carved from; the +// test list in test/test_synctargets mirrors it row for row). State: sig (last +// adopted options signature), dirty (settle window open), sinceMs (window start). +// Input per step: cur (current options), now. +// +// | # | Condition | Next state | Action | +// |---|-----------------------------------------|-------------------------------|-------------| +// | 1 | cur == sig | dirty := false | Unchanged | +// | 2 | cur != sig, !dirty | dirty := true; sinceMs := now | StartWindow | +// | 3 | cur != sig, dirty, now - sinceMs < 3000 | (none) | Waiting | +// | 4 | cur != sig, dirty, now - sinceMs >= 3000| sig := cur; dirty := false | Republish | +// +// Consequences the table encodes (each pinned by a test): +// - Row 1 mid-window is revert-cancel: membership flipping back to the published +// set cancels the pending republish. +// - Row 3 compares cur to the PUBLISHED sig, never the first-sighted value, so +// mid-window drift does NOT restart the window: A→B@t0, →C@t0+2s ⇒ one +// Republish at t0+3s adopting C. +// - seed() adopts and closes the window (no spurious first republish). +// - (now - sinceMs) is unsigned wrap arithmetic, safe across millis() rollover. +// +// Consumed by TimerHaHost: refreshTargets() steps it, createCarriers() seeds it +// (issue #200, ADR-0027). +class SyncTargetsDebounce +{ +public: + enum class Action { Unchanged, StartWindow, Waiting, Republish }; + + // Atomic step: compare currentOpts to the last-adopted signature and + // advance the settle window. Adopts the signature internally on Republish. + Action step(const char *currentOpts, unsigned long nowMs); + + // Baseline at carrier creation: adopt opts, close the window + // (no spurious first republish). + void seed(const char *opts); + +private: + String _sig; + bool _dirty = false; + unsigned long _sinceMs = 0; + static constexpr unsigned long kDebounceMs = 3000; +}; + +#endif diff --git a/src/TimerCommand.cpp b/src/TimerCommand.cpp new file mode 100644 index 00000000..7a0ea880 --- /dev/null +++ b/src/TimerCommand.cpp @@ -0,0 +1,126 @@ +#include "TimerCommand.h" + +#include // strcmp + +// The pure Timer command plan. See TimerCommand.h and docs/adr/0001. Lifted verbatim +// (behaviour-identical) from TimerManager::parseCommand's validation pass (#143); the +// shell now drives apply from the returned Plan instead of re-deriving these flags. + +namespace TimerCommand +{ + Plan classify(JsonObjectConst packet, const Context &ctx) + { + Plan p; // ok defaults false; arrays default-zeroed + + // -- Table settings: validate + coerce each present row into staging. Nothing is + // kept until every field below validates too (atomic-reject, ADR-0001). + // max_duration is range-defining for duration: capture its staged value so a + // payload that raises the ceiling AND sets a duration within it in the same + // call is accepted atomically (ADR-0001 addendum). melody_end/melody_tick + // accept an inline RTTTL tune (detected by content): validated here, staged as + // RAM, and the table row excluded from the bare-name store (always one-shot). + uint32_t effectiveMaxDuration = ctx.savedMaxDuration; + for (size_t i = 0; i < TIMER_SETTINGS_DESC_COUNT; ++i) + { + const TimerSettingDesc &d = TIMER_SETTINGS_DESCS[i]; + p.tablePresent[i] = packet.containsKey(d.cmdKey); + if (!p.tablePresent[i]) continue; + + bool isMelodyKey = (strcmp(d.cmdKey, "melody_end") == 0 || strcmp(d.cmdKey, "melody_tick") == 0); + if (isMelodyKey) + { + String mv = packet[d.cmdKey].as(); + if (timerMelodyIsInline(mv)) + { + if (!timerMelodyValidateInline(mv)) return p; // !ok + if (strcmp(d.cmdKey, "melody_end") == 0) { p.inlineEnd = mv; p.haveInlineEnd = true; } + else { p.inlineTick = mv; p.haveInlineTick = true; } + p.tablePresent[i] = false; // excluded from the bare-name parse/store/snapshot + continue; + } + } + + if (!timerSettingParse(d, packet[d.cmdKey], p.tableStaged[i])) return p; // !ok + if (strcmp(d.cmdKey, "max_duration") == 0) effectiveMaxDuration = p.tableStaged[i].num; + } + + // -- duration (member-backed, B1): validated against the effective ceiling above. + p.haveDuration = packet.containsKey("duration"); + if (p.haveDuration) + { + JsonVariantConst dv = packet["duration"]; + if (dv.is()) + { + if (!timerParseHMS(dv.as(), p.durationSec)) return p; // !ok + } + else if (dv.is() || dv.is()) // any JSON number; not bool/object/null + { + p.durationSec = dv.as(); + } + else + { + return p; // !ok + } + if (p.durationSec < 1 || (effectiveMaxDuration > 0 && p.durationSec > effectiveMaxDuration)) + return p; // !ok + } + + // -- Member-backed config half (B1): validate + coerce each present row via its + // pure validator. A bad member field rejects the whole command (ADR-0001). + for (size_t i = 0; i < TIMER_MEMBER_VALIDATOR_COUNT; ++i) + { + const TimerMemberValidatorDesc &d = TIMER_MEMBER_VALIDATORS[i]; + p.memberPresent[i] = packet.containsKey(d.cmdKey); + if (!p.memberPresent[i]) continue; + if (!d.validate(packet[d.cmdKey], p.memberStaged[i])) return p; // !ok + } + + // -- action (run-state verb). + if (packet.containsKey("action")) + { + String a = packet["action"].as(); + if (!timerIsValidAction(a)) return p; // !ok + a.toLowerCase(); + p.action = (a == "start") ? Action::Start + : (a == "pause") ? Action::Pause + : Action::Reset; + } + + // -- save flag (PRD #99 / issue #100): payload-level bool, default true. A + // non-boolean rejects the whole command (atomic-reject, ADR-0001). + bool saveFlag = true; + if (packet.containsKey("save")) + { + JsonVariantConst sv = packet["save"]; + if (!sv.is()) return p; // !ok + saveFlag = sv.as(); + } + + // -- one-shot decision (ADR-0017/0018). An inline melody is always one-shot; a + // remote-applied command is ALWAYS one-shot regardless of the leader's save. + p.oneShot = !saveFlag || p.haveInlineEnd || p.haveInlineTick || ctx.remoteApply; + + // -- configInCommand: a config-block key present (table inSnapshot half OR the + // member-backed half). sync_* (inSnapshot=false) and action/duration + // (run-state) are excluded. Drives rebaseline + config broadcast. + bool snapshotChanged = false; + for (size_t i = 0; i < TIMER_SETTINGS_DESC_COUNT; ++i) + if (p.tablePresent[i] && TIMER_SETTINGS_DESCS[i].inSnapshot) snapshotChanged = true; + bool memberTouched = false; + for (size_t i = 0; i < TIMER_MEMBER_VALIDATOR_COUNT; ++i) + if (p.memberPresent[i]) memberTouched = true; + p.configInCommand = snapshotChanged || memberTouched; + + // -- HA attribute carriers dirtied by this command (any mapped key present). + // Table-driven, fires for any present key including sync_* (State carrier), + // exactly as the old apply loop. The shell republishes these iff !oneShot. + for (size_t i = 0; i < TIMER_ATTR_GROUP_DESC_COUNT; ++i) + { + const TimerAttrGroupDesc &g = TIMER_ATTR_GROUP_DESCS[i]; + if (packet.containsKey(g.cmdKey)) p.attrCarrierDirty[(size_t)g.carrier] = true; + } + + p.ok = true; + return p; + } +} diff --git a/src/TimerCommand.h b/src/TimerCommand.h new file mode 100644 index 00000000..be9a702c --- /dev/null +++ b/src/TimerCommand.h @@ -0,0 +1,90 @@ +#ifndef TimerCommand_h +#define TimerCommand_h + +#include +#include + +#include "TimerSettings.h" // the descriptor-table family classify validates against +#include "TimerHa.h" // TimerHaEntity (the attr-carrier dirty set) + +// The Timer command plan -- the PURE atomic-reject validation decision lifted out of +// the 244-line TimerManager::parseCommand (PRD #28, ADR-0001), mirroring the +// SyncEnvelope sibling (ADR-0023). classify() runs the entire validation pass once, +// mutating NOTHING, and returns a Plan that carries everything the impure shell needs +// to apply the command without re-reading the packet. +// +// classify reads NO globals: its only non-packet input is a Context filled by the +// shell from the globals it owns (the saved max_duration ceiling + the _remoteApply +// flag). That discipline -- mirroring SyncEnvelope's {ownId, follow} -- is what lets +// it link against the dependency-light descriptor-table family alone ([env:native_validate], +// no singleton / no stubs) and be host-tested by constructing any ceiling / remote +// scenario as a plain Context value. +// +// The one-shot override STORE stays in TimerManager (ADR-0024); classify computes only +// the oneShot DECISION. apply ordering matters: the shell must write the table rows +// (landing a raised max_duration) BEFORE calling setDuration(), which re-clamps against +// the GLOBAL ceiling (ADR-0001 addendum) -- classify validates a duration against the +// staged ceiling, so a too-early setDuration would silently re-clamp it. +namespace TimerCommand +{ + // The only non-packet input classify reads -- filled by the shell from the globals + // it owns. savedMaxDuration is the current ceiling (TIMER_MAX_DURATION); a command + // that also raises max_duration re-bases the ceiling for its own duration check. + // remoteApply forces the command one-shot (a follower mirrors but never persists, + // ADR-0018). + struct Context + { + uint32_t savedMaxDuration; + bool remoteApply; + }; + + // Parsed run-state verb. None = the command carried no `action` key. + enum class Action : uint8_t { None, Start, Pause, Reset }; + + // Everything apply needs, so the shell never re-reads the packet. On !ok the only + // rejection classify produces is BadField (BadJson / Disabled are shell-level + // guards before classify); the shell maps !ok -> TimerCmdResult::BadField. All + // staging arrays are sized to the family's compile-time row caps. + struct Plan + { + bool ok = false; + + // Table half (TIMER_SETTINGS_DESCS): staged values + presence. A present row + // whose value was an inline melody is marked NOT present here (excluded from + // the bare-name store) and surfaced via the inline* fields instead. + TcValue tableStaged[TIMER_SETTINGS_DESC_CAP]; + bool tablePresent[TIMER_SETTINGS_DESC_CAP] = {false}; + + // Member half (TIMER_MEMBER_VALIDATORS, co-indexed with TIMER_MEMBER_CONFIG_DESCS). + TcValue memberStaged[TIMER_MEMBER_CONFIG_DESC_CAP]; + bool memberPresent[TIMER_MEMBER_CONFIG_DESC_CAP] = {false}; + + // duration (run-state, member-backed): pre-validated in range against the + // staged ceiling. The shell applies it via setDuration AFTER the table rows. + bool haveDuration = false; + uint32_t durationSec = 0; + + // Inline RTTTL melodies (issue #102): validated tunes staged as RAM strings; the + // shell assigns them to endRtttl/tickRtttl after the bare-name resolve. Always + // one-shot (no persistable file form), so either forces oneShot. + bool haveInlineEnd = false; + bool haveInlineTick = false; + String inlineEnd; + String inlineTick; + + Action action = Action::None; // the parsed run-state verb (no re-parse in apply) + bool oneShot = false; // !save || inline melody || remoteApply + bool configInCommand = false; // config-block key present (table inSnapshot OR member half) + + // Carriers whose mapped HA-attribute keys appear in the command -- the set the + // shell republishes (suppressed under a one-shot command). Indexed by carrier. + bool attrCarrierDirty[(size_t)TimerHaEntity::COUNT] = {false}; + }; + + // Run the whole atomic-reject pass once, mutating nothing. On the first invalid + // field returns {ok=false} having staged nothing the caller should apply. On + // success returns {ok=true} with every field above populated. + Plan classify(JsonObjectConst packet, const Context &ctx); +} + +#endif diff --git a/src/TimerConfigEditor.cpp b/src/TimerConfigEditor.cpp new file mode 100644 index 00000000..5c0e5212 --- /dev/null +++ b/src/TimerConfigEditor.cpp @@ -0,0 +1,96 @@ +#include "TimerConfigEditor.h" + +#include "Globals.h" // TIMER_MAX_DURATION (cap math) +#include "TimerSettings.h" // timerSecondsToHMS / timerHmsToSeconds (pure math free functions, #206) + +namespace { + // Hold-to-repeat timing (was TimerManager.cpp's anon namespace before issue #23). + constexpr unsigned long kBtnLongPressMs = 500; // hold this long before auto-repeat begins + constexpr unsigned long kBtnRepeatMs = 250; // cadence between auto-repeat steps +} + +void TimerConfigEditor::enter(uint32_t durationSec) +{ + uint32_t h, m, s; + timerSecondsToHMS(durationSec, h, m, s); + if (h > 99) h = 99; + hh_ = (uint8_t)h; + mm_ = (uint8_t)m; + ss_ = (uint8_t)s; + field_ = 0; + active_ = true; +} + +uint32_t TimerConfigEditor::exit() +{ + active_ = false; + return timerHmsToSeconds(hh_, mm_, ss_); +} + +void TimerConfigEditor::cycleField() +{ + if (!active_) return; + field_ = (field_ + 1) % 3; +} + +void TimerConfigEditor::adjust(int delta) +{ + if (!active_) return; + + const uint8_t hardMax = (field_ == 0) ? 99 : 59; + const uint32_t perUnit = (field_ == 0) ? 3600UL : (field_ == 1) ? 60UL : 1UL; + const uint32_t otherSec = (field_ == 0) + ? (uint32_t)mm_ * 60UL + (uint32_t)ss_ + : (field_ == 1) + ? (uint32_t)hh_ * 3600UL + (uint32_t)ss_ + : (uint32_t)hh_ * 3600UL + (uint32_t)mm_ * 60UL; + + uint8_t maxVal = hardMax; + if (TIMER_MAX_DURATION > 0) + { + uint32_t headroom = (TIMER_MAX_DURATION > otherSec) ? (TIMER_MAX_DURATION - otherSec) : 0; + uint32_t room = headroom / perUnit; + if (room < maxVal) maxVal = (uint8_t)room; + } + + uint8_t cur = (field_ == 0) ? hh_ : (field_ == 1 ? mm_ : ss_); + if (cur > maxVal) cur = maxVal; + int next = (int)cur + (delta >= 0 ? 1 : -1); + if (next < 0) next = maxVal; + else if (next > (int)maxVal) next = 0; + if (field_ == 0) hh_ = (uint8_t)next; + else if (field_ == 1) mm_ = (uint8_t)next; + else ss_ = (uint8_t)next; +} + +void TimerConfigEditor::tick(unsigned long nowMs, ButtonState buttons) +{ + if (!active_) return; + + // Hold-to-repeat: derive held time from nowMs vs a per-button press-start. + // While held past the long-press threshold, step once immediately, then once + // per repeat-cadence window. The editor never auto-applies on idle (#88). + repeatHeld(buttons.leftPressed, nowMs, leftPressStartMs_, leftRepeatMs_, -1); + repeatHeld(buttons.rightPressed, nowMs, rightPressStartMs_, rightRepeatMs_, +1); +} + +void TimerConfigEditor::repeatHeld(bool pressed, unsigned long nowMs, + unsigned long &pressStartMs, unsigned long &repeatMs, + int delta) +{ + if (!pressed) + { + pressStartMs = 0; + repeatMs = 0; + return; + } + + if (pressStartMs == 0) pressStartMs = nowMs; // rising edge: start the hold clock + if (nowMs - pressStartMs < kBtnLongPressMs) return; + + if (repeatMs == 0 || (nowMs - repeatMs) >= kBtnRepeatMs) + { + adjust(delta); + repeatMs = nowMs; + } +} diff --git a/src/TimerConfigEditor.h b/src/TimerConfigEditor.h new file mode 100644 index 00000000..481d95ad --- /dev/null +++ b/src/TimerConfigEditor.h @@ -0,0 +1,84 @@ +#ifndef TimerConfigEditor_h +#define TimerConfigEditor_h + +#include + +// The on-device duration editor (HH/MM/SS wheels), a display-free value object. It +// owns the editing working state — the field cursor and the three edit buffers — +// the cap-aware adjust math, and the button hold-to-repeat. It has no DisplayManager +// dependency and no hardware reference: tick(nowMs, buttonState) receives the +// current time and injected button presses, so the repeat cadence is testable +// without a real clock or buttons. Duration flows in via enter() and back out via +// exit(); the caller commits the result through setDuration(). +// +// The no-input auto-apply timeout was removed with PRD #83 / issue #88 (the TIMER +// menu — now the editor's only host — is timeout-free); only hold-to-repeat remains. +// See docs/adr/0011-timer-config-editor-extraction.md (extraction) and +// docs/adr/0012-timer-config-timing-in-editor.md (timing; auto-apply half removed). +class TimerConfigEditor +{ +public: + // Begin editing: decompose durationSec into HH/MM/SS, start on the HH field, + // become active. HH is clamped to 99 so the field stays two-digit-editable + // (the caller is responsible for any run-state clamp before handing it in). + void enter(uint32_t durationSec); + + // Finish editing: become inactive and return the edited duration in seconds. + // The caller commits it (TimerManager::setDuration). + uint32_t exit(); + + // Advance the highlighted field: HH -> MM -> SS -> HH. + void cycleField(); + + // Inc/dec the current field by one with wrapping. The wrap ceiling is the + // per-field hard max (99 for HH, 59 for MM/SS) further capped by the headroom + // left under the TIMER_MAX_DURATION global given the other two fields. + void adjust(int delta); + + // Injected per-tick button state: raw "pressed" reads (PeripheryManager + // buttonL/R isPressed()). The editor owns the long-press threshold and repeat + // cadence, deriving held time from the nowMs handed to tick(). + // An explicit two-arg constructor (alongside a defaulted default ctor) keeps the + // member defaults but makes construction standard-independent: a class with default + // member initializers is not an aggregate under C++11, so the call site's two-arg + // brace-init (TimerManager.cpp) would otherwise need the C++14 relaxed-aggregate + // rule and break the gnu++11 device build. See docs/adr/0013 and issue #25. + struct ButtonState + { + bool leftPressed = false; + bool rightPressed = false; + ButtonState() = default; + ButtonState(bool l, bool r) : leftPressed(l), rightPressed(r) {} + }; + + // Drive button hold-to-repeat: 500 ms long-press threshold then 250 ms cadence + // (left = -1 / right = +1). No timeout — the editor never auto-applies (#88). + void tick(unsigned long nowMs, ButtonState buttons); + + bool isActive() const { return active_; } + uint8_t field() const { return field_; } + uint8_t hh() const { return hh_; } + uint8_t mm() const { return mm_; } + uint8_t ss() const { return ss_; } + +private: + // Drive one button's hold-to-repeat for the current field. pressStartMs/repeatMs + // are that button's bookkeeping (0 = unpressed / no step yet); delta is the + // field step (-1 left, +1 right). + void repeatHeld(bool pressed, unsigned long nowMs, + unsigned long &pressStartMs, unsigned long &repeatMs, int delta); + + bool active_ = false; + uint8_t field_ = 0; // 0 = HH, 1 = MM, 2 = SS + uint8_t hh_ = 0; + uint8_t mm_ = 0; + uint8_t ss_ = 0; + + // Per-button hold-to-repeat bookkeeping (issue #23). 0 = unpressed / no repeat yet. + unsigned long leftPressStartMs_ = 0; + unsigned long rightPressStartMs_ = 0; + unsigned long leftRepeatMs_ = 0; + unsigned long rightRepeatMs_ = 0; +}; + +#endif diff --git a/src/TimerEnums.cpp b/src/TimerEnums.cpp new file mode 100644 index 00000000..9f50d540 --- /dev/null +++ b/src/TimerEnums.cpp @@ -0,0 +1,58 @@ +#include "TimerEnums.h" + +// One row per enum value, in enum-value order so the array index IS the enum +// value. Wire spellings are the canonical MQTT/HTTP/sync contract; menu/ha are the +// on-device and Home Assistant labels; aliases are extra accepted input spellings +// (parse only -- never emitted). See docs/adr/0010. +const TimerEnumCodec TIMER_BUZZER_CODEC[] = { + {"off", "OFF", "Off", nullptr}, + {"end", "END", "End", nullptr}, + {"countdown", "CDN", "Countdown", nullptr}, +}; +const size_t TIMER_BUZZER_CODEC_COUNT = + sizeof(TIMER_BUZZER_CODEC) / sizeof(TIMER_BUZZER_CODEC[0]); + +const TimerEnumCodec TIMER_FINISHED_CODEC[] = { + {"auto-clear", "CLEAR", "Auto-clear", "autoclear"}, + {"hold", "HOLD", "Hold", nullptr}, + {"re-alert", "RE-ALERT", "Re-alert", "realert"}, +}; +const size_t TIMER_FINISHED_CODEC_COUNT = + sizeof(TIMER_FINISHED_CODEC) / sizeof(TIMER_FINISHED_CODEC[0]); + +// Adding an enum value without giving it a table row (or vice versa) fails the +// build here -- the table count is pinned to the enum's COUNT sentinel. +static_assert(sizeof(TIMER_BUZZER_CODEC) / sizeof(TIMER_BUZZER_CODEC[0]) == + static_cast(BuzzerMode::COUNT), + "TIMER_BUZZER_CODEC must have one row per BuzzerMode value"); +static_assert(sizeof(TIMER_FINISHED_CODEC) / sizeof(TIMER_FINISHED_CODEC[0]) == + static_cast(FinishedMode::COUNT), + "TIMER_FINISHED_CODEC must have one row per FinishedMode value"); + +const TimerEnumCodec &buzzerCodec(BuzzerMode m) +{ + uint8_t i = static_cast(m); + if (i >= TIMER_BUZZER_CODEC_COUNT) i = 0; + return TIMER_BUZZER_CODEC[i]; +} + +const TimerEnumCodec &finishedCodec(FinishedMode m) +{ + uint8_t i = static_cast(m); + if (i >= TIMER_FINISHED_CODEC_COUNT) i = 0; + return TIMER_FINISHED_CODEC[i]; +} + +bool timerEnumParse(const TimerEnumCodec *table, size_t count, + const String &s, uint8_t &outIndex) +{ + String in = s; + in.toLowerCase(); + for (size_t i = 0; i < count; ++i) + { + if (in == table[i].wire) { outIndex = (uint8_t)i; return true; } + // aliases is a single extra spelling today; compare case-insensitively too. + if (table[i].aliases && in == table[i].aliases) { outIndex = (uint8_t)i; return true; } + } + return false; +} diff --git a/src/TimerEnums.h b/src/TimerEnums.h new file mode 100644 index 00000000..0ffa6362 --- /dev/null +++ b/src/TimerEnums.h @@ -0,0 +1,58 @@ +#ifndef TimerEnums_h +#define TimerEnums_h + +#include + +// The Timer's two label-bearing enums and their per-enum CODEC TABLE: the fifth +// member of the descriptor-table family (alongside TIMER_SETTINGS_DESCS, +// TIMER_MEMBER_CONFIG_DESCS, TIMER_HA_DESCRIPTORS, TIMER_MENU_SLOTS). See +// docs/adr/0010. +// +// One row per enum value, INDEXED BY THE ENUM'S NUMERIC VALUE -- the enum value +// *is* the row index (the convention kBuzzerLabels/kFinishedLabels already used, +// now made authoritative). Each row carries every spelling of that value: +// * wire -- canonical MQTT/HTTP/sync string (the wire contract) +// * menu -- on-device TIMER-menu label +// * ha -- Home Assistant select-option label +// * aliases -- extra accepted INPUT spellings (parse only), nullptr if none +// +// This file is a findable leaf module: it depends only on Arduino.h, so it can be +// reused by TimerManager, TimerMenu and TimerHa without forming a dependency cycle +// (TimerManager.h includes THIS header for the enums, never the reverse). +// +// B1 boundary is unchanged (ADR-0007/0009): this only consolidates how labels / +// strings are ENCODED. The MQTT raw-uint8_t-index publish path is untouched -- the +// published index is the enum value, which is exactly this table's row index. + +// Timer lifecycle state. Lives here (not in TimerManager.h) so display-side value +// types like TimerSnapshot can carry it without depending on the manager singleton. +enum class TimerState : uint8_t { Idle = 0, Running = 1, Paused = 2, Finished = 3 }; + +enum class BuzzerMode : uint8_t { Off = 0, End = 1, Countdown = 2, COUNT }; +enum class FinishedMode : uint8_t { AutoClear = 0, Hold = 1, ReAlert = 2, COUNT }; + +struct TimerEnumCodec +{ + const char *wire; // canonical wire string (MQTT/HTTP/sync) + const char *menu; // on-device BARE leaf value (e.g. "END", not "BZR END") + const char *ha; // Home Assistant select-option label + const char *aliases; // extra accepted input spelling(s), nullptr if none +}; + +extern const TimerEnumCodec TIMER_BUZZER_CODEC[]; +extern const size_t TIMER_BUZZER_CODEC_COUNT; +extern const TimerEnumCodec TIMER_FINISHED_CODEC[]; +extern const size_t TIMER_FINISHED_CODEC_COUNT; + +// Row read by enum value (the value IS the row index). Bounds-clamped to row 0 so +// a stray cast can never index out of bounds. +const TimerEnumCodec &buzzerCodec(BuzzerMode m); +const TimerEnumCodec &finishedCodec(FinishedMode m); + +// Case-insensitive scan: match `s` against a row's canonical wire spelling OR any +// of its aliases. On a hit, sets `outIndex` to the matching row (= enum value) and +// returns true; otherwise leaves `outIndex` untouched and returns false. +bool timerEnumParse(const TimerEnumCodec *table, size_t count, + const String &s, uint8_t &outIndex); + +#endif diff --git a/src/TimerHa.cpp b/src/TimerHa.cpp new file mode 100644 index 00000000..ff220c11 --- /dev/null +++ b/src/TimerHa.cpp @@ -0,0 +1,161 @@ +#include "TimerHa.h" + +#include +#include + +#include "TimerEnums.h" + +// PROGMEM is a no-op for const data on the ESP32 (flash is memory-mapped and +// directly addressable), and isn't defined on the native host build. Define it +// away when absent so this table compiles in both environments. +#ifndef PROGMEM +#define PROGMEM +#endif + +// Build a select's "`;`"-joined HA option list from a per-enum codec table's HA +// column, in enum-value order (the table's row index IS the enum value, pinned by +// its static_assert — see docs/adr/0010). One join site, no hand-written list to +// drift from the enum labels. The returned String has static lifetime below, so +// the descriptor's `options` pointer stays valid for the program's life. +static String joinHaOptions(const TimerEnumCodec *table, size_t count) +{ + String s; + for (size_t i = 0; i < count; ++i) + { + if (i) s += ';'; + s += table[i].ha; + } + return s; +} + +// HA entity strings for the Timer. Co-located with the descriptor table so the +// Timer's HA contract lives in one place (moved here from Dictionary.cpp). +static const char HAtimerDurID[] PROGMEM = {"%s_timer_dur"}; +static const char HAtimerDurIcon[] PROGMEM = {"mdi:timer-cog-outline"}; +static const char HAtimerDurName[] PROGMEM = {"Timer duration"}; + +static const char HAtimerRemID[] PROGMEM = {"%s_timer_rem"}; +static const char HAtimerRemIcon[] PROGMEM = {"mdi:timer-sand"}; +static const char HAtimerRemName[] PROGMEM = {"Timer remaining"}; +static const char HAtimerRemUnit[] PROGMEM = {"s"}; +static const char HAtimerRemClass[] PROGMEM = {"duration"}; + +static const char HAtimerStateID[] PROGMEM = {"%s_timer_state"}; +static const char HAtimerStateIcon[] PROGMEM = {"mdi:state-machine"}; +static const char HAtimerStateName[] PROGMEM = {"Timer state"}; + +static const char HAtimerBuzID[] PROGMEM = {"%s_timer_buz"}; +static const char HAtimerBuzIcon[] PROGMEM = {"mdi:volume-high"}; +static const char HAtimerBuzName[] PROGMEM = {"Timer buzzer"}; +static const String HAtimerBuzOptions = joinHaOptions(TIMER_BUZZER_CODEC, TIMER_BUZZER_CODEC_COUNT); + +static const char HAtimerFinID[] PROGMEM = {"%s_timer_fin"}; +static const char HAtimerFinIcon[] PROGMEM = {"mdi:bell-ring-outline"}; +static const char HAtimerFinName[] PROGMEM = {"Timer finished mode"}; +static const String HAtimerFinOptions = joinHaOptions(TIMER_FINISHED_CODEC, TIMER_FINISHED_CODEC_COUNT); + +static const char HAtimerStartID[] PROGMEM = {"%s_timer_start"}; +static const char HAtimerStartIcon[] PROGMEM = {"mdi:play"}; +static const char HAtimerStartName[] PROGMEM = {"Timer start"}; + +static const char HAtimerPauseID[] PROGMEM = {"%s_timer_pause"}; +static const char HAtimerPauseIcon[] PROGMEM = {"mdi:pause"}; +static const char HAtimerPauseName[] PROGMEM = {"Timer pause"}; + +static const char HAtimerResetID[] PROGMEM = {"%s_timer_reset"}; +static const char HAtimerResetIcon[] PROGMEM = {"mdi:restore"}; +static const char HAtimerResetName[] PROGMEM = {"Timer reset"}; + +// Sync-control entities (issue #110): the two sync settings — until now read-only +// HA attributes on the state sensor — become writable. The Follow switch carries +// the receive-consent toggle; the Targets select ships STATIC Off/All only (it +// becomes dynamic in a later peer-discovery slice). +static const char HAtimerSyncFollowID[] PROGMEM = {"%s_timer_sync_follow"}; +static const char HAtimerSyncFollowIcon[] PROGMEM = {"mdi:account-sync"}; +static const char HAtimerSyncFollowName[] PROGMEM = {"Timer sync follow"}; + +static const char HAtimerSyncTargetsID[] PROGMEM = {"%s_timer_sync_targets"}; +static const char HAtimerSyncTargetsIcon[] PROGMEM = {"mdi:target-account"}; +static const char HAtimerSyncTargetsName[] PROGMEM = {"Timer sync targets"}; +// Static option list, in TimerSyncTargetsOption order (Off=0, All=1). The select's +// state index reflects sync_targets via timerSyncTargetsIndexForValue. +static const char HAtimerSyncTargetsOptions[] PROGMEM = {"Off;All"}; + +const TimerHaDescriptor TIMER_HA_DESCRIPTORS[TIMER_HA_DESCRIPTOR_COUNT] = { + {TimerHaEntity::Duration, "text", HAtimerDurID, HAtimerDurIcon, HAtimerDurName, nullptr, nullptr, nullptr}, + {TimerHaEntity::Remaining,"sensor", HAtimerRemID, HAtimerRemIcon, HAtimerRemName, nullptr, HAtimerRemUnit, HAtimerRemClass}, + {TimerHaEntity::State, "sensor", HAtimerStateID, HAtimerStateIcon, HAtimerStateName, nullptr, nullptr, nullptr}, + {TimerHaEntity::Buzzer, "select", HAtimerBuzID, HAtimerBuzIcon, HAtimerBuzName, HAtimerBuzOptions.c_str(), nullptr, nullptr}, + {TimerHaEntity::Finished, "select", HAtimerFinID, HAtimerFinIcon, HAtimerFinName, HAtimerFinOptions.c_str(), nullptr, nullptr}, + {TimerHaEntity::Start, "button", HAtimerStartID, HAtimerStartIcon, HAtimerStartName, nullptr, nullptr, nullptr}, + {TimerHaEntity::Pause, "button", HAtimerPauseID, HAtimerPauseIcon, HAtimerPauseName, nullptr, nullptr, nullptr}, + {TimerHaEntity::Reset, "button", HAtimerResetID, HAtimerResetIcon, HAtimerResetName, nullptr, nullptr, nullptr}, + {TimerHaEntity::SyncFollow, "switch", HAtimerSyncFollowID, HAtimerSyncFollowIcon, HAtimerSyncFollowName, nullptr, nullptr, nullptr}, + {TimerHaEntity::SyncTargets, "select", HAtimerSyncTargetsID, HAtimerSyncTargetsIcon, HAtimerSyncTargetsName, HAtimerSyncTargetsOptions, nullptr, nullptr}, +}; + +// --- Dynamic SyncTargets select (#112) --------------------------------------- +// The select's options and id<->index mapping are derived at runtime from the +// CURRENT peer-id list. See the contract in TimerHa.h. + +void timerSyncTargetsBuildOptions(const char *const *ids, size_t n, char *out, size_t outLen) +{ + // Base options are always present; peers append in the caller-provided (sorted) + // order. String here is local scratch — the device build has it via Arduino.h. + String s = "Off;All"; + for (size_t i = 0; i < n; ++i) + { + s += ';'; + s += ids[i]; + } + snprintf(out, outLen, "%s", s.c_str()); +} + +int8_t timerSyncTargetsIndexForValue(const char *syncTargets, const char *const *ids, size_t n) +{ + if (syncTargets == nullptr || syncTargets[0] == '\0') return (int8_t)TimerSyncTargetsOption::Off; + if (strcmp(syncTargets, "all") == 0) return (int8_t)TimerSyncTargetsOption::All; + // A multi-id CSV cannot be expressed as a single select option -> unknown. + if (strchr(syncTargets, ',') != nullptr) return -1; + // A single specific id reflects only while it is still a discovered peer; the + // index follows its position in the sorted list (after the two base options). + for (size_t i = 0; i < n; ++i) + if (strcmp(syncTargets, ids[i]) == 0) return (int8_t)(2 + i); + return -1; // an id no longer present -> unknown (HASelect: no option selected) +} + +bool timerSyncTargetsValueForIndex(int8_t index, const char *const *ids, size_t n, char *out, size_t outLen) +{ + if (index == (int8_t)TimerSyncTargetsOption::Off) { snprintf(out, outLen, "%s", ""); return true; } + if (index == (int8_t)TimerSyncTargetsOption::All) { snprintf(out, outLen, "%s", "all"); return true; } + if (index >= 2 && (size_t)(index - 2) < n) { snprintf(out, outLen, "%s", ids[index - 2]); return true; } + return false; +} + +void formatTimerHaEntityId(const TimerHaDescriptor &d, const char *macSuffix, char *out, size_t outLen) +{ + snprintf(out, outLen, d.idFormat, macSuffix); +} + +void formatTimerHaDataTopic(const char *dataPrefix, const char *deviceUniqueId, + const char *entityId, char *out, size_t outLen) +{ + // "stat_t" is ArduinoHA's HAStateTopic suffix; see the contract in TimerHa.h. + snprintf(out, outLen, "%s/%s/%s/stat_t", dataPrefix, deviceUniqueId, entityId); +} + +void formatTimerHaAttrTopic(const char *dataPrefix, const char *deviceUniqueId, + const char *entityId, char *out, size_t outLen) +{ + // "json_attr_t" is ArduinoHA's HAJsonAttributesTopic suffix; see the contract + // in TimerHa.h. + snprintf(out, outLen, "%s/%s/%s/json_attr_t", dataPrefix, deviceUniqueId, entityId); +} + +bool haRegistrationAtCap(uint8_t registered, uint8_t maxEntities) +{ + // Mirror byte-for-byte ArduinoHA's HAMqtt::addDeviceType guard + // (`_devicesTypesNb + 1 >= _maxDevicesTypesNb`); the uint16_t widening keeps a + // maxEntities of 255 from wrapping. See the contract in TimerHa.h. + return (uint16_t)registered + 1 >= maxEntities; +} diff --git a/src/TimerHa.h b/src/TimerHa.h new file mode 100644 index 00000000..7d2c8719 --- /dev/null +++ b/src/TimerHa.h @@ -0,0 +1,116 @@ +#ifndef TimerHa_h +#define TimerHa_h + +#include +#include + +// Timer HA Presence: the single source of truth for the Home Assistant entities +// that project the Timer over the MQTT discovery layer. This header is pure data +// (no ArduinoHA, no Globals) so it compiles on the host and the contract below can +// be unit-tested. The ArduinoHA `new HAX + setters` apply and the discovery +// teardown both read TIMER_HA_DESCRIPTORS in MQTTManager — one table, no drift. +// See CONTEXT.md ("Timer HA Presence") and docs/timer.md. + +enum class TimerHaEntity : uint8_t +{ + Duration = 0, + Remaining, + State, + Buzzer, + Finished, + Start, + Pause, + Reset, + SyncFollow, // writable switch: the receive-consent toggle (sync_follow) + SyncTargets, // writable select: static Off/All targeting (sync_targets) + COUNT +}; + +// The static option indices for the SyncTargets select (issue #110). The select +// ships ONLY these two base options ("Off"/"All"); a specific-ID CSV set out-of-band +// is reflected as unknown (-1) — see timerSyncTargetsIndexForValue. +enum class TimerSyncTargetsOption : int8_t +{ + Off = 0, // sync_targets == "" (receive only / no relay) + All = 1, // sync_targets == "all" + COUNT +}; + +struct TimerHaDescriptor +{ + TimerHaEntity slot; + const char *component; // HA discovery component: "text" | "sensor" | "select" | "button" + const char *idFormat; // unique-id format, e.g. "%s_timer_dur" ("%s" is the MAC suffix) + const char *icon; + const char *name; + const char *options; // select only ("`;`"-joined), else nullptr + const char *unit; // sensor only, else nullptr + const char *deviceClass; // sensor only, else nullptr +}; + +// One descriptor per TimerHaEntity slot, in slot order (TIMER_HA_DESCRIPTORS[(size_t)slot]). +constexpr size_t TIMER_HA_DESCRIPTOR_COUNT = static_cast(TimerHaEntity::COUNT); +extern const TimerHaDescriptor TIMER_HA_DESCRIPTORS[TIMER_HA_DESCRIPTOR_COUNT]; + +// Convenience accessor: the descriptor for a given slot. +inline const TimerHaDescriptor &timerHaDescriptor(TimerHaEntity slot) +{ + return TIMER_HA_DESCRIPTORS[static_cast(slot)]; +} + +// The single place a descriptor row + device MAC suffix becomes an entity's HA +// discovery unique id: formats the row's idFormat ("%s" -> macSuffix) into +// out[outLen]. Discovery setup (fill) and teardown both derive ids through this, +// keyed by the row they hold, so the two cannot derive different ids for a slot +// and reordering the table moves no entity's id. +void formatTimerHaEntityId(const TimerHaDescriptor &d, const char *macSuffix, char *out, size_t outLen); + +// The single place an entity id becomes the full MQTT data (state) topic the +// broker sees: "{dataPrefix}/{deviceUniqueId}/{entityId}/stat_t". MUST stay +// byte-identical to what ArduinoHA emits for the same entity +// (HASerializer::generateDataTopic with the HAStateTopic suffix "stat_t") — +// the wire seam publishes to this topic where setValue() used to, so a format +// drift here is a wire-protocol change. Pinned by test W1. +void formatTimerHaDataTopic(const char *dataPrefix, const char *deviceUniqueId, + const char *entityId, char *out, size_t outLen); + +// The finished-mode select's JSON-attributes data topic: +// "{dataPrefix}/{deviceUniqueId}/{entityId}/json_attr_t". Same data-topic shape +// as formatTimerHaDataTopic but with ArduinoHA's HAJsonAttributesTopic suffix +// ("json_attr_t") — MUST stay byte-identical to what HASelect::setJsonAttributes +// advertises in the discovery config, since the retained attribute value rides +// the wire seam to this exact topic. Pinned by test W13. +void formatTimerHaAttrTopic(const char *dataPrefix, const char *deviceUniqueId, + const char *entityId, char *out, size_t outLen); + +// Dynamic SyncTargets select (#112). The select consumes the peer registry, so its +// option list and id<->index mapping are built at runtime from the CURRENT set of +// discovered peer ids (already sorted) rather than the static "Off;All" table field. +// All three helpers are pure (char* / out-buffer, no String/Globals) so they are +// host-testable alongside the rest of this contract. + +// Build the option list "Off;All" followed by ";" for each of the n peer ids, +// in the given order, into out[outLen] (truncated like snprintf). n==0 -> "Off;All". +void timerSyncTargetsBuildOptions(const char *const *ids, size_t n, char *out, size_t outLen); + +// Forward map (sync_targets value -> select option index) over the CURRENT id list: +// "" -> 0 (Off), "all" -> 1 (All), a single id present at sorted position k -> 2+k, +// an id no longer present OR a multi-id CSV -> -1 (unknown / no option selected). +// With n==0 this reduces to static Off/All/-1 behaviour (the select's base options). +int8_t timerSyncTargetsIndexForValue(const char *syncTargets, const char *const *ids, size_t n); + +// Reverse map (select option index -> sync_targets value) over the CURRENT id list: +// 0 -> "", 1 -> "all", k>=2 -> ids[k-2], written into out[outLen]. Returns false +// (and leaves out untouched) when index is out of range — the command path's BadField. +bool timerSyncTargetsValueForIndex(int8_t index, const char *const *ids, size_t n, char *out, size_t outLen); + +// ArduinoHA's HAMqtt::addDeviceType drops an entity when +// `_devicesTypesNb + 1 >= _maxDevicesTypesNb`, so the EFFECTIVE capacity is +// maxEntities - 1, not maxEntities (an off-by-one that silently dropped the two +// Timer sync-control entities — issue #125). This helper encodes that guard once, +// host-tested, so the DEBUG_MODE registration check and ArduinoHA agree: it returns +// true when `registered` entities already fill the effective cap, i.e. the next +// addDeviceType would be rejected. Pure (no ArduinoHA/Globals) so it is host-testable. +bool haRegistrationAtCap(uint8_t registered, uint8_t maxEntities); + +#endif diff --git a/src/TimerHaHost.cpp b/src/TimerHaHost.cpp new file mode 100644 index 00000000..8afa9b65 --- /dev/null +++ b/src/TimerHaHost.cpp @@ -0,0 +1,436 @@ +#include "TimerHaHost.h" +#include +#include +#include "Globals.h" +#include "MQTTManager.h" // kMaxHAEntities (the general MQTT module's HA entity cap) +#include "TimerManager.h" +#include "TimerSettings.h" // timerFormatHMS (the Duration state's clock string) +#include "TimerHa.h" +#include "SyncTargetsDebounce.h" + +// The ArduinoHA client + device stay owned by the general MQTT translation unit +// (MQTTManager.cpp); the host reaches them via extern (no injection). See ADR-0026. +extern HADevice device; +extern HAMqtt mqtt; + +// The shared ArduinoHA callbacks stay in MQTTManager (they keep their non-Timer +// branches); createCarriers() wires the Timer carriers onto them, and each delegates +// its Timer branch back to this host via tryHandle* (leading guard in MQTTManager). +extern void onButtonCommand(HAButton *sender); +extern void onSelectCommand(int8_t index, HASelect *sender); +extern void onSwitchCommand(bool state, HASwitch *sender); + +// --- Carrier state (private to the host) ------------------------------------- +// The ten HA carrier entity pointers, all file-local. +static HAText *timerDuration = nullptr; +static HASensorNumber *timerRemaining = nullptr; +static HASensor *timerStateSensor = nullptr; +static HASelect *timerBuzzer = nullptr, *timerFinishedSel = nullptr; +static HAButton *timerStartBtn = nullptr, *timerPauseBtn = nullptr, *timerResetBtn = nullptr; +// Sync-control entities (issue #110): the writable Follow switch and dynamic +// Off/All Targets select that make the two sync settings controllable from HA. +static HASwitch *timerSyncFollowSw = nullptr; +static HASelect *timerSyncTargetsSel = nullptr; + +// Each Timer entity's resolved HA discovery unique id ("%s" filled with the MAC), +// indexed by TimerHaEntity slot (timerHaIds[(size_t)slot]). Filled once in setup() +// via formatTimerHaEntityId() and read by both createCarriers() and remove() +// through timerHaId(slot) — never by hand-ordered position, so create and teardown +// cannot drift. The strings must outlive the entities: ArduinoHA stores the +// unique-id pointer, not a copy. Exposed to MQTTManager's wire seam via entityId(). +static char timerHaIds[TIMER_HA_DESCRIPTOR_COUNT][40]; + +// The id buffer for a given Timer HA slot. See timerHaIds above. +static char *timerHaId(TimerHaEntity slot) { return timerHaIds[static_cast(slot)]; } + +// --- Dynamic Targets select (#112) debounce state (private to the host) ------ +// Option-string sizing for the dynamic Targets select. +constexpr size_t kSyncTargetPeerCap = 16; +constexpr size_t kSyncTargetOptsCap = 8 + kSyncTargetPeerCap * 33 + 1; // "Off;All" + ";"... +// The settle-window republish decision (signature, dirty flag, window start) lives in +// the host-tested SyncTargetsDebounce module (issue #199/#200, ADR-0027); this instance +// is the only debounce state. createCarriers() seeds it; refreshTargets() steps it. +static SyncTargetsDebounce syncTargetsDebounce; + +// Snapshot the current sorted peer ids and build the select's option string into +// optsOut. Returns the peer count; idsOut holds the ids (whose c_str() backs ptrsOut) +// so the caller can also map a value<->index against the SAME list. +static size_t buildCurrentSyncTargetsOptions(String *idsOut, const char **ptrsOut, size_t cap, + char *optsOut, size_t optsLen) +{ + size_t n = TimerManager.peerIds(idsOut, cap); + for (size_t i = 0; i < n; ++i) ptrsOut[i] = idsOut[i].c_str(); + timerSyncTargetsBuildOptions(ptrsOut, n, optsOut, optsLen); + return n; +} + +// --- SHOW_TIMER reconcile bookkeeping (private to the host) ------------------ +// Set by reconcile() when SHOW_TIMER latched off across a reboot; onConnected() +// consumes it once MQTT is up to prune the Timer carriers' discovery. Private host +// state — nothing outside the host touches it (issue #195). SHOW_TIMER_HA_PREV, by +// contrast, is a persisted (NVS-backed) shared global the settings-apply path writes +// independently, so it stays a Globals-owned flag reconcile() reads/writes via extern +// — exactly like SHOW_TIMER itself. See ADR-0026. +static bool pendingTimerHADiscoveryCleanup = false; + +// The dedicated Timer duration text callback (registered on the Duration carrier). +static void onTimerDurationMessage(const char *message, uint16_t length, HAText *sender) +{ + String in; + in.reserve(length); + for (uint16_t i = 0; i < length; i++) in += message[i]; + in.trim(); + + // Route the raw HH:MM:SS text through parseCommand (issue #109): it owns the + // parse/validate (timerParseHMS + range, reject-not-clamp) — the HA layer no longer + // duplicates that logic. A rejected input applies nothing (atomic-reject). + TimerManager.timerHaApply(TimerHaEntity::Duration, in); + + // Echo the canonical value back so rejected input snaps the field to the + // previous valid time rather than leaving the bad text displayed. + sender->setState(timerFormatHMS(TimerManager.getDuration()).c_str(), true); +} + +// Creates the Timer HA entity objects (and registers them with HAMqtt via their +// constructors). Idempotent: the objects persist for the device lifetime, so a second +// call is a no-op. This must run with the Timer entity id buffers already resolved +// (setup() fills them before SHOW_TIMER is consulted). +static void createCarriers() +{ + if (timerDuration) return; + + // Each entity's strings come from the descriptor table; the ArduinoHA + // object type and the type-specific wiring (callbacks, initial state) + // stay here because they're heterogeneous across HA component types. + const TimerHaDescriptor &dDur = timerHaDescriptor(TimerHaEntity::Duration); + const TimerHaDescriptor &dRem = timerHaDescriptor(TimerHaEntity::Remaining); + const TimerHaDescriptor &dState = timerHaDescriptor(TimerHaEntity::State); + const TimerHaDescriptor &dBuz = timerHaDescriptor(TimerHaEntity::Buzzer); + const TimerHaDescriptor &dFin = timerHaDescriptor(TimerHaEntity::Finished); + const TimerHaDescriptor &dStart = timerHaDescriptor(TimerHaEntity::Start); + const TimerHaDescriptor &dPause = timerHaDescriptor(TimerHaEntity::Pause); + const TimerHaDescriptor &dReset = timerHaDescriptor(TimerHaEntity::Reset); + + timerDuration = new HAText(timerHaId(TimerHaEntity::Duration)); + timerDuration->setIcon(dDur.icon); + timerDuration->setName(dDur.name); + timerDuration->setRetain(true); + timerDuration->onMessage(onTimerDurationMessage); + timerDuration->setState(timerFormatHMS(TimerManager.getDuration()).c_str(), true); + // Opt the Duration text entity into JSON attributes (HAText opt-in, issue #67) + // so its discovery config advertises json_attr_t; its retained {max_duration} + // object — the cap in carrier-native clock form ("24:00:00") — rides the wire + // seam to that topic (PRD #66 / issue #68). No TIMER_HA_DESCRIPTORS change. + timerDuration->setJsonAttributes(true); + + timerRemaining = new HASensorNumber(timerHaId(TimerHaEntity::Remaining), HASensorNumber::PrecisionP0); + timerRemaining->setIcon(dRem.icon); + timerRemaining->setName(dRem.name); + timerRemaining->setUnitOfMeasurement(dRem.unit); + timerRemaining->setDeviceClass(dRem.deviceClass); + timerRemaining->setCurrentValue((uint32_t)TimerManager.getRemaining()); + // Opt the remaining sensor into JSON attributes (HASensorNumber inherits the + // opt-in from HASensor): its retained {remaining_publish_interval} object rides + // the wire seam to json_attr_t (PRD #57 / issue #59). No descriptor change. + timerRemaining->setJsonAttributes(true); + + timerStateSensor = new HASensor(timerHaId(TimerHaEntity::State)); + timerStateSensor->setIcon(dState.icon); + timerStateSensor->setName(dState.name); + // Opt the state sensor into JSON attributes so its discovery config advertises + // json_attr_t; the retained config-view object (max_duration, bar_color as + // "#RRGGBB", sync roles, …) rides the wire seam to that topic (issue #59). + timerStateSensor->setJsonAttributes(true); + + timerBuzzer = new HASelect(timerHaId(TimerHaEntity::Buzzer)); + timerBuzzer->setOptions(dBuz.options); + timerBuzzer->onCommand(onSelectCommand); + timerBuzzer->setIcon(dBuz.icon); + timerBuzzer->setName(dBuz.name); + timerBuzzer->setState((uint8_t)TimerManager.getBuzzerMode(), true); + // Opt the buzzer select into JSON attributes so its discovery config advertises + // json_attr_t; its retained {countdown_seconds, melody_tick, melody_end} object + // rides the wire seam to that topic (PRD #57 / issue #58). No descriptor change. + timerBuzzer->setJsonAttributes(true); + + timerFinishedSel = new HASelect(timerHaId(TimerHaEntity::Finished)); + timerFinishedSel->setOptions(dFin.options); + timerFinishedSel->onCommand(onSelectCommand); + timerFinishedSel->setIcon(dFin.icon); + timerFinishedSel->setName(dFin.name); + timerFinishedSel->setState((uint8_t)TimerManager.getFinishedMode(), true); + // Opt the finished select into JSON attributes so its discovery config + // advertises json_attr_t; the retained {"realert_interval":N} then rides the + // wire seam to that topic (issue #51 / PRD #17). No TIMER_HA_DESCRIPTORS change. + timerFinishedSel->setJsonAttributes(true); + + timerStartBtn = new HAButton(timerHaId(TimerHaEntity::Start)); + timerStartBtn->setIcon(dStart.icon); + timerStartBtn->setName(dStart.name); + timerStartBtn->onCommand(onButtonCommand); + + timerPauseBtn = new HAButton(timerHaId(TimerHaEntity::Pause)); + timerPauseBtn->setIcon(dPause.icon); + timerPauseBtn->setName(dPause.name); + timerPauseBtn->onCommand(onButtonCommand); + + timerResetBtn = new HAButton(timerHaId(TimerHaEntity::Reset)); + timerResetBtn->setIcon(dReset.icon); + timerResetBtn->setName(dReset.name); + timerResetBtn->onCommand(onButtonCommand); + + // Sync-control entities (issue #110). The two sync settings — until now + // read-only attributes on the state sensor — become writable here. Both + // callbacks route through TimerManager::timerHaApply -> parseCommand, so they + // inherit the atomic-reject validation and NVS persistence the rest of the + // control surface has. sync_* are local identity (inSnapshot=false) and never + // propagate to peers — see ADR-0006/0014. + const TimerHaDescriptor &dSyncF = timerHaDescriptor(TimerHaEntity::SyncFollow); + const TimerHaDescriptor &dSyncT = timerHaDescriptor(TimerHaEntity::SyncTargets); + + timerSyncFollowSw = new HASwitch(timerHaId(TimerHaEntity::SyncFollow)); + timerSyncFollowSw->setIcon(dSyncF.icon); + timerSyncFollowSw->setName(dSyncF.name); + timerSyncFollowSw->onCommand(onSwitchCommand); + timerSyncFollowSw->setState(TIMER_SYNC_FOLLOW, true); + + timerSyncTargetsSel = new HASelect(timerHaId(TimerHaEntity::SyncTargets)); + // Dynamic options (issue #112): "Off;All" plus each currently-discovered peer id, + // not the static dSyncT.options table field — the select tracks the peer registry. + String stIds[kSyncTargetPeerCap]; + const char *stPtrs[kSyncTargetPeerCap]; + char stOpts[kSyncTargetOptsCap]; + size_t stN = buildCurrentSyncTargetsOptions(stIds, stPtrs, kSyncTargetPeerCap, + stOpts, sizeof(stOpts)); + timerSyncTargetsSel->setOptions(stOpts); + syncTargetsDebounce.seed(stOpts); // seed the republish baseline (no spurious first republish) + timerSyncTargetsSel->onCommand(onSelectCommand); + timerSyncTargetsSel->setIcon(dSyncT.icon); + timerSyncTargetsSel->setName(dSyncT.name); + // Reflect the current sync_targets against the CURRENT id list: Off/All, the + // matching peer's option, or unknown (-1) for a multi-ID CSV / an id no longer + // present — the read-only attribute stays authoritative for the exact value. + timerSyncTargetsSel->setState( + timerSyncTargetsIndexForValue(TIMER_SYNC_TARGETS.c_str(), stPtrs, stN), true); + + // Issue #125: every `new HAX` above auto-registered with HAMqtt via the + // HABaseDeviceType ctor. ArduinoHA drops entities once registration reaches the + // effective cap (kMaxHAEntities - 1; see haRegistrationAtCap) — which silently + // dropped these two sync-control entities until the cap was raised. Log the count + // and warn if we're back at the cap (the runtime guard, since the build-flag- + // conditional base count rules out a clean compile-time check). + if (DEBUG_MODE) + { + uint8_t registered = mqtt.getDevicesTypesNb(); + DEBUG_PRINTF("HA entities registered: %u of %u", registered, kMaxHAEntities); + if (haRegistrationAtCap(registered, kMaxHAEntities)) + DEBUG_PRINTLN(F("WARN: HA entity registration at effective cap; entities may be dropped (raise kMaxHAEntities)")); + } +} + +void TimerHaHost_::setup() +{ + // Resolve every Timer entity's unique id from the Timer HA Presence table into + // the slot-keyed timerHaIds buffers (also read by teardown), each through the one + // formatTimerHaEntityId() helper so create and teardown agree per slot. Row i + // describes slot i (pinned by test_U32). Same MAC suffix MQTTManager::setup() + // feeds its own entities, so the ids are unchanged by the move. + uint8_t mac[6]; + WiFi.macAddress(mac); + char macStr[7]; + snprintf(macStr, 7, "%02x%02x%02x", mac[3], mac[4], mac[5]); + for (size_t i = 0; i < TIMER_HA_DESCRIPTOR_COUNT; ++i) + formatTimerHaEntityId(TIMER_HA_DESCRIPTORS[i], macStr, timerHaIds[i], sizeof(timerHaIds[i])); + + if (SHOW_TIMER) + createCarriers(); +} + +void TimerHaHost_::onConnected() +{ + // Flush the one-shot cleanup reconcile() latched: if SHOW_TIMER went off across a + // reboot, prune the Timer carriers' retained discovery now that MQTT is up. When + // set, SHOW_TIMER is false, so the publish block below is skipped this connect. + if (pendingTimerHADiscoveryCleanup) + { + remove(); + pendingTimerHADiscoveryCleanup = false; + } + if (SHOW_TIMER) + { + TimerManager.publishAllWire(); // every wire artifact, derived from the member table (issue #41) + TimerManager.publishAllAttributeGroups(); // every carrier's read-only attribute object (PRD #57) + } +} + +// Reconcile SHOW_TIMER against its persisted last-seen value at boot (before MQTT +// connects). This is the host equivalent of the free reconcileTimerHAState() that +// lived in MQTTManager (issue #195): if the timer was toggled off while powered down, +// latch the one-shot discovery cleanup onConnected() flushes; and persist the new +// last-seen value whenever it changed. SHOW_TIMER_HA_PREV stays a shared persisted +// global (settings-apply writes it too), reached via extern — see ADR-0026. +void TimerHaHost_::reconcile() +{ + if (SHOW_TIMER_HA_PREV && !SHOW_TIMER) + { + pendingTimerHADiscoveryCleanup = true; + } + if (SHOW_TIMER_HA_PREV != SHOW_TIMER) + { + SHOW_TIMER_HA_PREV = SHOW_TIMER; + saveSettings(); + } +} + +// Brings the Timer HA entities online at runtime when SHOW_TIMER flips false->true. +// Mirror of remove(): creates the entities if missing, then publishes their discovery +// config (so HA adds them without a full reconnect) and current values. +void TimerHaHost_::enable() +{ + if (!HA_DISCOVERY) return; + createCarriers(); + if (!mqtt.isConnected()) return; // discovery will publish at next connect via onConnected() + + HABaseDeviceType *timerTypes[] = { + timerDuration, timerRemaining, timerStateSensor, timerBuzzer, + timerFinishedSel, timerStartBtn, timerPauseBtn, timerResetBtn, + timerSyncFollowSw, timerSyncTargetsSel}; + for (HABaseDeviceType *dt : timerTypes) + mqtt.publishConfigForDeviceType(dt); + + TimerManager.publishAllWire(); // every wire artifact, derived from the member table (issue #41) + TimerManager.publishAllAttributeGroups(); // every carrier's read-only attribute object (PRD #57) +} + +void TimerHaHost_::remove() +{ + const char *deviceUniqueId = device.getUniqueId(); + if (!deviceUniqueId) return; + char topic[160]; + for (size_t i = 0; i < TIMER_HA_DESCRIPTOR_COUNT; ++i) + { + const TimerHaDescriptor &d = TIMER_HA_DESCRIPTORS[i]; + snprintf(topic, sizeof(topic), "%s/%s/%s/%s/config", + HA_PREFIX.c_str(), d.component, deviceUniqueId, timerHaId(d.slot)); + mqtt.publish(topic, "", true); + } + // Clear each carrier's retained json_attr_t object too, so pruning the + // discovery config does not leave an orphaned attribute payload behind on the + // broker (issue #60). Rides the same wire seam the attribute publish does. + TimerManager.clearAllAttributeGroups(); +} + +// Re-publish the Targets select's discovery when peer-registry membership changes, +// debounced so a burst of beacon churn yields one republish (issue #112). No-op when +// the timer HA entities are absent or MQTT is down (discovery re-publishes at the next +// connect). On a settled change it rebuilds the options, re-publishes the discovery +// config so Home Assistant sees the new list, and re-applies the selected state. +void TimerHaHost_::refreshTargets(unsigned long nowMs) +{ + if (timerSyncTargetsSel == nullptr) return; + if (!mqtt.isConnected()) return; + + String ids[kSyncTargetPeerCap]; + const char *ptrs[kSyncTargetPeerCap]; + char opts[kSyncTargetOptsCap]; + size_t n = buildCurrentSyncTargetsOptions(ids, ptrs, kSyncTargetPeerCap, + opts, sizeof(opts)); + + // The settle-window decision (unchanged / start window / waiting / republish) is + // SyncTargetsDebounce's transition table; only Republish reaches the effects below. + // The guards above stay BEFORE step() so window state ages across MQTT-down gaps. + if (syncTargetsDebounce.step(opts, nowMs) != SyncTargetsDebounce::Action::Republish) + return; + + timerSyncTargetsSel->resetOptions(); + timerSyncTargetsSel->setOptions(opts); + mqtt.publishConfigForDeviceType(timerSyncTargetsSel); + timerSyncTargetsSel->setState( + timerSyncTargetsIndexForValue(TIMER_SYNC_TARGETS.c_str(), ptrs, n), true); +} + +bool TimerHaHost_::tryHandleButton(HAButton *sender) +{ + if (sender == timerStartBtn) + { + // Route through parseCommand (issue #109): start/pause/reset re-enter the + // control surface, so HA buttons drive peer clocks (run-state propagation) + // the same way the MQTT topic does. parseCommand also owns the + // start-from-idle switch-to-Timer-app behaviour, so it isn't duplicated here. + TimerManager.timerHaApply(TimerHaEntity::Start, ""); + return true; + } + if (sender == timerPauseBtn) + { + TimerManager.timerHaApply(TimerHaEntity::Pause, ""); + return true; + } + if (sender == timerResetBtn) + { + TimerManager.timerHaApply(TimerHaEntity::Reset, ""); + return true; + } + return false; +} + +bool TimerHaHost_::tryHandleSwitch(bool state, HASwitch *sender) +{ + if (sender == timerSyncFollowSw) + { + // Route through parseCommand (issue #110): the Follow switch re-enters the + // control surface, so it gets the strict-bool atomic-reject validation and + // NVS persistence. sync_follow is local identity and never propagates. + TimerManager.timerHaApply(TimerHaEntity::SyncFollow, String(state ? 1 : 0)); + // Echo the value actually applied back (snap-back if a write were rejected). + sender->setState(TIMER_SYNC_FOLLOW); + return true; + } + return false; +} + +bool TimerHaHost_::tryHandleSelect(HASelect *sender, int8_t index) +{ + if (sender == timerBuzzer) + { + // Route through parseCommand (issue #109): the HA select re-enters the + // control surface like every other edit, so it gets atomic-reject + // validation and the per-enum codec wire spelling — no deep setter here. + TimerManager.timerHaApply(TimerHaEntity::Buzzer, String(index)); + // Echo the canonical live mode back (snap-back to last valid on a reject). + sender->setState((int8_t)TimerManager.getBuzzerMode()); + return true; + } + if (sender == timerFinishedSel) + { + TimerManager.timerHaApply(TimerHaEntity::Finished, String(index)); + sender->setState((int8_t)TimerManager.getFinishedMode()); + return true; + } + if (sender == timerSyncTargetsSel) + { + // Dynamic select (issue #112): resolve the chosen option index through the + // CURRENT peer-id list to its sync_targets value (Off -> "", All -> "all", a + // peer option -> that id), then route through parseCommand (#110) for the + // bespoke validator + NVS persistence. Echo the canonical applied value back + // against the same list (Off/All/peer, or unknown for a multi-ID CSV / an id + // no longer present — the read-only attribute stays authoritative). sync_targets + // never propagates. + String ids[kSyncTargetPeerCap]; + const char *ptrs[kSyncTargetPeerCap]; + char opts[kSyncTargetOptsCap]; + size_t n = buildCurrentSyncTargetsOptions(ids, ptrs, kSyncTargetPeerCap, + opts, sizeof(opts)); + char val[40]; + if (timerSyncTargetsValueForIndex(index, ptrs, n, val, sizeof(val))) + TimerManager.timerHaApply(TimerHaEntity::SyncTargets, val); + sender->setState(timerSyncTargetsIndexForValue(TIMER_SYNC_TARGETS.c_str(), ptrs, n)); + return true; + } + return false; +} + +bool TimerHaHost_::carriersReady() const { return timerDuration != nullptr; } + +const char *TimerHaHost_::entityId(TimerHaEntity slot) const { return timerHaId(slot); } + +TimerHaHost_ TimerHaHost; diff --git a/src/TimerHaHost.h b/src/TimerHaHost.h new file mode 100644 index 00000000..52106a93 --- /dev/null +++ b/src/TimerHaHost.h @@ -0,0 +1,56 @@ +#ifndef TimerHaHost_h +#define TimerHaHost_h + +#include +#include "TimerHa.h" + +// TimerHaHost owns the Timer's Home Assistant *carrier* lifecycle: the ten HA +// discovery entities (their pointers and resolved unique ids), their construction +// and teardown, the dedicated Timer duration text callback, and the Timer branches +// of the shared ArduinoHA select/switch/button callbacks (reached via tryHandle*). +// It is a device-bound adapter — it constructs real ArduinoHA objects against the +// HADevice/HAMqtt globals still owned by MQTTManager (reached via extern) — so it is +// compiled only on device (excluded from every native env's build_src_filter, exactly +// like MQTTManager.cpp). The pure builder half lives in TimerHa (topics, descriptors, +// options, haRegistrationAtCap). See PRD #191 / issue #193 / ADR-0026. + +struct TimerHaHost_ +{ + // Resolve the carrier ids and (when SHOW_TIMER) construct/register the carriers. + // Runs at the same point in HA setup as before, so the ArduinoHA registration + // order — and therefore the entity-cap drop order — is unchanged. + void setup(); + // Publish the Timer wire artifacts + attribute groups on MQTT (re)connect. + // Also consumes the pending discovery-cleanup latch reconcile() set (see below). + void onConnected(); + // Reconcile the SHOW_TIMER setting against its persisted last-seen value at boot, + // before MQTT connects: if the timer was toggled off while powered down, latch a + // one-shot discovery cleanup that onConnected() flushes once MQTT is up. Persists + // the new last-seen value when it changed. Called from the device loop. + void reconcile(); + // SHOW_TIMER false->true: create carriers if missing, publish discovery + values. + void enable(); + // SHOW_TIMER true->false: prune discovery config and clear the retained attr bags. + void remove(); + + // Re-publish the dynamic Targets select's discovery when peer-registry membership + // changes, debounced (issue #112). Called every device loop; a no-op when the + // carriers are absent or MQTT is down. See the debounce state in TimerHaHost.cpp. + void refreshTargets(unsigned long nowMs); + + // Each returns true iff sender is a Timer carrier; when true the host performs the + // route-through-timerHaApply + snap-back echo the shared callbacks did before. + bool tryHandleSelect(HASelect *sender, int8_t index); + bool tryHandleSwitch(bool state, HASwitch *sender); + bool tryHandleButton(HAButton *sender); + + // Accessors for the Timer wire seam that deliberately stays in MQTTManager + // (ADR-0026): whether the carriers exist, and a carrier's resolved unique id. + // They are the seam's surface, not a transition step. + bool carriersReady() const; + const char *entityId(TimerHaEntity slot) const; +}; + +extern TimerHaHost_ TimerHaHost; + +#endif diff --git a/src/TimerManager.cpp b/src/TimerManager.cpp new file mode 100644 index 00000000..0cdc5440 --- /dev/null +++ b/src/TimerManager.cpp @@ -0,0 +1,942 @@ +#include "TimerManager.h" +#include "TimerSettings.h" +#include "TimerCommand.h" // the pure atomic-reject command plan (classify, #143) +#include "TimerHa.h" +#include "SyncEnvelope.h" // wire envelope + pure inbound receive gate (ADR-0023) +#include "Globals.h" +#include "PeripheryManager.h" +#include "DisplayManager.h" +#include "MQTTManager.h" +#include "MenuManager.h" +#include "ServerManager.h" +#include +#include +#include + +namespace { + // Sized to hold the full config snapshot (~18 keys) plus the _sync envelope on + // the propagation surface, with headroom for ArduinoJson's larger 64-bit slots + // (host tests). The HTTP/MQTT control surfaces never approach it. + constexpr uint16_t kTimerCmdJsonSize = 2048; + + const char *FALLBACK_END_RTTTL = "timer:d=4,o=5,b=120:c,8p,c,8p,c"; + const char *FALLBACK_TICK_RTTTL = "tick:d=16,o=6,b=200:c"; + + // The config-block keys that stay member-backed (B1 boundary, ADR-0007/0009) now + // live in TIMER_MEMBER_CONFIG_DESCS (TimerSettings.cpp), the config block's second + // table. Validation, apply, snapshot-emit and the broadcast trigger all loop that + // one table, so they cannot drift apart. + + // The one home for the wire-refresh first-appearance dedup. Walk `count` rows, + // project each to a key via keyOf(i), and invoke fn(key) once per DISTINCT key in + // first-appearance order. publishAll{AttributeGroups,Wire} and clearAllAttributeGroups + // differ only in the key projected (carrier enum vs publish-hook pointer) and the + // per-distinct action, so the O(n^2) dedup rule lives here alone -- a new carrier or + // shared hook is picked up by all three paths automatically. Host-compatible. + template + void forEachDistinct(size_t count, KeyOf keyOf, Fn fn) + { + for (size_t i = 0; i < count; ++i) + { + auto key = keyOf(i); + bool seen = false; + for (size_t j = 0; j < i && !seen; ++j) + seen = (keyOf(j) == key); + if (!seen) fn(key); + } + } +} + +static Preferences timerPrefs; + +TimerManager_ &TimerManager_::getInstance() +{ + static TimerManager_ instance; + return instance; +} + +TimerManager_ &TimerManager = TimerManager_::getInstance(); + +void TimerManager_::loadMelodiesCached() +{ + const char *endName = TIMER_MELODY_END.length() > 0 ? TIMER_MELODY_END.c_str() : "timer_end"; + const char *tickName = TIMER_MELODY_TICK.length() > 0 ? TIMER_MELODY_TICK.c_str() : "timer_tick"; + endRtttl = PeripheryManager.resolveRtttl(endName, FALLBACK_END_RTTTL); + tickRtttl = PeripheryManager.resolveRtttl(tickName, FALLBACK_TICK_RTTTL); +} + +// One-shot override (PRD #99 / issue #100). captureSnapshot records the SAVED config +// before a save:false command applies on top of it; restoreSnapshot writes it back +// when the timer returns to Idle. The table half (Family A inSnapshot rows) is captured +// generically over TIMER_SETTINGS_DESCS; the member-backed half (Family B) is captured as +// one TimerMemberConfig value (the two helpers below are the single home of its field +// list); the run-state duration and the resolved melody RAM are TimerManager's own members +// captured directly. Restore writes members directly (no setter), so it triggers no +// persist/publish side effects — carriers reported saved values throughout. +// The single home of the member-half field list: read live -> value. +// The NVS string key for each state's icon slot, indexed by TimerState. Shared by +// the load (setup) and save (persist) loops so the two cannot drift apart. +static const char *const kIconNvsKeys[kTimerStateCount] = { + "ICON_IDLE", "ICON_RUN", "ICON_PAUSE", "ICON_FIN"}; + +TimerMemberConfig TimerManager_::snapshotMemberConfig() const +{ + TimerMemberConfig c{buzzerMode, finishedMode}; + for (size_t i = 0; i < kTimerStateCount; ++i) c.iconByState[i] = iconByState[i]; + return c; +} + +// ...and value -> live, RAW: no persist, no publish, no broadcast. The override revert +// and the honest-observation swap both need exactly this side-effect-free write. +void TimerManager_::restoreMemberConfig(const TimerMemberConfig &c) +{ + buzzerMode = c.buzzer; + finishedMode = c.finished; + for (size_t i = 0; i < kTimerStateCount; ++i) iconByState[i] = c.iconByState[i]; +} + +void TimerManager_::captureSnapshot() +{ + timerSettingsCaptureSnapshot(_snapTable); + _snapMember = snapshotMemberConfig(); + _snapDuration = durationSec; // run-state: reverts with the override, outside the member half + _snapEndRtttl = endRtttl; // resolved melody RAM: ditto (the saved NAME is a table key) + _snapTickRtttl = tickRtttl; +} + +void TimerManager_::restoreSnapshot() +{ + timerSettingsRestoreSnapshot(_snapTable); + restoreMemberConfig(_snapMember); + durationSec = _snapDuration; + endRtttl = _snapEndRtttl; + tickRtttl = _snapTickRtttl; +} + +// The single revert seam shared by reset() and the tick auto-clear transition: if a +// one-shot override is active, restore the saved config and clear the flag. A no-op +// otherwise, so the normal return-to-Idle paths are unaffected. +void TimerManager_::returnToIdle() +{ + if (!_overrideActive) return; + restoreSnapshot(); + _overrideActive = false; +} + +// Honest observation carriers (issue #101): swap the SAVED config block into live +// storage for the duration of a carrier projection, then restore the effective +// (one-shot) values. Only the config-block state the projections read is swapped — +// the table inSnapshot rows (sync_* excluded by construction) and the member-backed +// half (buzzer/finished/icons). Run-state (duration) and the resolved melody RAM are +// not touched. No-op when no override is active, or when enable is false (the caller +// wants the EFFECTIVE view even under an override, e.g. buildConfigSnapshot(View::Effective)). +TimerManager_::SavedConfigScope::SavedConfigScope(const TimerManager_ &t, bool enable) + : tm(const_cast(t)), active(enable && t._overrideActive) +{ + if (!active) return; + // Stash the effective (one-shot) config block, then present the saved config. + timerSettingsCaptureSnapshot(effTable); + effMember = tm.snapshotMemberConfig(); + + timerSettingsRestoreSnapshot(tm._snapTable); + tm.restoreMemberConfig(tm._snapMember); +} + +TimerManager_::SavedConfigScope::~SavedConfigScope() +{ + if (!active) return; + timerSettingsRestoreSnapshot(effTable); + tm.restoreMemberConfig(effMember); +} + +void TimerManager_::setup() +{ + timerPrefs.begin("timer", false); + durationSec = timerPrefs.getUInt("DUR", 300); + buzzerMode = (BuzzerMode)timerPrefs.getUChar("BUZ", (uint8_t)BuzzerMode::End); + finishedMode = (FinishedMode)timerPrefs.getUChar("FIN", (uint8_t)FinishedMode::AutoClear); + for (size_t i = 0; i < kTimerStateCount; ++i) + iconByState[i] = timerPrefs.getString(kIconNvsKeys[i], ""); + timerPrefs.end(); + + // dev.json per-state overrides (unchanged keys), indexed by TimerState. + const String *const iconOverrides[kTimerStateCount] = { + &TIMER_ICON_IDLE, &TIMER_ICON_RUNNING, &TIMER_ICON_PAUSED, &TIMER_ICON_FINISHED}; + for (size_t i = 0; i < kTimerStateCount; ++i) + if (iconOverrides[i]->length() > 0) iconByState[i] = *iconOverrides[i]; + + if (durationSec < 1) durationSec = 1; + if (TIMER_MAX_DURATION > 0 && durationSec > TIMER_MAX_DURATION) durationSec = TIMER_MAX_DURATION; + remainingSec = durationSec; + state = TimerState::Idle; + _suspendPersist = false; + _dirty = false; // just loaded from NVS: RAM matches it, nothing pending + + // A (re)boot knows no peers and has emitted no beacon yet — the peer registry is + // pure RAM/LAN-derived state, repopulated by inbound beacons (#111 / ADR-0019). + _registry.setOwnId(uniqueID); + _registry.clear(); + _lastPresenceMs = 0; + _presenceEverSent = false; + + // A (re)boot has applied no sync command yet; the dedup set is pure RAM + // (ADR-0022), repopulated by inbound commands. + _seen.clear(); + + loadMelodiesCached(); +} + +void TimerManager_::persist() +{ + timerPrefs.begin("timer", false); + timerPrefs.putUInt("DUR", durationSec); + timerPrefs.putUChar("BUZ", (uint8_t)buzzerMode); + timerPrefs.putUChar("FIN", (uint8_t)finishedMode); + for (size_t i = 0; i < kTimerStateCount; ++i) + timerPrefs.putString(kIconNvsKeys[i], iconByState[i]); + timerPrefs.end(); +} + +void TimerManager_::persistIfDirty() +{ + if (_suspendPersist) + { + _dirty = true; + return; + } + persist(); + _dirty = false; +} + +String TimerManager_::validateIconName(const String &name) +{ + // Coercing wrapper around the family's char-rule (#143): accepted name passes + // through (empty = clear), a rejected one coerces to "" (and logs). Single source + // of the char-rule is timerIsValidIconName, so this cannot drift from the parser. + if (timerIsValidIconName(name)) return name; + if (DEBUG_MODE) DEBUG_PRINTLN("timer: icon name rejected"); + return String(""); +} + +const String &TimerManager_::getIconForState(TimerState s) const +{ + // Running/Paused/Finished fall back to Idle when their own slot is empty. + const String &own = iconByState[(size_t)s]; + if (s != TimerState::Idle && own.length() == 0) return iconByState[(size_t)TimerState::Idle]; + return own; +} + +void TimerManager_::setIcon(TimerState s, const String &name, bool publish) +{ + String n = validateIconName(name); + if (name.length() > 0 && n.length() == 0) return; // reject-guard: bad non-empty name is ignored + String &slot = iconByState[(size_t)s]; + if (slot == n) return; + slot = n; + persistIfDirty(); + if (publish) publishIcons(); +} + +void TimerManager_::publishIcons() +{ + // The four icon rows declare ONE shared aggregate publish hook (issue #34), + // so dispatching any icon key reaches the same declaration. + timerMemberConfigPublish("icon_idle"); +} + +uint32_t TimerManager_::computeCurrentRemaining() const +{ + if (state != TimerState::Running) return remainingSec; + unsigned long elapsedMs = millis() - runStartMs; + uint32_t elapsedSec = elapsedMs / 1000UL; + if (elapsedSec >= runStartRemainingSec) return 0; + return runStartRemainingSec - elapsedSec; +} + +const char *TimerManager_::getStateString() const +{ + switch (state) + { + case TimerState::Idle: return "idle"; + case TimerState::Running: return "running"; + case TimerState::Paused: return "paused"; + case TimerState::Finished: return "finished"; + } + return "idle"; +} + +// Canonical wire spellings are a single row read from the per-enum codec table +// (src/TimerEnums.cpp) -- the enum value is the row index. See docs/adr/0010. +const char *TimerManager_::buzzerModeString() const +{ + return buzzerCodec(buzzerMode).wire; +} + +const char *TimerManager_::finishedModeString() const +{ + return finishedCodec(finishedMode).wire; +} + +String TimerManager_::getStateJson() const +{ + // Bumped from the old 512-byte fixed buffer to the shared command-size + // constant the snapshot/broadcast paths use, to hold the added config mirror + // (PRD #73: ~20 keys incl. two melodies, a CSV sync_targets, four icon names). + DynamicJsonDocument doc(kTimerCmdJsonSize); + uint32_t remaining = computeCurrentRemaining(); + doc["state"] = getStateString(); + doc["enabled"] = (bool)SHOW_TIMER; + doc["remaining"] = remaining; + doc["remaining_str"] = timerFormatHMS(remaining); + doc["duration"] = durationSec; + doc["duration_str"] = timerFormatHMS(durationSec); + doc["buzzer"] = buzzerModeString(); + doc["finished"] = finishedModeString(); + + // Persisted-config mirror (PRD #73): the complete two-table dump under a + // nested `config` object, so an HTTP-only client reads back everything it can + // POST. Built in a temp doc by the pure table-walking projection, then + // deep-copied in -- duration stays top-level only (run-state, not config). + DynamicJsonDocument cfg(kTimerCmdJsonSize); + // During a one-shot override the `config` mirror reports the SAVED config, while + // the top-level run-state above stays effective (issue #101 / ADR-0015). + withConfigView(View::Saved, [&] { timerBuildFullConfig(cfg); }); + doc["config"] = cfg.as(); + + String out; + serializeJson(doc, out); + return out; +} + +// Apply the run-state bookkeeping a returned Transition names (issue #180). The +// pure engine decides the phase change; this owns the state mutation — including +// enterRunning's runStart* capture and the ADR-0024 override restore behind +// ToIdle — so the override store stays in the singleton (PRD #28 US7). Runs +// BEFORE the effects, so the publishes carry the new phase/remaining. +void TimerManager_::applyTransition(const TimerRuntime::Result &r, unsigned long now, + const TimerRuntime::Inputs &in) +{ + switch (r.transition) + { + case TimerRuntime::Transition::ToRunning: + // enterRunning: a fresh start (from Idle/Finished) loads the full duration; + // a resume (from Paused) keeps the frozen remaining. `state` is still the + // prior phase here, so it decides which. + if (state != TimerState::Paused) remainingSec = in.durationSec; + state = TimerState::Running; + runStartMs = now; + runStartRemainingSec = remainingSec; + runDurationSec = in.durationSec; // progress-bar denominator (buffers a mid-run duration edit) + lastPublishMs = now; + break; + case TimerRuntime::Transition::ToPaused: + remainingSec = in.newRemaining; // freeze the wall-clock remaining + state = TimerState::Paused; + break; + case TimerRuntime::Transition::ToFinished: + state = TimerState::Finished; + remainingSec = 0; + enteredFinishedMs = now; + lastRealertMs = now; + break; + case TimerRuntime::Transition::ToIdle: + returnToIdle(); // one-shot: restore saved config (ADR-0024 store stays here) + state = TimerState::Idle; + remainingSec = in.durationSec; + break; + case TimerRuntime::Transition::IdleReload: + remainingSec = in.durationSec; // duration edited while Idle; phase stays Idle + break; + case TimerRuntime::Transition::None: + break; + } +} + +// Thin adapter for the input-driven lifecycle verbs (issue #180): resolve the +// inputs, let TimerRuntime::step() decide the transition + ordered effects, apply +// the run-state bookkeeping, then execute the effects. The same shape tick() uses, +// so start/pause/reset/setDuration and the countdown step share one seam. +void TimerManager_::runCommand(TimerRuntime::Command cmd) +{ + unsigned long now = millis(); + TimerRuntime::Inputs in = buildInputs(); + in.command = cmd; + TimerRuntime::State st{state, remainingSec, lastPublishMs, enteredFinishedMs, lastRealertMs}; + TimerRuntime::Result r = TimerRuntime::step(st, now, in); + applyTransition(r, now, in); + for (const TimerRuntime::Effect &e : r.effects) applyEffect(e); +} + +// Resolve the environment gates + config the pure engine reads into an Inputs +// value (issue #179). tick() shares this across the Running and Finished branches, +// so the globals/singletons the engine deliberately doesn't name are touched in +// exactly one place. +TimerRuntime::Inputs TimerManager_::buildInputs() const +{ + TimerRuntime::Inputs in; + in.durationSec = durationSec; + in.newRemaining = computeCurrentRemaining(); + in.countdownArmed = SOUND_ACTIVE && buzzerMode == BuzzerMode::Countdown && tickRtttl.length() > 0; + in.isPlaying = PeripheryManager.isPlaying(); + in.countdownSeconds = TIMER_COUNTDOWN_SECONDS; + in.publishInterval = TIMER_PUBLISH_INTERVAL; + in.navigationFree = !GAME_ACTIVE && !BLOCK_NAVIGATION && !MenuManager.inMenu; + in.matrixOff = MATRIX_OFF; + in.brightness = BRIGHTNESS; + in.playEndTone = SOUND_ACTIVE && buzzerMode != BuzzerMode::Off && endRtttl.length() > 0; + in.finishedMode = finishedMode; + in.finishedHold = TIMER_FINISHED_HOLD; + in.realertInterval = TIMER_REALERT_INTERVAL; + in.realertArmed = SOUND_ACTIVE && buzzerMode != BuzzerMode::Off; + in.realertToneSet = endRtttl.length() > 0; + return in; +} + +// Executes one effect against the hardware managers / wire seam. The full effect +// vocabulary is handled so the later slices can lean on it; the Finished path emits +// only a subset (issue #178). +void TimerManager_::applyEffect(const TimerRuntime::Effect &e) +{ + switch (e.kind) + { + case TimerRuntime::EffectKind::PublishState: + publishState(); + break; + case TimerRuntime::EffectKind::PublishRemaining: + publishRemaining(); + break; + case TimerRuntime::EffectKind::SwitchToTimerApp: + { + String j = "{\"name\":\"Timer\",\"fast\":true}"; + DisplayManager.switchToApp(j.c_str()); + break; + } + case TimerRuntime::EffectKind::SetBrightness: + DisplayManager.setBrightness(e.brightness); + break; + case TimerRuntime::EffectKind::PlayTone: + PeripheryManager.playRTTTLString(e.tone == TimerRuntime::Tone::End ? endRtttl : tickRtttl); + break; + case TimerRuntime::EffectKind::StopSound: + PeripheryManager.stopSound(); + break; + } +} + +void TimerManager_::start() { runCommand(TimerRuntime::Command::Start); } + +void TimerManager_::pause() { runCommand(TimerRuntime::Command::Pause); } + +void TimerManager_::reset() { runCommand(TimerRuntime::Command::Reset); } + +// The run-state seam (issue #221 / #213): the verb + its peer mirror, paired in one +// place. broadcastRunState self-no-ops under _remoteApply and sync-off, so this is +// safe to call unconditionally from any locally-driven path. +void TimerManager_::runStateAction(TimerCommand::Action a) +{ + switch (a) + { + case TimerCommand::Action::Start: start(); broadcastRunState("start"); break; + case TimerCommand::Action::Pause: pause(); broadcastRunState("pause"); break; + case TimerCommand::Action::Reset: reset(); broadcastRunState("reset"); break; + case TimerCommand::Action::None: break; // no verb, no packet + } +} + +void TimerManager_::setDuration(uint32_t seconds) +{ + // Clamp + no-op guard stay in the adapter (input validation, not a transition); + // durationSec must be committed before runCommand so buildInputs() feeds the + // engine the NEW duration (the Idle reload / paused-edit reset load it into + // remaining). The engine decides the phase transition (issue #180); the duration + // persist + republish are unconditional adapter concerns. + if (seconds < 1) seconds = 1; + if (TIMER_MAX_DURATION > 0 && seconds > TIMER_MAX_DURATION) seconds = TIMER_MAX_DURATION; + if (durationSec == seconds) return; + durationSec = seconds; + runCommand(TimerRuntime::Command::SetDuration); // Idle: reload remaining; Paused: reset to Idle (US6) + persistIfDirty(); + publishDuration(); +} + +void TimerManager_::setBuzzerMode(BuzzerMode m, bool persist) +{ + if (buzzerMode == m) return; + buzzerMode = m; + if (persist) persistIfDirty(); + else _dirty = true; // deferred edit: pending until the next PersistBatch commit + publishBuzzerMode(); +} + +void TimerManager_::setFinishedMode(FinishedMode m, bool persist) +{ + if (finishedMode == m) return; + finishedMode = m; + if (persist) persistIfDirty(); + else _dirty = true; // deferred edit: pending until the next PersistBatch commit + publishFinishedMode(); +} + +// Thin adapter over the pure engine (issue #179): resolve the inputs, let +// TimerRuntime::step() decide the ordered effects + the run-state bookkeeping for +// both the Running and Finished branches, then apply the bookkeeping and execute +// the effects in order. The state mutation (and the ADR-0024 override restore +// behind ToIdle) stays here; effects run after it so the publishes carry the new +// phase/remaining, exactly as the imperative tick did. +void TimerManager_::tick() +{ + if (state != TimerState::Running && state != TimerState::Finished) return; + + unsigned long now = millis(); + TimerRuntime::Inputs in = buildInputs(); + TimerRuntime::State st{state, remainingSec, lastPublishMs, enteredFinishedMs, lastRealertMs}; + TimerRuntime::Result r = TimerRuntime::step(st, now, in); + + // Tick-only bookkeeping the transition switch does not own: the Running branch + // always advances remaining to the freshly-computed value and re-anchors the + // publish throttle; the Finished branch advances the re-alert anchor. The phase + // change itself (ToFinished / auto-clear ToIdle) is applied by applyTransition, + // the same seam start/pause/reset/setDuration use (issue #180). + if (state == TimerState::Running) + { + remainingSec = in.newRemaining; + if (r.publishFired) lastPublishMs = now; + } + else if (r.realertFired) // Finished + { + lastRealertMs = now; + } + + applyTransition(r, now, in); + + for (const TimerRuntime::Effect &e : r.effects) applyEffect(e); +} + +TimerCmdResult TimerManager_::parseCommand(const char *json) +{ + if (!SHOW_TIMER) return TimerCmdResult::Disabled; + if (json == nullptr || json[0] == '\0') return TimerCmdResult::BadJson; + + DynamicJsonDocument doc(kTimerCmdJsonSize); + auto err = deserializeJson(doc, json); + if (err) + { + if (DEBUG_MODE && err == DeserializationError::NoMemory) + DEBUG_PRINTLN("timer: parseCommand NoMemory"); + return TimerCmdResult::BadJson; + } + + // -- Validation: the whole atomic-reject pass runs once in the pure + // TimerCommand::classify, mutating nothing (ADR-0001, extracted #143). The shell + // fills the Context from the globals it owns (the saved ceiling + _remoteApply) + // and drives apply from the returned Plan -- the packet is never re-read below. -- + TimerCommand::Plan plan = + TimerCommand::classify(doc.as(), + TimerCommand::Context{TIMER_MAX_DURATION, _remoteApply}); + if (!plan.ok) return TimerCmdResult::BadField; // first invalid field; nothing applied + + // -- Command is known-good: only now disturb device state. -- + // One-shot override (issue #100): before applying, snapshot the saved config so + // returnToIdle() can restore it. Only the first one-shot in a run captures (latest- + // command-wins, single snapshot); a later one-shot applies on top of the same baseline. + if (plan.oneShot && !_overrideActive) + { + captureSnapshot(); + _overrideActive = true; + } + + // -- Apply, inside ONE PersistBatch window; its scope exit commits the whole config + // (both NVS namespaces, each at most once). Table rows FIRST, so a raised + // TIMER_MAX_DURATION lands before setDuration() re-clamps against the GLOBAL + // ceiling (ADR-0001 addendum -- a duration classify accepted against the staged + // ceiling would otherwise be silently clamped to the old one). Then duration + // (run-state, B1), then the member-backed applies via their publish-aware setters. -- + bool melodyChanged = false; + { + PersistBatch batch(*this); + if (plan.oneShot) batch.setTransient(); // one-shot: scope exit writes nothing to flash + for (size_t i = 0; i < TIMER_SETTINGS_DESC_COUNT; ++i) + { + if (!plan.tablePresent[i]) continue; + const TimerSettingDesc &d = TIMER_SETTINGS_DESCS[i]; + if (timerSettingStore(d, plan.tableStaged[i])) batch.markTableDirty(); + if (strcmp(d.cmdKey, "melody_tick") == 0 || strcmp(d.cmdKey, "melody_end") == 0) melodyChanged = true; + } + if (plan.haveDuration) setDuration(plan.durationSec); // pre-validated in range + for (size_t i = 0; i < TIMER_MEMBER_CONFIG_DESC_COUNT; ++i) + if (plan.memberPresent[i]) TIMER_MEMBER_CONFIG_DESCS[i].apply(plan.memberStaged[i]); + } + + if (melodyChanged) loadMelodiesCached(); + // Inline melodies (issue #102): use the validated tune directly as the resolved RAM, + // after any bare-name re-resolve above so it wins. The saved name globals are + // untouched (config mirror keeps the saved name); the snapshot captured the saved- + // resolved RAM, so returnToIdle() reverts these on return to Idle. + if (plan.haveInlineEnd) endRtttl = plan.inlineEnd; + if (plan.haveInlineTick) tickRtttl = plan.inlineTick; + + // Rebaseline (issue #100, story 21): a normal (save:true) config command arriving + // during an active one-shot run commits the live config — including prior one-shot + // values — as the new saved baseline and ends the override, so a later revert leaves + // the promoted truth in place. A pure action/duration command does NOT rebaseline (it + // carries no config to commit), so the override survives to revert. configInCommand + // (table inSnapshot half OR member half; sync_* and run-state excluded) is computed + // by classify. + if (!plan.oneShot && _overrideActive && plan.configInCommand) + { + persist(); // member half: full live state -> "timer" NVS + saveSettings(); // table half: full live state -> "awtrix" NVS + _overrideActive = false; + } + + // Settings projected as read-only HA attributes (PRD #57): republish each affected + // carrier's bag when any of its mapped keys was in the command (classify computed the + // dirty set). Fires on the remote-apply path too (not _remoteApply-gated), keeping + // each synced peer's HA attributes consistent (generalizes #52). Suppressed under a + // one-shot command so the retained bags keep reporting the saved config (#101). + if (!plan.oneShot) + { + for (size_t c = 0; c < (size_t)TimerHaEntity::COUNT; ++c) + if (plan.attrCarrierDirty[c]) publishAttributeGroup((TimerHaEntity)c); + } + + // Run-state dispatch rides the runStateAction seam (issue #221): the verb and its + // peer mirror are paired there, so this path can't emit one without the other. + // The propagation contract is unchanged (run-scoped config mirror, ADR-0018, + // superseding ADR-0006): a `start` emits the ONE combined packet (action + + // duration + effective config snapshot), pause/reset propagate run-state only, + // and a config/bare-duration edit propagates NOTHING (#126); broadcastRunState + // itself no-ops under _remoteApply (one-hop) and sync-off. The broadcast fires + // BEFORE the switch-to-app below (deliberate, #213 grilling): the broadcast reads + // state/duration/effective config, switchToApp touches display only. + bool fromIdle = (state == TimerState::Idle); + runStateAction(plan.action); + if (plan.action == TimerCommand::Action::Start && fromIdle && + !GAME_ACTIVE && !BLOCK_NAVIGATION) + { + String j = "{\"name\":\"Timer\"}"; + DisplayManager.switchToApp(j.c_str()); + } + + return TimerCmdResult::Ok; +} + +// HA control adapter (issue #109): each HA timer callback re-enters the control +// surface through parseCommand, exactly as the propagation surface does, rather +// than poking a deep setter. The minimal JSON each entity builds is the SAME shape +// the {prefix}/timer MQTT topic accepts, so HA edits inherit atomic-reject +// validation, run-state propagation, and the per-enum codec spellings for free — +// nothing about duration parsing or enum encoding is duplicated in the HA layer. +TimerCmdResult TimerManager_::timerHaApply(TimerHaEntity entity, const String &rawValue) +{ + StaticJsonDocument<128> doc; + switch (entity) + { + case TimerHaEntity::Buzzer: + { + // The select callback hands us the chosen option index; map it through the + // per-enum codec (ADR-0010) so the emitted wire string is the one + // parseCommand accepts — the two cannot drift. + long idx = rawValue.toInt(); + if (idx < 0 || (size_t)idx >= TIMER_BUZZER_CODEC_COUNT) return TimerCmdResult::BadField; + doc["buzzer"] = TIMER_BUZZER_CODEC[idx].wire; + break; + } + case TimerHaEntity::Finished: + { + long idx = rawValue.toInt(); + if (idx < 0 || (size_t)idx >= TIMER_FINISHED_CODEC_COUNT) return TimerCmdResult::BadField; + doc["finished"] = TIMER_FINISHED_CODEC[idx].wire; + break; + } + case TimerHaEntity::Duration: + // The raw HH:MM:SS text rides straight into parseCommand, which owns the + // parse/validate (timerParseHMS + range, reject-not-clamp). On a non-Ok result + // the caller echoes the canonical live value back (snap-back). + doc["duration"] = rawValue; + break; + case TimerHaEntity::Start: doc["action"] = "start"; break; + case TimerHaEntity::Pause: doc["action"] = "pause"; break; + case TimerHaEntity::Reset: doc["action"] = "reset"; break; + case TimerHaEntity::SyncFollow: + // The switch callback hands us the new bool ("1"/"0"); emit the strict + // bool parseCommand's sync_follow validator (TcCheck::Bool) accepts. Local + // identity (inSnapshot=false), so it persists but never propagates. + doc["sync_follow"] = (rawValue.toInt() != 0); + break; + case TimerHaEntity::SyncTargets: + // The dynamic select (issue #112) hands us the RESOLVED sync_targets value, + // not an index: "" (Off), "all" (All), or a discovered peer id. MQTTManager + // maps the chosen option index through the CURRENT id list before calling, so + // the option set tracks the registry. The bespoke sync_targets validator + // (parseSyncTargets) rejects a malformed id (atomic-reject parity). Local + // identity (inSnapshot=false): persists but never propagates. + doc["sync_targets"] = rawValue; + break; + default: + return TimerCmdResult::BadField; // not a control entity + } + + String json; + serializeJson(doc, json); + return parseCommand(json.c_str()); +} + +void TimerManager_::onShowTimerChange(bool prev, bool now) +{ + if (prev && !now) reset(); +} + +// State is run-state, not a member-config row, so it goes through the wire +// seam directly: the exact (topic, payload) the broker receives, byte-identical +// to the retired HASensor::setValue path (issue #31 / PRD #28). +void TimerManager_::publishState() +{ + MQTTManager.publishTimerWire(MQTTManager.timerWireTopic(TimerHaEntity::State).c_str(), + getStateString()); +} +// Remaining is run-state too (issue #32): straight through the seam, payload a +// plain decimal string — byte-identical to the retired HASensorNumber +// (PrecisionP0) setValue path. +void TimerManager_::publishRemaining() +{ + MQTTManager.publishTimerWire(MQTTManager.timerWireTopic(TimerHaEntity::Remaining).c_str(), + String(remainingSec).c_str()); +} +// Duration is run-state too (issue #34): straight through the seam, payload the +// trimmed-HMS clock string — byte-identical to the retired HAText::setState path. +void TimerManager_::publishDuration() +{ + MQTTManager.publishTimerWire(MQTTManager.timerWireTopic(TimerHaEntity::Duration).c_str(), + timerFormatHMS(durationSec).c_str()); +} +// The enum keys are member-config rows (issue #33): dispatch through the row's +// declared publish hook so validate/apply/emit/publish stay co-located and the +// table is the single definition of how each key goes out on the wire. +void TimerManager_::publishBuzzerMode() { timerMemberConfigPublish("buzzer"); } +void TimerManager_::publishFinishedMode() { timerMemberConfigPublish("finished"); } + +// A carrier's read-only JSON attribute object (PRD #57 / issue #58): the bag is +// built table-driven by timerBuildAttributeGroup, serialized, and ridden onto the +// carrier's json_attr_t topic by the wire seam — the same path every other Timer +// value takes. HA reads it because the TimerHaHost carrier build opted the carrier +// select into json attributes (setJsonAttributes), so the discovery config advertises +// this topic. Retained means HA repopulates after a restart for free. A carrier +// with no mapped rows yields an empty bag and publishes nothing. +void TimerManager_::publishAttributeGroup(TimerHaEntity carrier) +{ + // 512: the state sensor's bag is the largest (eight config-view keys incl. + // two strings), which overflows 256 on a 64-bit host (issue #59). + DynamicJsonDocument doc(512); + // A republish (e.g. on reconnect) during a one-shot override serializes the SAVED + // config, so HA attribute bags never show transient one-off values (issue #101 / + // ADR-0014). + withConfigView(View::Saved, [&] { timerBuildAttributeGroup(carrier, doc); }); + if (doc.as().size() == 0) return; + String payload; + serializeJson(doc, payload); + MQTTManager.publishTimerWire(MQTTManager.timerWireAttrTopic(carrier).c_str(), payload.c_str()); +} + +// Every distinct carrier's attribute object, each published once. Carriers are +// deduped by first appearance in the attribute-group table (a carrier owns +// several rows), mirroring publishAllWire's hook dedupe. +void TimerManager_::publishAllAttributeGroups() +{ + forEachDistinct(TIMER_ATTR_GROUP_DESC_COUNT, + [](size_t i) { return TIMER_ATTR_GROUP_DESCS[i].carrier; }, + [this](TimerHaEntity carrier) { publishAttributeGroup(carrier); }); +} + +// Teardown mirror of publishAllAttributeGroups: empty the retained json_attr_t +// topic of every distinct carrier so disabling the Timer (discovery teardown, +// issue #60) leaves no orphaned attribute object on the broker. Same carrier +// dedupe and same wire seam — an empty retained payload is the MQTT clear. +// publishTimerWire's creation-sentinel gate makes this no-op when no entity ever +// existed (nothing was advertised, so nothing to clear). +void TimerManager_::clearAllAttributeGroups() +{ + forEachDistinct(TIMER_ATTR_GROUP_DESC_COUNT, + [](size_t i) { return TIMER_ATTR_GROUP_DESCS[i].carrier; }, + [](TimerHaEntity carrier) { + MQTTManager.publishTimerWire(MQTTManager.timerWireAttrTopic(carrier).c_str(), ""); + }); +} + +// Full wire refresh (issue #41, closing PRD #28). The run-state trio is a fixed +// set (state/remaining/duration are run-state, not table rows); the config half +// is DERIVED from TIMER_MEMBER_CONFIG_DESCS, so a row added with a publish hook +// is republished on connect / discovery-enable without touching this function. +// Hooks are deduped by pointer (forEachDistinct) — the four icon rows share one +// aggregate hook, whose JSON must hit the wire exactly once; the null-hook rows +// (key not individually published) collapse to one distinct null that the guard +// skips. Order preserved from the retired hand-listed blocks: duration, remaining, +// state, then table order. +void TimerManager_::publishAllWire() +{ + publishDuration(); + publishRemaining(); + publishState(); + forEachDistinct(TIMER_MEMBER_CONFIG_DESC_COUNT, + [](size_t i) { return TIMER_MEMBER_CONFIG_DESCS[i].publish; }, + [](void (*hook)()) { if (hook) hook(); }); +} + +// --------------------------------------------------------------------------- +// Propagation surface (device-to-device timer sync). See CONTEXT.md and +// docs/adr/0006-timer-multi-device-sync.md. +// --------------------------------------------------------------------------- + +void TimerManager_::addSyncEnvelope(JsonObject &sync) +{ + // Thin forwarder: this class owns the monotonic _syncSeq counter (injected as + // seq); the envelope shape + target-CSV parsing live in SyncEnvelope (ADR-0023). + SyncEnvelope::build(sync, uniqueID, ++_syncSeq, TIMER_SYNC_TARGETS); +} + +void TimerManager_::buildConfigSnapshot(JsonDocument &doc, View view) const +{ + // Config block only — never action/duration (run-state) or sync_* (local identity, + // inSnapshot=false). Two tables, one config block: TIMER_SETTINGS_DESCS' inSnapshot + // rows and TIMER_MEMBER_CONFIG_DESCS (the member-backed half, B1, ADR-0007/0009). + // Each table also feeds the parseCommand broadcast trigger, so the snapshot can't + // drift from what fires a broadcast. + // View::Saved masks an active one-shot override so the snapshot reports the SAVED + // config — a follower never receives transient one-off values it has no notion of + // reverting (issue #101 / ADR-0006; broadcastConfig is itself suppressed during an + // override, issue #100, so this is also defensive). View::Effective takes live + // storage as-is so a leader's own one-shot run mirrors to followers — the snapshot + // reports what is actually running (ADR-0018 §4). + withConfigView(view, [&] { + timerSettingsBuildSnapshot(doc); + timerMemberConfigBuildSnapshot(doc); + }); +} + +void TimerManager_::broadcastRunState(const char *action) +{ + if (_remoteApply) return; // one-hop: never re-emit an applied remote command + if (TIMER_SYNC_TARGETS.length() == 0) return; // sync off + + // Only an action ever reaches here — start / pause / reset (#126). A `start` is the + // SOLE duration-bearing packet: it carries the leader's effective `duration` plus the + // leader's EFFECTIVE config snapshot, bundled into one combined packet — the + // run-scoped config mirror (ADR-0018). Config no longer travels on a config edit, and + // a bare duration edit propagates nothing; both ride one combined packet with the + // start so a follower mirrors the leader for that run. The combined packet needs the + // full kTimerCmdJsonSize buffer (config snapshot + envelope); pause/reset stay + // run-state-only and fit a small static buffer. + bool isStart = (strcasecmp(action, "start") == 0); + + if (isStart) + { + DynamicJsonDocument doc(kTimerCmdJsonSize); + JsonObject sync = doc.createNestedObject("_sync"); + addSyncEnvelope(sync); + doc["action"] = action; + doc["duration"] = durationSec; // duration rides only with a start (defines the countdown) + // EFFECTIVE config: a leader's own one-shot run mirrors to followers, so the + // snapshot reports what is actually running. sync_* are inSnapshot=false and + // excluded by construction; inline melodies never travel (the saved bare name + // globals back the snapshot, ADR-0017 / ADR-0018 §4). + buildConfigSnapshot(doc, View::Effective); + + String out; serializeJson(doc, out); + ServerManager.sendTimerSync(out); + return; + } + + // pause / reset: run-state only, no duration, no config. + StaticJsonDocument<256> doc; + JsonObject sync = doc.createNestedObject("_sync"); + addSyncEnvelope(sync); + doc["action"] = action; + + String out; serializeJson(doc, out); + ServerManager.sendTimerSync(out); +} + +void TimerManager_::broadcastConfig() +{ + if (_remoteApply) return; + if (TIMER_SYNC_TARGETS.length() == 0) return; + + DynamicJsonDocument doc(kTimerCmdJsonSize); + JsonObject sync = doc.createNestedObject("_sync"); + addSyncEnvelope(sync); + buildConfigSnapshot(doc, View::Saved); // propagated config reports SAVED (ADR-0006 / ADR-0017) + + String out; serializeJson(doc, out); + ServerManager.sendTimerSync(out); +} + +void TimerManager_::applySyncCommand(const char *json) +{ + if (json == nullptr || json[0] == '\0') return; + + DynamicJsonDocument doc(kTimerCmdJsonSize); + if (deserializeJson(doc, json)) return; + + // The echo/presence/follow/target decision is a PURE function (SyncEnvelope, + // ADR-0023): this shell only deserializes, then acts on the Decision. Dedup is + // deliberately NOT in classify — SyncSeenCache::seen() is stateful (test-and- + // record), so it stays here as the single guard before re-entry. + SyncEnvelope::Decision d = + SyncEnvelope::classify(doc.as(), {uniqueID, TIMER_SYNC_FOLLOW}); + + switch (d.kind) + { + case SyncEnvelope::Decision::HarvestPresence: + // Presence harvest (#111 / ADR-0019): record the sender ungated, apply no + // timer state. classify already bypassed the follow/target gate. + _registry.record(d.src, millis()); + break; + + case SyncEnvelope::Decision::Apply: + if (_seen.seen(d.src, d.seq, millis())) return; // redundant copy of a burst + // Re-enter the local control surface. The send-path _remoteApply guard + // prevents re-broadcasting (one-hop); parseCommand ignores the _sync envelope. + _remoteApply = true; + parseCommand(json); + _remoteApply = false; + break; + + case SyncEnvelope::Decision::Ignore: + break; + } +} + +// --------------------------------------------------------------------------- +// Peer presence beacon (#111 / ADR-0019). The peer SET lives in PeerRegistry +// (extracted per ADR-0021); this class keeps only the beacon cadence + UDP send. +// --------------------------------------------------------------------------- + +void TimerManager_::broadcastPresence() +{ + // A small unconditional beacon: {_sync:{src,seq}, presence:true}. No tgt — it is + // informational, harvested ungated by every receiver. Independent of sync targets + // so even a clock that commands nobody is still discoverable. 256 matches the + // pause/reset run-state beacon buffer (the src/seq envelope routes through + // SyncEnvelope::build, which wants a touch more pool headroom on the 64-bit host). + StaticJsonDocument<256> doc; + JsonObject sync = doc.createNestedObject("_sync"); + SyncEnvelope::build(sync, uniqueID, ++_syncSeq); // bare {src,seq}: no tgt (informational) + doc["presence"] = true; + + String out; serializeJson(doc, out); + ServerManager.sendTimerSync(out); // also AP-gated at the transport (defense in depth) +} + +void TimerManager_::tickPresence(unsigned long nowMs) +{ + _registry.prune(nowMs); + + if (AP_MODE) return; // no beacon in AP mode (a standalone clock with no real LAN) + + if (!_presenceEverSent || (nowMs - _lastPresenceMs) >= kPresenceIntervalMs) + { + broadcastPresence(); + _lastPresenceMs = nowMs; + _presenceEverSent = true; + } +} diff --git a/src/TimerManager.h b/src/TimerManager.h new file mode 100644 index 00000000..61befdee --- /dev/null +++ b/src/TimerManager.h @@ -0,0 +1,393 @@ +#ifndef TimerManager_h +#define TimerManager_h + +#include +#include + +#include "TimerEnums.h" // TimerState + BuzzerMode / FinishedMode + their codec tables (ADR-0010) +#include "TimerRuntime.h" // pure run-state engine: step() returns effects this class applies (issue #178) +#include "TimerHa.h" // TimerHaEntity (the HA carrier publishAttributeGroup targets) +#include "TimerSettings.h" // TcValue + TIMER_SETTINGS_DESC_CAP (one-shot override snapshot, PRD #99) +#include "TimerCommand.h" // TimerCommand::Action (the runStateAction verb, issue #221) +#include "PeerRegistry.h" // the LAN peer set (extracted from this class, ADR-0019/0021) +#include "SyncSeenCache.h" // the sync dedup set (extracted from this class, ADR-0022) + +// Result of parseCommand. All control surfaces share one validation policy +// (reject invalid input atomically); only the HTTP API surfaces this as a +// status code — MQTT ignores it. See docs/adr/0001-timer-command-validation-parity.md. +enum class TimerCmdResult : uint8_t { Ok = 0, BadJson = 1, BadField = 2, Disabled = 3 }; + +// Globals.cpp's full "awtrix"-namespace flush (declared in Globals.h, repeated +// here so the PersistBatch guard below can perform the table-half commit). +void saveSettings(); + +// The member-backed config half (B1) as one value, mirroring TIMER_MEMBER_CONFIG_DESCS +// ({buzzer, finished, the four icon_ names}) -- the singleton fields that are NOT +// driven by the generic TIMER_SETTINGS_DESCS table. It is exactly the set the one-shot +// override snapshots and SavedConfigScope swaps, so "swap == copy this struct" holds. +// Deliberately excludes durationSec (run-state) and the resolved melody RAM +// (endRtttl/tickRtttl -- the saved melody NAME is a table key); those revert with the +// override but are not config the observation carriers project as "saved". See ADR-0024. +// Number of TimerState values (Idle/Running/Paused/Finished) — the width of the +// state-indexed icon array. TimerState has no COUNT member; this is its stand-in. +static constexpr size_t kTimerStateCount = 4; + +struct TimerMemberConfig +{ + BuzzerMode buzzer = BuzzerMode::End; + FinishedMode finished = FinishedMode::AutoClear; + String iconByState[kTimerStateCount]; // indexed by TimerState +}; + +class TimerManager_ +{ +private: + TimerManager_() = default; + + TimerState state = TimerState::Idle; + BuzzerMode buzzerMode = BuzzerMode::End; + FinishedMode finishedMode = FinishedMode::AutoClear; + uint32_t durationSec = 300; + uint32_t remainingSec = 300; + + String iconByState[kTimerStateCount]; // indexed by TimerState (Idle/Running/Paused/Finished) + + String endRtttl; + String tickRtttl; + + unsigned long runStartMs = 0; + uint32_t runStartRemainingSec = 0; + uint32_t runDurationSec = 0; // duration in force when the current run began; the progress-bar denominator (buffers a mid-run duration edit) + unsigned long enteredFinishedMs = 0; + unsigned long lastRealertMs = 0; + unsigned long lastPublishMs = 0; + + bool _suspendPersist = false; + // Member-backed RAM state differs from the "timer" NVS namespace: set by a + // suspended persistIfDirty() inside a PersistBatch window, or by a deferred + // (persist=false) enum edit during a TIMER-menu scroll session. Cleared by + // the flush that writes it (persistIfDirty / PersistBatch scope exit). + bool _dirty = false; + + // -- Propagation surface (device-to-device timer sync) -- + // While true, an inbound sync packet is being applied via parseCommand; the + // broadcast* methods early-return so a received command is never re-emitted + // (one-hop topology). See CONTEXT.md "Propagation surface". + bool _remoteApply = false; + uint32_t _syncSeq = 0; // per-command sequence; only needs uniqueness within the dedup window + + // Emit one UDP broadcast mirroring a locally-accepted action to peers. No-ops + // when sync is off (empty target list) or while applying an inbound packet + // (_remoteApply). Private since #222: every local run-state actor goes through + // runStateAction(), so the verb+mirror pairing is not a caller obligation. + void broadcastRunState(const char *action); + + // Bounded recently-seen (src,seq) dedup set so the 3x redundant send is applied + // once. Extracted to its own host-testable module (SyncSeenCache, ADR-0022); the + // UDP transport + parseCommand re-entry stay here. TTL-based, so a sender reboot + // (seq restart) self-clears by ageing out. + SyncSeenCache _seen; + + // -- Peer presence registry (#111 / ADR-0019, extracted to PeerRegistry per + // ADR-0021) -- + // The LAN peer set lives in its own host-testable module (PeerRegistry); this + // class keeps only the beacon cadence + UDP send. Harvested UNGATED from + // inbound presence beacons (presence is informational, not a command — it + // bypasses the follow/target gate and applies no timer state). + static constexpr unsigned long kPresenceIntervalMs = 30000; // beacon cadence + PeerRegistry _registry; + unsigned long _lastPresenceMs = 0; + bool _presenceEverSent = false; + void broadcastPresence(); // emit one {_sync,presence:true} beacon + + // -- One-shot override (save:false), PRD #99 / issue #100 -- + // A save:false command applies its config for the CURRENT RUN only: the saved + // config is snapshotted, the command applies live, and returnToIdle() restores + // the snapshot when the timer next returns to Idle (reset or auto-clear). While + // an override is active no NVS write, no config broadcast and no HA config- + // attribute republish occur. A normal (save:true) config command mid-override + // promotes the live config to the new saved baseline and ends the override. + bool _overrideActive = false; + TcValue _snapTable[TIMER_SETTINGS_DESC_CAP]; // Family A (inSnapshot rows), generic capture + TimerMemberConfig _snapMember; // Family B (member-backed half) + uint32_t _snapDuration = 300; + String _snapEndRtttl, _snapTickRtttl; + void captureSnapshot(); // record the saved config (both tables + duration + melody RAM) + void restoreSnapshot(); // write the snapshot back (no publish/persist side effects) + void returnToIdle(); // revert seam shared by reset() and the tick auto-clear transition + + // The only two places that name the member-backed config fields: read the live + // singleton state into a value (snapshot), and write a value back RAW -- no persist, + // no publish, no broadcast -- as the override revert + the honest-observation swap + // both require. Used by captureSnapshot/restoreSnapshot and SavedConfigScope. + TimerMemberConfig snapshotMemberConfig() const; + void restoreMemberConfig(const TimerMemberConfig &c); + + // Honest observation carriers (issue #101). RAII: while an override is active, + // present the SAVED config block (table inSnapshot rows + member-backed half) in + // live storage so a carrier projection reads saved values, then restore the + // effective (one-shot) values on scope exit. No-op when no override is active. + // The const_cast is sound — the singleton is non-const and the swap is fully + // reverted, so wrapping a const projection method stays observably const. Run-state + // (duration) and sync_* (inSnapshot=false) are intentionally untouched. + class SavedConfigScope + { + public: + // enable=false forces a no-op even under an active override (View::Effective). + explicit SavedConfigScope(const TimerManager_ &t, bool enable = true); + ~SavedConfigScope(); + SavedConfigScope(const SavedConfigScope &) = delete; + SavedConfigScope &operator=(const SavedConfigScope &) = delete; + private: + TimerManager_ &tm; + bool active; + TcValue effTable[TIMER_SETTINGS_DESC_CAP]; + TimerMemberConfig effMember; // the effective (one-shot) member half, restored on scope exit + }; + + // Which config a serializer reports: Saved opens a SavedConfigScope so an active + // one-shot override is masked (broadcastConfig / ADR-0006); Effective takes live + // storage as-is so a leader's own one-shot run mirrors to followers (ADR-0018 §4). + enum class View { Saved, Effective }; + + // The one seam every config-honesty serializer names its view through: runs + // `serialize` with live storage swapped to `view` (View::Saved opens a + // SavedConfigScope masking the one-shot override; View::Effective is a no-op). + // Callers (GET mirror, HA bags, propagated snapshot) must name a View to compile, + // so no path silently omits the scope (ADR-0015 / ADR-0017). + template + void withConfigView(View view, Serialize &&serialize) const + { + SavedConfigScope saved(*this, view == View::Saved); + serialize(); + } + void buildConfigSnapshot(JsonDocument &doc, View view) const; // config keys only; no action/duration/sync_* + void addSyncEnvelope(JsonObject &sync); // forwards to SyncEnvelope::build (injects _syncSeq) + + uint32_t computeCurrentRemaining() const; + TimerRuntime::Inputs buildInputs() const; // resolve engine inputs from the environment (issue #179) + void runCommand(TimerRuntime::Command cmd); // start/pause/reset/setDuration adapter: step() + apply (issue #180) + void applyTransition(const TimerRuntime::Result &r, unsigned long now, + const TimerRuntime::Inputs &in); // run-state bookkeeping for a returned Transition + void applyEffect(const TimerRuntime::Effect &e); // the effects-adapter seam (issue #178) + void persist(); + void persistIfDirty(); + void loadMelodiesCached(); + + static String validateIconName(const String &name); + +public: + // RAII guard that makes the persist-batching window a visible lexical scope + // (PRD #29) — the ONE commit seam shared by both batch call sites: + // parseCommand's apply block and the TIMER menu's long-press commit + // (MenuManager). While the guard lives, member-backed persistence is + // suspended; scope exit commits the whole Timer config, flushing both NVS + // namespaces at most once each: the member-backed "timer" namespace iff RAM + // holds uncommitted member state (a setter dirtied it inside the window, or + // deferred persist=false menu edits dirtied it beforehand), then the + // table-backed "awtrix" namespace (saveSettings) iff markTableDirty() was + // called. Exceptions are off on this target, so "scope exit" means the + // normal return paths. + class PersistBatch + { + public: + explicit PersistBatch(TimerManager_ &tm) : tm(tm) + { + tm._suspendPersist = true; + } + ~PersistBatch() + { + tm._suspendPersist = false; + // Transient (one-shot, save:false) window: scope exit writes NOTHING to + // flash. RAM intentionally diverges from NVS for the duration of the run + // (returnToIdle() restores it), so the pending member-half dirty flag is + // discarded rather than flushed, and the table half is left untouched. + if (transient) + { + tm._dirty = false; + return; + } + if (tm._dirty) + { + tm._dirty = false; + tm.persist(); + } + if (tableDirty) + saveSettings(); + } + // A table-backed ("awtrix"-namespace) value changed inside the window. + void markTableDirty() { tableDirty = true; } + // Mark this window one-shot: scope exit skips both NVS flushes (issue #100). + void setTransient() { transient = true; } + + PersistBatch(const PersistBatch &) = delete; + PersistBatch &operator=(const PersistBatch &) = delete; + + private: + TimerManager_ &tm; + bool tableDirty = false; + bool transient = false; + }; + + static TimerManager_ &getInstance(); + void setup(); + void tick(); + + void start(); + void pause(); + void reset(); + + // The run-state seam (issue #221 / #213): dispatch the verb (start/pause/reset) + // AND mirror it to peers via broadcastRunState with the matching action string — + // the pairing every accepted action must make, folded into one entry so a call + // site can't emit the verb without the mirror. Action::None is a no-op. Safe on + // every path: broadcastRunState self-no-ops under _remoteApply and sync-off. + void runStateAction(TimerCommand::Action a); + + void setDuration(uint32_t seconds); + // persist=false applies + publishes live but defers the NVS write (the TIMER + // menu's deferred-to-commit path; mirrors setIcon*'s publish flag). Every other + // caller uses the default and persists immediately. See docs/adr/0008. + void setBuzzerMode(BuzzerMode m, bool persist = true); + void setFinishedMode(FinishedMode m, bool persist = true); + + // The parse/validate/format statics that once sat here are gone: deleted or + // relocated into the descriptor-table free functions (#204/#205/#206, + // TimerSettings.h; see docs/timer.md) — those are the only spellings. + + // The one shared icon setter: validate/reject/equality-skip/assign/persist/publish + // for one state's slot. The four named setters below are thin delegators over it. + void setIcon(TimerState s, const String &name, bool publish = true); + + void setIconIdle (const String &name, bool publish = true) { setIcon(TimerState::Idle, name, publish); } + void setIconRunning (const String &name, bool publish = true) { setIcon(TimerState::Running, name, publish); } + void setIconPaused (const String &name, bool publish = true) { setIcon(TimerState::Paused, name, publish); } + void setIconFinished(const String &name, bool publish = true) { setIcon(TimerState::Finished, name, publish); } + + void publishIcons(); + + // Republish the current run-state string through the wire seam (issue #31). + // Public because MQTTManager re-emits it on connect / discovery enable; it + // is the only path that puts the state key on the wire. + void publishState(); + + // Same for the remaining-seconds key (issue #32): the only path that puts + // remaining on the wire; the periodic republish throttle stays in tick(). + void publishRemaining(); + + // Same for the duration key (issue #34): the only path that puts the + // trimmed-HMS duration on the wire. Public for the same connect / + // discovery-enable republish sites. + void publishDuration(); + + // Same for the two enum keys (issue #33), dispatching through their member- + // config rows' publish hooks: the only paths that put buzzer/finished on + // the wire. Public for the same connect / discovery-enable republish sites. + void publishBuzzerMode(); + void publishFinishedMode(); + + // Full wire refresh (issue #41): republish every Timer wire artifact once — + // the run-state trio plus every member-config row's publish hook, derived + // from TIMER_MEMBER_CONFIG_DESCS so a new published row cannot be skipped. + // The single call the connect / discovery-enable republish sites make. + void publishAllWire(); + + // Publish one HA carrier's read-only JSON attribute object — the carrier's + // mapped settings keys built via timerBuildAttributeGroup — onto its + // json_attr_t topic via the wire seam (PRD #57 / issue #58, generalizing the + // bespoke realert_interval publish of issue #51). Retained, so HA repopulates + // after a restart for free. No-op for a carrier with no mapped keys. + void publishAttributeGroup(TimerHaEntity carrier); + + // Publish every distinct carrier's attribute object once. The single call the + // discovery-enable / reconnect paths make right after publishAllWire(), so HA + // never sees an entity with missing attributes. + void publishAllAttributeGroups(); + + // Clear (empty retained payload) every distinct carrier's json_attr_t topic — + // the teardown mirror of publishAllAttributeGroups(). The discovery teardown + // (TimerHaHost::remove, SHOW_TIMER true->false) calls this so disabling the + // Timer leaves no orphaned attribute object retained on the broker (issue #60). + void clearAllAttributeGroups(); + + TimerCmdResult parseCommand(const char *json); + + // -- Home Assistant control adapter (issue #109) -- + // Route a single HA timer callback through parseCommand instead of a deep + // setter, so HA edits get the SAME atomic-reject validation, the same + // propagation, and the same codec strings as the {prefix}/timer MQTT surface. + // Each entity builds the minimal JSON command it represents and hands it to + // parseCommand, mirroring the sync receive path. Display-free (no ArduinoHA, + // no MQTT client) so the HA->parseCommand path is host-testable. + // + // rawValue per entity: + // * Buzzer / Finished : the selected select-option INDEX as a decimal + // string; mapped through the per-enum codec (ADR-0010) to the canonical + // wire spelling, so emitted and accepted JSON cannot drift. + // * Duration : the raw HH:MM:SS text; parseCommand owns the + // parse/validate (timerParseHMS + range), so that logic is NOT duplicated here. + // * Start/Pause/Reset : ignored; the entity selects the action. + // Returns parseCommand's result so the caller can echo the canonical live + // value back on a non-Ok result (snap-back to the last valid value). + TimerCmdResult timerHaApply(TimerHaEntity entity, const String &rawValue); + + // -- Propagation surface -- + // Run-state and config travel on separate packets; broadcastRunState is private + // (the runStateAction seam owns the verb+mirror pairing). See docs/adr/0006. + void broadcastConfig(); + // Validate, gate (echo/follow/target/dedup), then apply an inbound sync packet + // through parseCommand under the _remoteApply guard. A presence beacon + // (presence:true) is harvested into the peer registry UNGATED and short-circuits + // before any command path (it carries no action/config and changes no state). + void applySyncCommand(const char *json); + + // Peer presence (#111 / ADR-0019). Called from the device loop with the current + // millis(): emits a presence beacon at most once per kPresenceIntervalMs when on + // a real network (NEVER in AP mode — broadcasts unconditionally otherwise so a + // standalone clock is still discoverable), and ages out stale peers each call. + void tickPresence(unsigned long nowMs); + // Peer registry observers (consumed by the dynamic HA Targets select in #112). + // Thin forwarders onto the extracted PeerRegistry, so consumers (MQTTManager) + // are unchanged by the extraction (ADR-0021). + int peerCount() const { return _registry.count(); } + bool hasPeer(const String &id) const { return _registry.has(id); } + size_t peerIds(String *out, size_t cap) const { return _registry.ids(out, cap); } + + void onShowTimerChange(bool prev, bool now); + + TimerState getState() const { return state; } + uint32_t getRemaining() const { return remainingSec; } + uint32_t getDuration() const { return durationSec; } + uint32_t getRunDuration() const { return runDurationSec; } + BuzzerMode getBuzzerMode() const { return buzzerMode; } + FinishedMode getFinishedMode() const { return finishedMode; } + + // The raw per-state slot (no Idle fallback — that lives in getIconForState). + // The one indexed read the snapshot/emit hook uses; the four named getters + // delegate here, mirroring how setIcon unified the setters (#184/#185). + const String &getIcon(TimerState s) const { return iconByState[(size_t)s]; } + const String &getIconIdle() const { return getIcon(TimerState::Idle); } + const String &getIconRunning() const { return getIcon(TimerState::Running); } + const String &getIconPaused() const { return getIcon(TimerState::Paused); } + const String &getIconFinished() const { return getIcon(TimerState::Finished); } + const String &getIconForState(TimerState s) const; + + const char *getStateString() const; + + // Canonical output spellings for the timer enums, co-located with + // getStateString() so the one true spelling of each enum lives in one place. + // (The command parser additionally tolerates non-hyphen aliases on input.) + // Public so the member-config table's emit hooks (TimerSettings.cpp) can read + // them when building the propagated config snapshot. See docs/adr/0009. + const char *buzzerModeString() const; + const char *finishedModeString() const; + + // Live read-only snapshot for the GET /api/timer observation surface. + // Reports computeCurrentRemaining() (wall-clock fresh), not the throttled + // cached value. See docs/api.md and CONTEXT.md "observation surface". + String getStateJson() const; +}; + +extern TimerManager_ &TimerManager; + +#endif diff --git a/src/TimerMenu.cpp b/src/TimerMenu.cpp new file mode 100644 index 00000000..4501c7fc --- /dev/null +++ b/src/TimerMenu.cpp @@ -0,0 +1,126 @@ +#include "TimerMenu.h" + +#include "TimerManager.h" +#include "TimerSettings.h" + +namespace +{ + // Enum hooks. The setters defer the NVS write (persist=false): the TIMER menu + // applies + publishes live during scroll and persists on the long-press + // commit, where MenuManager opens the PersistBatch guard (ADR-0008 as amended + // by PRD #29 / #45). Non-capturing lambdas decay to the row's function pointers. + uint8_t getBuzzer() { return (uint8_t)TimerManager.getBuzzerMode(); } + void setBuzzer(uint8_t v) { TimerManager.setBuzzerMode((BuzzerMode)v, /*persist=*/false); } + uint8_t getFinished() { return (uint8_t)TimerManager.getFinishedMode(); } + void setFinished(uint8_t v) { TimerManager.setFinishedMode((FinishedMode)v, /*persist=*/false); } +} + +// idx order is the on-screen list order (left/right walks it, wrapping). DURATION +// is first (the HH:MM:SS wheel, delegating to TimerConfigEditor); MAIN is the lone +// Navigation row and sits last (the device reads it as the back-to-main item; PRD +// #83). +// kind, name, cmdKey, step, codec, labelCount, getEnum, setEnum +// The two EnumCycle slots read their bare leaf value from the per-enum codec +// table's menu column (ADR-0010) -- no private label copy to drift from it. +const TimerMenuSlot TIMER_MENU_SLOTS[] = { + {TimerMenuKind::Duration, "DURATION", nullptr, 0, nullptr, 0, nullptr, nullptr}, + {TimerMenuKind::EnumCycle, "BUZZER", nullptr, 0, TIMER_BUZZER_CODEC, (uint8_t)BuzzerMode::COUNT, getBuzzer, setBuzzer}, + {TimerMenuKind::SteppedRange, "COUNTDOWN", "countdown_seconds", 1, nullptr, 0, nullptr, nullptr}, + {TimerMenuKind::EnumCycle, "FINISH", nullptr, 0, TIMER_FINISHED_CODEC, (uint8_t)FinishedMode::COUNT, getFinished, setFinished}, + {TimerMenuKind::SteppedRange, "CLEAR DELAY", "finished_hold", 5, nullptr, 0, nullptr, nullptr}, + {TimerMenuKind::SteppedRange, "RE-ALERT INTERVAL", "realert_interval", 5, nullptr, 0, nullptr, nullptr}, + {TimerMenuKind::BoolToggle, "ICON", "icon_enabled", 0, nullptr, 0, nullptr, nullptr}, + {TimerMenuKind::BoolToggle, "PROGRESS BAR", "bar_enabled", 0, nullptr, 0, nullptr, nullptr}, + {TimerMenuKind::Navigation, "MAIN", nullptr, 0, nullptr, 0, nullptr, nullptr}, +}; + +const size_t TIMER_MENU_SLOT_COUNT = sizeof(TIMER_MENU_SLOTS) / sizeof(TIMER_MENU_SLOTS[0]); + +String timerMenuName(uint8_t slot) +{ + if (slot >= TIMER_MENU_SLOT_COUNT) return String(); + const char *n = TIMER_MENU_SLOTS[slot].name; + return n ? String(n) : String(); +} + +String timerMenuValue(uint8_t slot) +{ + if (slot >= TIMER_MENU_SLOT_COUNT) return String(); + const TimerMenuSlot &s = TIMER_MENU_SLOTS[slot]; + switch (s.kind) + { + case TimerMenuKind::Duration: + { + // The committed duration as a zero-padded HH:MM:SS clock (matches the + // wheel), independent of any live edit in the leaf engine. + return timerClock(TimerManager.getDuration(), ClockStyle::Padded); + } + case TimerMenuKind::EnumCycle: + return String(s.codec[s.getEnum()].menu); + case TimerMenuKind::SteppedRange: + { + const TimerSettingDesc *d = timerSettingByCmdKey(s.cmdKey); + uint16_t v = d ? *static_cast(d->storage) : 0; + return String(v); + } + case TimerMenuKind::BoolToggle: + { + const TimerSettingDesc *d = timerSettingByCmdKey(s.cmdKey); + bool on = d && *static_cast(d->storage); + return String(on ? "ON" : "OFF"); + } + case TimerMenuKind::Navigation: + return String(); + } + return String(); +} + +void timerMenuAdjust(uint8_t slot, int dir) +{ + if (slot >= TIMER_MENU_SLOT_COUNT) return; + const TimerMenuSlot &s = TIMER_MENU_SLOTS[slot]; + switch (s.kind) + { + case TimerMenuKind::Duration: + break; // the duration wheel is driven by the edit engine, not adjust + case TimerMenuKind::EnumCycle: + { + uint8_t cur = s.getEnum(); + uint8_t next = (dir > 0) + ? (uint8_t)((cur + 1) % s.labelCount) + : (uint8_t)((cur + s.labelCount - 1) % s.labelCount); + s.setEnum(next); + break; + } + case TimerMenuKind::SteppedRange: + { + const TimerSettingDesc *d = timerSettingByCmdKey(s.cmdKey); + if (!d) break; + uint16_t &v = *static_cast(d->storage); + if (dir > 0) v = (v + s.step <= d->hi) ? (uint16_t)(v + s.step) : (uint16_t)d->hi; + else v = (v >= d->lo + s.step) ? (uint16_t)(v - s.step) : (uint16_t)d->lo; + break; + } + case TimerMenuKind::BoolToggle: + { + const TimerSettingDesc *d = timerSettingByCmdKey(s.cmdKey); + if (!d) break; + bool &b = *static_cast(d->storage); + b = !b; + break; + } + case TimerMenuKind::Navigation: + break; // no value to adjust + } +} + +TimerNavLeaf timerMenuLeafKind(uint8_t slot, TimerState st) +{ + if (slot >= TIMER_MENU_SLOT_COUNT) return TimerNavLeaf::Value; + if (TIMER_MENU_SLOTS[slot].kind != TimerMenuKind::Duration) + return TimerNavLeaf::Value; + // Duration: editable HH:MM:SS wheel only while Idle; read-only otherwise. + return (st == TimerState::Idle) + ? TimerNavLeaf::DurationEditable + : TimerNavLeaf::DurationReadOnly; +} diff --git a/src/TimerMenu.h b/src/TimerMenu.h new file mode 100644 index 00000000..14cca237 --- /dev/null +++ b/src/TimerMenu.h @@ -0,0 +1,69 @@ +#ifndef TimerMenu_h +#define TimerMenu_h + +#include + +#include "TimerEnums.h" +#include "TimerMenuNav.h" // TimerNavLeaf (the leaf-kind the device hands the nav SM) + +// TIMER menu slot table: the data model behind the on-device TIMER global menu's +// drill-in list (the third member of the Timer descriptor-table family, alongside +// TIMER_SETTINGS_DESCS and TIMER_HA_DESCRIPTORS). One row per list item; the +// TimerMenuNav state machine walks it and MenuManager keeps only the drawing + +// commit. This header is display-free (no DisplayManager) so the name/value/adjust +// logic is host-testable. See CONTEXT.md ("TIMER menu slot table"), docs/adr/0008 +// and docs/adr/0016 (drill-in navigation). +// +// Two slot families, mirroring the B1 boundary (ADR-0007): +// * table-backed slots (SteppedRange / BoolToggle) reuse their TIMER_SETTINGS_DESCS +// row by cmdKey for storage + range -- they cannot drift from the settings table. +// * the two enum slots (buzzer / finished) are member-backed: they carry bespoke +// getEnum/setEnum hooks (like the settings table's `bespoke` fn pointers). Their +// setEnum defers the NVS write to the menu commit (setBuzzerMode(m, persist=false)). + +// Slot kinds. The value kinds (EnumCycle / SteppedRange / BoolToggle) drill into a +// leaf editor; Duration drills into the HH:MM:SS wheel (delegating to the existing +// TimerConfigEditor edit engine, no settings-storage row); Navigation is the lone +// non-value row (MAIN) that walks the device back to the main menu (PRD #83). +enum class TimerMenuKind : uint8_t { Duration, EnumCycle, SteppedRange, BoolToggle, Navigation }; + +struct TimerMenuSlot +{ + TimerMenuKind kind; + const char *name; // list label (the item name): "BUZZER", "COUNTDOWN", "MAIN" + const char *cmdKey; // SteppedRange/BoolToggle: -> TIMER_SETTINGS_DESCS (storage + lo/hi) + uint16_t step; // SteppedRange step + const TimerEnumCodec *codec; // EnumCycle: codec table; .menu column is the bare leaf value + uint8_t labelCount; // EnumCycle modulus + uint8_t (*getEnum)(); // EnumCycle only + void (*setEnum)(uint8_t); // EnumCycle only (routes via TimerManager setter) +}; + +extern const TimerMenuSlot TIMER_MENU_SLOTS[]; +extern const size_t TIMER_MENU_SLOT_COUNT; + +// The slot's LIST LABEL (the item name shown while walking the list), e.g. +// "BUZZER", "COUNTDOWN", "MAIN". Returns "" for an out-of-range slot index. +String timerMenuName(uint8_t slot); + +// The slot's BARE LEAF VALUE (shown while editing the leaf), e.g. "END", "10", +// "ON". The item name already gives the context, so no prefix. The Duration row +// returns the current duration as a zero-padded "HH:MM:SS" clock string; +// Navigation rows (MAIN) have no value and return "". Returns "" for an +// out-of-range index. +String timerMenuValue(uint8_t slot); + +// How the leaf for `slot` behaves, given the timer's current state `st`. The +// Duration row is an editable HH:MM:SS wheel only when the timer is Idle; while +// Running/Paused it is read-only (PRD #83 user story 27). Every other row is a +// plain Value leaf. The device hands the result to TimerMenuNav, so the Idle-only +// gating decision is host-testable. +TimerNavLeaf timerMenuLeafKind(uint8_t slot, TimerState st); + +// Adjust the slot by one step in the given direction (dir > 0 = right/increment, +// dir <= 0 = left/decrement). Stepped ranges saturate at the descriptor's lo/hi; +// enums wrap; bools toggle (either direction). No-op for a Navigation row or an +// out-of-range index. +void timerMenuAdjust(uint8_t slot, int dir); + +#endif diff --git a/src/TimerMenuNav.cpp b/src/TimerMenuNav.cpp new file mode 100644 index 00000000..6d7bc0e6 --- /dev/null +++ b/src/TimerMenuNav.cpp @@ -0,0 +1,100 @@ +#include "TimerMenuNav.h" + +// Display-free TIMER-menu navigation state machine (PRD #83 / issues #85, #86). +// See the header for the input->outcome contract. The implementation is pure logic +// over {focus, index, origin, leaf, bounds} -- no device, no display, no storage -- +// which is exactly what makes the interaction model host-testable. + +TimerMenuNav::TimerMenuNav() + : _itemCount(1), _mainIndex(0), _index(0), + _focus(TimerNavFocus::List), _origin(TimerNavOrigin::Menu), + _leaf(TimerNavLeaf::Value) {} + +TimerMenuNav::TimerMenuNav(uint8_t itemCount, uint8_t mainIndex, TimerNavOrigin origin) + : _itemCount(itemCount ? itemCount : 1), + _mainIndex(mainIndex), + _index(0), + _focus(TimerNavFocus::List), + _origin(origin), + _leaf(TimerNavLeaf::Value) {} + +void TimerMenuNav::enter(uint8_t itemCount, uint8_t mainIndex, TimerNavOrigin origin) +{ + _itemCount = itemCount ? itemCount : 1; + _mainIndex = mainIndex; + _index = 0; + _focus = TimerNavFocus::List; + _origin = origin; + _leaf = TimerNavLeaf::Value; +} + +TimerNavOutcome TimerMenuNav::navigate(int dir) +{ + if (_focus == TimerNavFocus::Editing) + { + // The leaf value lives in the data model / edit engine; the caller applies + // the step. DURATION steps the active field; a read-only leaf ignores it. + switch (_leaf) + { + case TimerNavLeaf::Value: return TimerNavOutcome::AdjustValue; + case TimerNavLeaf::DurationEditable: return TimerNavOutcome::AdjustField; + case TimerNavLeaf::DurationReadOnly: return TimerNavOutcome::None; + } + return TimerNavOutcome::None; + } + + // List focus: move the cursor with wrap. + if (dir > 0) + _index = (uint8_t)((_index + 1) % _itemCount); + else + _index = (uint8_t)((_index == 0) ? _itemCount - 1 : _index - 1); + return TimerNavOutcome::None; +} + +TimerNavOutcome TimerMenuNav::select(TimerNavLeaf leafKind) +{ + if (_focus == TimerNavFocus::Editing) + { + switch (_leaf) + { + case TimerNavLeaf::Value: + // Short press in a value leaf: confirm and step back up to the list. + _focus = TimerNavFocus::List; + return TimerNavOutcome::ConfirmBackToList; + case TimerNavLeaf::DurationEditable: + // Short press in the duration wheel: cycle H -> M -> S, stay editing. + return TimerNavOutcome::CycleField; + case TimerNavLeaf::DurationReadOnly: + // Read-only: any press returns to the list. + _focus = TimerNavFocus::List; + return TimerNavOutcome::BackToList; + } + } + + // List focus. + if (onMain()) + return TimerNavOutcome::GoToMainMenu; // commit handled by the device + + _leaf = leafKind; // remember how the leaf behaves + _focus = TimerNavFocus::Editing; // drill into the leaf + return TimerNavOutcome::EnterLeaf; +} + +TimerNavOutcome TimerMenuNav::back() +{ + if (_focus == TimerNavFocus::Editing) + { + TimerNavLeaf leaf = _leaf; + _focus = TimerNavFocus::List; // every leaf back-out returns to the list + // The duration leaf commits its run-state value on back-out; value leaves + // are already live in RAM (committed with the list -> main batch). + return (leaf == TimerNavLeaf::DurationEditable) + ? TimerNavOutcome::CommitDuration + : TimerNavOutcome::BackToList; + } + + // List focus: context-aware exit by entry origin. + return (_origin == TimerNavOrigin::App) + ? TimerNavOutcome::ExitMenu + : TimerNavOutcome::GoToMainMenu; +} diff --git a/src/TimerMenuNav.h b/src/TimerMenuNav.h new file mode 100644 index 00000000..a20a0eae --- /dev/null +++ b/src/TimerMenuNav.h @@ -0,0 +1,93 @@ +#ifndef TimerMenuNav_h +#define TimerMenuNav_h + +#include + +// Display-free navigation state machine for the TIMER global menu (PRD #83 / +// issue #85). It owns the whole interaction model behind a small interface: +// list/leaf focus, the selected index, and the entry origin. Button inputs map +// to OUTCOMES that the device layer (MenuManager) acts on; the device keeps only +// drawing (the list indicator / the leaf value) and the single commit. Because +// it depends on nothing but Arduino.h, the interaction model is host-testable +// under test_timer without a device. See CONTEXT.md and docs/adr/0016. +// +// The list is the TIMER menu's slot rows. Value rows (enum / number / bool) drill +// into a leaf editor; the lone navigation row (MAIN) returns to the main menu. +// MAIN's index is supplied by the device from the slot table, so the state +// machine never hard-codes which row is MAIN. + +enum class TimerNavFocus : uint8_t { List, Editing }; +enum class TimerNavOrigin : uint8_t { Menu, App }; + +// The kind of leaf a value row drills into. Most rows are a plain Value leaf +// (cycle/step/toggle). DURATION is a field editor (HH/MM/SS wheel) when the timer +// is Idle, and read-only otherwise -- the device maps slot+timer-state to this via +// timerMenuLeafKind() (host-tested), so the gating decision lives in testable code. +enum class TimerNavLeaf : uint8_t { Value, DurationEditable, DurationReadOnly }; + +// Outcomes the device layer acts on. Anything not listed (plain list movement) is +// reported as None; the caller reads focus()/index() for the new cursor position. +enum class TimerNavOutcome : uint8_t +{ + None, // handled internally (list cursor moved / read-only no-op) + AdjustValue, // Editing, value leaf: caller applies timerMenuAdjust(index(), dir) + AdjustField, // Editing, DURATION leaf: caller steps the active H/M/S field + CycleField, // Editing, DURATION leaf, short press: cycle the H/M/S field + EnterLeaf, // List focus, value/duration row selected: drilled into its leaf + ConfirmBackToList, // Editing, value leaf, short press: confirm, back to the list + BackToList, // Editing, long press (or read-only press): back to the list + CommitDuration, // Editing, DURATION leaf, long press: commit duration, back to list + GoToMainMenu, // commit + return to the main menu + ExitMenu, // commit + return to the Timer app (origin == App) +}; + +class TimerMenuNav +{ +public: + // Default: an empty single-item list in List focus. Use enter() before driving. + TimerMenuNav(); + // itemCount = number of list rows; mainIndex = the MAIN (navigation) row. + TimerMenuNav(uint8_t itemCount, uint8_t mainIndex, + TimerNavOrigin origin = TimerNavOrigin::Menu); + + // (Re)enter the list: reset to List focus, index 0, with fresh bounds + origin. + // Called by the device whenever the TIMER menu is opened. + void enter(uint8_t itemCount, uint8_t mainIndex, TimerNavOrigin origin); + + TimerNavFocus focus() const { return _focus; } + uint8_t index() const { return _index; } + TimerNavOrigin origin() const { return _origin; } + bool onMain() const { return _index == _mainIndex; } + + // left/right. List focus: move the cursor with wrap (-> None). Editing focus: + // value leaf -> AdjustValue; DurationEditable -> AdjustField; DurationReadOnly + // -> None (no-op). + TimerNavOutcome navigate(int dir); + + // short press (middle button). + // List focus, value/duration row -> EnterLeaf (focus becomes Editing); pass + // the target row's leaf kind so the leaf behaves correctly once editing. + // List focus, MAIN -> GoToMainMenu. + // Editing, value leaf -> ConfirmBackToList (focus becomes List). + // Editing, DurationEditable leaf -> CycleField (stays Editing). + // Editing, DurationReadOnly leaf -> BackToList (any press returns to the list). + TimerNavOutcome select(TimerNavLeaf leafKind = TimerNavLeaf::Value); + + // long press (middle button, held). + // List focus -> context-aware exit: GoToMainMenu (origin Menu) or + // ExitMenu (origin App). + // Editing, value leaf -> BackToList (value already live in RAM). + // Editing, DurationEditable leaf -> CommitDuration (then back to the list). + // Editing, DurationReadOnly leaf -> BackToList. + TimerNavOutcome back(); + +private: + uint8_t _itemCount; + uint8_t _mainIndex; + uint8_t _index; + TimerNavFocus _focus; + TimerNavOrigin _origin; + TimerNavLeaf _leaf; // kind of the leaf currently being edited (Editing focus) +}; + +#endif diff --git a/src/TimerRuntime.cpp b/src/TimerRuntime.cpp new file mode 100644 index 00000000..0a45df50 --- /dev/null +++ b/src/TimerRuntime.cpp @@ -0,0 +1,181 @@ +#include "TimerRuntime.h" + +namespace TimerRuntime +{ + // The Finished transition's effect bundle. Ported straight from the old + // TimerManager::enterFinished, in the SAME order the imperative code emitted: + // publishState, publishRemaining, then the three gated effects (switch-to-app, + // brightness restore, end tone). Keeping the order byte-identical is what lets + // the adapter stay behaviourally indistinguishable on device. + static void appendFinished(std::vector &fx, const Inputs &in) + { + fx.push_back({EffectKind::PublishState}); + fx.push_back({EffectKind::PublishRemaining}); + if (in.navigationFree) + fx.push_back({EffectKind::SwitchToTimerApp}); + if (in.matrixOff) + { + Effect e{EffectKind::SetBrightness}; + e.brightness = in.brightness; + fx.push_back(e); + } + if (in.playEndTone) + { + Effect e{EffectKind::PlayTone}; + e.tone = Tone::End; + fx.push_back(e); + } + } + + // The reset bundle, shared by an explicit reset and a paused-duration edit: + // stop any sound, republish the (now Idle) run-state, and — when the panel is + // dark — drop brightness to 0. Ported in order from the old TimerManager::reset. + static void appendReset(std::vector &fx, const Inputs &in) + { + fx.push_back({EffectKind::StopSound}); + fx.push_back({EffectKind::PublishState}); + fx.push_back({EffectKind::PublishRemaining}); + if (in.matrixOff) + fx.push_back({EffectKind::SetBrightness}); // brightness 0 + } + + // The input-driven lifecycle transitions (issue #180): start/pause/reset/ + // setDuration decided as pure phase transitions over the same effect seam. The + // adapter owns the run-state bookkeeping each Transition names (enterRunning's + // runStart* capture, the ADR-0024 override restore behind ToIdle); here we only + // decide the phase change and the ordered effects. + static Result stepCommand(const State &s, const Inputs &in) + { + Result r; + switch (in.command) + { + case Command::Start: + if (s.phase == TimerState::Running) break; // already running: no-op + if (s.phase == TimerState::Finished) + r.effects.push_back({EffectKind::StopSound}); + r.effects.push_back({EffectKind::PublishState}); + r.effects.push_back({EffectKind::PublishRemaining}); + r.transition = Transition::ToRunning; + break; + + case Command::Pause: + if (s.phase == TimerState::Running) + { + r.effects.push_back({EffectKind::PublishState}); + r.effects.push_back({EffectKind::PublishRemaining}); + r.transition = Transition::ToPaused; + } + else if (s.phase == TimerState::Paused) + { + // A second press resumes: same publishes as enterRunning. + r.effects.push_back({EffectKind::PublishState}); + r.effects.push_back({EffectKind::PublishRemaining}); + r.transition = Transition::ToRunning; + } + break; + + case Command::Reset: + appendReset(r.effects, in); + r.transition = Transition::ToIdle; + break; + + case Command::SetDuration: + if (s.phase == TimerState::Idle) + { + r.effects.push_back({EffectKind::PublishRemaining}); + r.transition = Transition::IdleReload; + } + else if (s.phase == TimerState::Paused) + { + // US6: editing the duration while Paused resets cleanly to Idle + // with the new duration — the reset bundle, then remaining reloads. + appendReset(r.effects, in); + r.transition = Transition::ToIdle; + } + // Running / Finished: duration changes silently (the adapter still + // persists + republishes it); no run-state transition. + break; + + case Command::Tick: + break; // unreachable: step() dispatches Tick to the countdown path + } + return r; + } + + Result step(const State &state, unsigned long nowMs, const Inputs &in) + { + if (in.command != Command::Tick) + return stepCommand(state, in); + + Result r; + + if (state.phase == TimerState::Finished) + { + if (in.finishedMode == FinishedMode::AutoClear && + (nowMs - state.enteredFinishedMs >= (unsigned long)in.finishedHold * 1000UL)) + { + // Auto-clear: stop the sound, return to Idle, republish, and (when + // the panel is dark) drop brightness to 0. The adapter maps ToIdle + // to returnToIdle()+state/remaining so ADR-0024's store stays there. + r.effects.push_back({EffectKind::StopSound}); + r.effects.push_back({EffectKind::PublishState}); + r.effects.push_back({EffectKind::PublishRemaining}); + if (in.matrixOff) + r.effects.push_back({EffectKind::SetBrightness}); // brightness 0 + r.transition = Transition::ToIdle; + } + else if (in.finishedMode == FinishedMode::ReAlert && in.realertArmed && + (nowMs - state.lastRealertMs >= (unsigned long)in.realertInterval * 1000UL)) + { + // Re-sound the end tone at the interval. The anchor advances even + // when a tone is already playing (one tone at a time), so the next + // re-alert lands a full interval later — not immediately after. + if (!in.isPlaying && in.realertToneSet) + { + Effect e{EffectKind::PlayTone}; + e.tone = Tone::End; + r.effects.push_back(e); + } + r.realertFired = true; + } + return r; + } + + if (state.phase != TimerState::Running) + return r; + + if (in.newRemaining != state.remainingSec) + { + if (in.countdownArmed) + { + // Beep if any second in [1, countdownSeconds] was crossed this + // tick. The buzzer plays one tone at a time, so a single beep + // covers the gap when tick() falls behind (rather than queuing N + // back-to-back plays). + uint32_t lo = in.newRemaining > 0 ? in.newRemaining : 1; + uint32_t hi = state.remainingSec > 0 ? state.remainingSec - 1 : 0; + if (hi > in.countdownSeconds) hi = in.countdownSeconds; + if (hi >= lo && !in.isPlaying) + { + Effect e{EffectKind::PlayTone}; + e.tone = Tone::Tick; + r.effects.push_back(e); + } + } + + if (in.publishInterval > 0 && + (nowMs - state.lastPublishMs >= (unsigned long)in.publishInterval * 1000UL)) + { + r.effects.push_back({EffectKind::PublishRemaining}); + r.publishFired = true; + } + } + + if (in.newRemaining == 0) + { + appendFinished(r.effects, in); + r.transition = Transition::ToFinished; + } + return r; + } +} diff --git a/src/TimerRuntime.h b/src/TimerRuntime.h new file mode 100644 index 00000000..b6c23289 --- /dev/null +++ b/src/TimerRuntime.h @@ -0,0 +1,129 @@ +#ifndef TimerRuntime_h +#define TimerRuntime_h + +#include +#include + +#include "TimerEnums.h" // TimerState / FinishedMode (the run-state the engine keys on) + +// Pure timer run-state engine (PRD #28, issues #178/#179). step() is a total +// function of (state, nowMs, inputs) that RETURNS the ordered effects plus the +// run-state bookkeeping the adapter must apply, instead of performing anything; +// the TimerManager adapter executes each returned effect in order against +// PeripheryManager / DisplayManager / the wire seam and applies the bookkeeping. +// +// No hardware headers: the engine names PeripheryManager/DisplayManager nowhere, +// so it is host-compilable on its own. The [env:native_runtime] link — which +// pulls in TimerRuntime.cpp and links no TimerManager singleton — is the +// architectural claim, exactly as native_sync/native_validate are for theirs. +namespace TimerRuntime +{ + // Which pre-loaded tune the adapter should play. The engine stays free of the + // RTTTL strings themselves: End -> endRtttl, Tick -> tickRtttl at the adapter. + enum class Tone : uint8_t { End, Tick }; + + // What the adapter is asking the engine to decide. Tick is the wall-clock + // countdown step (issues #178/#179); the rest are the input-driven lifecycle + // commands (issue #180) — the same public verbs TimerManager exposes. Default + // is Tick so an Inputs built without naming a command is the countdown step. + enum class Command : uint8_t { Tick, Start, Pause, Reset, SetDuration }; + + // The full effect vocabulary. The Running tick emits PlayTone(Tick)/publish; + // the Finished transition emits the switch/brightness/end-tone bundle; + // auto-clear emits StopSound + the return-to-Idle publish/brightness effects. + enum class EffectKind : uint8_t + { + PlayTone, + StopSound, + SwitchToTimerApp, + SetBrightness, + PublishState, + PublishRemaining, + }; + + // Flat tagged effect. `tone` is meaningful only for PlayTone; `brightness` + // only for SetBrightness. A flat struct (not a union/variant) keeps the type + // trivially copyable and gnu++11-friendly for the device build. + struct Effect + { + EffectKind kind; + Tone tone = Tone::End; // valid iff kind == PlayTone + uint8_t brightness = 0; // valid iff kind == SetBrightness + }; + + // The run-state bookkeeping the adapter applies AFTER executing the effects. + // The engine decides the transition; the adapter owns the state mutation (and + // the override-store restore behind ToIdle) so ADR-0024's store stays in the + // manager (PRD #28 US7). + enum class Transition : uint8_t + { + None, // no phase change + ToFinished, // Running reached zero + ToIdle, // return to Idle: Finished auto-clear, reset, or a paused-duration edit + // (adapter: returnToIdle() + remaining = durationSec) + ToRunning, // start (fresh: load durationSec) or resume from Paused (keep remaining) + ToPaused, // Running -> Paused (adapter freezes remaining = newRemaining) + IdleReload, // duration edited while Idle: remaining = durationSec, phase stays Idle + }; + + // Environment gates resolved to plain bools/values so the engine needs none of + // the globals/singletons that own them: + // navigationFree = !GAME_ACTIVE && !BLOCK_NAVIGATION && !MenuManager.inMenu + // matrixOff = MATRIX_OFF (restore brightness when the panel is dark) + // playEndTone = SOUND_ACTIVE && buzzer != Off && endRtttl set + // countdownArmed = SOUND_ACTIVE && buzzer == Countdown && tickRtttl set + // realertArmed = SOUND_ACTIVE && buzzer != Off + // realertToneSet = endRtttl set + struct Inputs + { + Command command = Command::Tick; // which lifecycle decision to make + + // Running tick. + uint32_t newRemaining = 0; // computeCurrentRemaining() + bool countdownArmed = false; + bool isPlaying = false; // PeripheryManager.isPlaying() + uint16_t countdownSeconds = 0; // TIMER_COUNTDOWN_SECONDS + uint16_t publishInterval = 0; // TIMER_PUBLISH_INTERVAL (sec; 0 disables) + + // Lifecycle commands (start/pause/reset/setDuration). durationSec is the + // configured full duration; the adapter loads it into remaining on a fresh + // start / an Idle duration edit, and into remaining on the return-to-Idle. + uint32_t durationSec = 0; + + // Running->Finished transition. + bool navigationFree = false; + bool matrixOff = false; + uint8_t brightness = 0; + bool playEndTone = false; + + // Finished branch. + FinishedMode finishedMode = FinishedMode::AutoClear; + uint16_t finishedHold = 0; // TIMER_FINISHED_HOLD (sec) + uint16_t realertInterval = 0; // TIMER_REALERT_INTERVAL (sec) + bool realertArmed = false; + bool realertToneSet = false; + }; + + struct State + { + TimerState phase; + uint32_t remainingSec; // the previous remaining (before this tick) + unsigned long lastPublishMs = 0; // Running publish throttle anchor + unsigned long enteredFinishedMs = 0; // auto-clear hold anchor + unsigned long lastRealertMs = 0; // re-alert interval anchor + }; + + struct Result + { + std::vector effects; + Transition transition = Transition::None; + bool publishFired = false; // Running: adapter sets lastPublishMs = nowMs + bool realertFired = false; // Finished: adapter sets lastRealertMs = nowMs + }; + + // Returns the ordered effects + bookkeeping for the tick that `state` (with + // `inputs` at `nowMs`) represents. + Result step(const State &state, unsigned long nowMs, const Inputs &in); +} + +#endif diff --git a/src/TimerSettings.cpp b/src/TimerSettings.cpp new file mode 100644 index 00000000..bedf7e81 --- /dev/null +++ b/src/TimerSettings.cpp @@ -0,0 +1,591 @@ +#include "TimerSettings.h" + +#include +#include // snprintf (timerFormatHMS, the bar-color formatters) + +// The descriptor-table family is the PURE Timer validation surface (#143): it links +// against ArduinoJson + the enum codec tables ONLY -- no Globals.h (FastLED), no +// TimerManager singleton, no MQTTManager, no Preferences. That dependency-light link +// is what lets TimerCommand::classify be host-tested in [env:native_validate] with no +// stubs. The impure half -- the member-config apply/emit/publish hooks, the NVS +// round-trip, and the full-config dump, all of which DO touch the singleton / MQTT / +// Preferences -- lives in TimerSettingsApply.cpp, linked only in device + full-suite +// builds. The two share this header. +// +// The 13 persisted timer-setting globals are DEFINED here (moved out of Globals.cpp; +// Globals.h keeps the extern decls so every other caller is source-unchanged) so the +// TIMER_SETTINGS_DESCS storage pointers resolve without dragging in Globals.cpp. +uint32_t TIMER_MAX_DURATION = 86400; +uint16_t TIMER_PUBLISH_INTERVAL = 1; +uint16_t TIMER_FINISHED_HOLD = 10; +uint16_t TIMER_REALERT_INTERVAL = 15; +uint16_t TIMER_COUNTDOWN_SECONDS = 3; +String TIMER_MELODY_TICK = "timer_tick"; +String TIMER_MELODY_END = "timer_end"; +bool TIMER_BAR_ENABLED = true; +bool TIMER_ICON_ENABLED = true; +uint32_t TIMER_BAR_COLOR = 0; +uint32_t TIMER_BAR_BG_COLOR = 0; // 0 = black = no track (literal off; see ADR-0020) +bool TIMER_SYNC_FOLLOW = true; // fresh clock is a follower; standalone is opt-out (#124) +String TIMER_SYNC_TARGETS = ""; + +namespace +{ + // sync_targets accepts "" (off), "all", or a comma list of device-id tokens + // ([A-Za-z0-9_-], 1..32 each). Moved here from TimerManager.cpp so the table is + // the single home of the sync_targets validation. Same rule as before. + bool isValidSyncTargets(const String &s) + { + String t = s; t.trim(); + if (t.length() == 0 || t == "all") return true; + int start = 0; + const int n = t.length(); + while (start <= n) + { + int comma = t.indexOf(',', start); + if (comma < 0) comma = n; + String tok = t.substring(start, comma); tok.trim(); + if (tok.length() == 0 || tok.length() > 32) return false; + for (size_t i = 0; i < tok.length(); ++i) + { + char c = tok[i]; + bool ok = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || c == '_' || c == '-'; + if (!ok) return false; + } + if (comma == n) break; + start = comma + 1; + } + return true; + } + + // bar_color: a JSON number 0..0xFFFFFF, or an "#RRGGBB" / "RRGGBB" hex string. + bool parseBarColor(JsonVariantConst v, TcValue &out) + { + if (v.is() || v.is()) + { + uint32_t n = v.as(); + if (n > 0xFFFFFFu) return false; + out.num = n; + return true; + } + if (v.is() || v.is()) + { + String s = v.as(); + s.trim(); + if (s.length() > 0 && s[0] == '#') s = s.substring(1); + if (s.length() != 6) return false; // exactly RRGGBB + for (size_t i = 0; i < s.length(); ++i) + { + char c = s[i]; + bool ok = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + if (!ok) return false; + } + out.num = (uint32_t)strtoul(s.c_str(), nullptr, 16); + return true; + } + return false; + } + + // max_duration carrier-native HA-attribute formatter (PRD #66 / issue #68): + // renders the stored cap (raw seconds, a U32) as the trimmed H:MM:SS clock + // string the Duration text entity's OWN state speaks, by reusing the exact + // formatHMS the Duration state uses ("24:00:00", "1:00:00", "0:45"). This is + // the first key whose attribute representation differs PER CARRIER: the state + // sensor keeps the raw-seconds number (no formatter), while the Duration + // carrier renders this clock string — each carrier in its native form, over + // the same persisted storage, so the underlying value cannot drift. + void formatMaxDurationHMS(const TimerSettingDesc &d, JsonDocument &doc) + { + uint32_t v = *static_cast(d.storage); + doc[d.cmdKey] = timerFormatHMS(v); // family-local pure formatter (#143) + } + + // sync_targets: strict string type, then the comma-list rule above. + bool parseSyncTargets(JsonVariantConst v, TcValue &out) + { + if (!(v.is() || v.is())) return false; + String s = v.as(); + if (!isValidSyncTargets(s)) return false; + out.str = s; + return true; + } +} + +// bar_color / bar_bg_color HA-attribute formatters (PRD #57 / issue #59, ADR-0020). +// External linkage (not file-local) so both the attribute-group table here and the +// HTTP full-config dump in TimerSettingsApply.cpp render the stored 0xRRGGBB int the +// same way (#143) -- they cannot disagree about the string form. bar_color's off +// sentinel (0) is "default" (follow text color, ADR-0004); bar_bg_color's off is +// "none" (black = no track, literal off); any other value is uppercase "#RRGGBB". +void timerFormatBarColor(const TimerSettingDesc &d, JsonDocument &doc) +{ + uint32_t v = *static_cast(d.storage); + if (v == 0) { doc[d.cmdKey] = "default"; return; } + char buf[8]; + snprintf(buf, sizeof(buf), "#%06X", (unsigned)(v & 0xFFFFFFu)); + doc[d.cmdKey] = buf; +} + +void timerFormatBarBgColor(const TimerSettingDesc &d, JsonDocument &doc) +{ + uint32_t v = *static_cast(d.storage); + if (v == 0) { doc[d.cmdKey] = "none"; return; } + char buf[8]; + snprintf(buf, sizeof(buf), "#%06X", (unsigned)(v & 0xFFFFFFu)); + doc[d.cmdKey] = buf; +} + +// One row per persisted value-config Timer key. The two inSnapshot=false rows are +// the sync roles (local identity), excluded from the propagated config block. +// cmdKey, devKey, nvsKey, type, check, lo, hi, dfltNum, dfltStr, bespoke, inSnapshot, storage +const TimerSettingDesc TIMER_SETTINGS_DESCS[] = { + {"finished_hold", "timer_finished_hold", "TFHOLD", TcType::U16, TcCheck::UIntRange, 1, 300, 10, nullptr, nullptr, true, &TIMER_FINISHED_HOLD}, + {"realert_interval", "timer_realert_interval", "TRALERT", TcType::U16, TcCheck::UIntRange, 5, 300, 15, nullptr, nullptr, true, &TIMER_REALERT_INTERVAL}, + {"countdown_seconds", "timer_countdown_seconds", "TCDOWN", TcType::U16, TcCheck::UIntRange, 0, 30, 3, nullptr, nullptr, true, &TIMER_COUNTDOWN_SECONDS}, + {"max_duration", "timer_max_duration", "TMAXD", TcType::U32, TcCheck::UIntRange, 1, 604800, 86400, nullptr, nullptr, true, &TIMER_MAX_DURATION}, + {"remaining_publish_interval", "timer_remaining_publish_interval", "TPUBI", TcType::U16, TcCheck::UIntRange, 1, 60, 1, nullptr, nullptr, true, &TIMER_PUBLISH_INTERVAL}, + {"icon_enabled", "timer_icon_enabled", "TICONEN", TcType::Bool, TcCheck::Bool, 0, 0, 1, nullptr, nullptr, true, &TIMER_ICON_ENABLED}, + {"bar_enabled", "timer_bar_enabled", "TBAREN", TcType::Bool, TcCheck::Bool, 0, 0, 1, nullptr, nullptr, true, &TIMER_BAR_ENABLED}, + {"bar_color", "timer_bar_color", "TBARC", TcType::U32, TcCheck::Bespoke, 0, 0, 0, nullptr, parseBarColor, true, &TIMER_BAR_COLOR}, + {"bar_bg_color", "timer_bar_bg_color", "TBARBC", TcType::U32, TcCheck::Bespoke, 0, 0, 0, nullptr, parseBarColor, true, &TIMER_BAR_BG_COLOR}, + {"melody_tick", "timer_melody_tick", "TMTICK", TcType::Str, TcCheck::Name, 0, 0, 0, "timer_tick", nullptr, true, &TIMER_MELODY_TICK}, + {"melody_end", "timer_melody_end", "TMEND", TcType::Str, TcCheck::Name, 0, 0, 0, "timer_end", nullptr, true, &TIMER_MELODY_END}, + {"sync_follow", "timer_sync_follow", "TSYNF", TcType::Bool, TcCheck::Bool, 0, 0, 1, nullptr, nullptr, false, &TIMER_SYNC_FOLLOW}, + {"sync_targets", "timer_sync_targets", "TSYNT", TcType::Str, TcCheck::Bespoke, 0, 0, 0, "", parseSyncTargets, false, &TIMER_SYNC_TARGETS}, +}; + +const size_t TIMER_SETTINGS_DESC_COUNT = sizeof(TIMER_SETTINGS_DESCS) / sizeof(TIMER_SETTINGS_DESCS[0]); + +// The header's compile-time extent (used to size the one-shot snapshot buffer) must +// match the actual table. If a row is added, bump TIMER_SETTINGS_DESC_CAP. +static_assert(sizeof(TIMER_SETTINGS_DESCS) / sizeof(TIMER_SETTINGS_DESCS[0]) == TIMER_SETTINGS_DESC_CAP, + "TIMER_SETTINGS_DESC_CAP must equal the descriptor row count"); + +bool timerSettingParse(const TimerSettingDesc &d, JsonVariantConst v, TcValue &out) +{ + if (d.bespoke) return d.bespoke(v, out); + + switch (d.check) + { + case TcCheck::Bool: + if (!v.is()) return false; + out.b = v.as(); + return true; + + case TcCheck::UIntRange: + { + if (!(v.is() || v.is())) return false; // number only (not bool/string/null) + uint32_t n = v.as(); + if (n < d.lo || n > d.hi) return false; + out.num = n; + return true; + } + + case TcCheck::Name: + { + // Bare filename; same char-rule as icons. Empty resets to the default at + // coerce time (matches legacy melody semantics). Mirrors the legacy path, + // which coerced via as() without a strict pre-type-check. + String s = v.as(); + if (!timerIsValidIconName(s)) return false; + out.str = (s.length() == 0) ? String(d.dfltStr) : s; + return true; + } + + case TcCheck::Bespoke: + return false; // unreachable: bespoke rows carry d.bespoke, handled above + } + return false; +} + +bool timerSettingStore(const TimerSettingDesc &d, const TcValue &v) +{ + switch (d.type) + { + case TcType::U16: + { + uint16_t *p = static_cast(d.storage); + if (*p == (uint16_t)v.num) return false; + *p = (uint16_t)v.num; + return true; + } + case TcType::U32: + { + uint32_t *p = static_cast(d.storage); + if (*p == v.num) return false; + *p = v.num; + return true; + } + case TcType::Bool: + { + bool *p = static_cast(d.storage); + if (*p == v.b) return false; + *p = v.b; + return true; + } + case TcType::Str: + { + String *p = static_cast(d.storage); + if (*p == v.str) return false; + *p = v.str; + return true; + } + } + return false; +} + +// timerSettingsLoadNvs / timerSettingsSaveNvs (Preferences round-trip) moved to the +// impure TimerSettingsApply.cpp (#143): they touch Preferences, which this pure TU +// deliberately does not link. + +void timerSettingsLoadDevJson(JsonObjectConst obj) +{ + for (size_t i = 0; i < TIMER_SETTINGS_DESC_COUNT; ++i) + { + const TimerSettingDesc &d = TIMER_SETTINGS_DESCS[i]; + if (!obj.containsKey(d.devKey)) continue; + TcValue val; + if (timerSettingParse(d, obj[d.devKey], val)) // per-key best-effort: skip if invalid + timerSettingStore(d, val); + } +} + +void timerSettingEmitValue(const TimerSettingDesc &d, JsonDocument &doc) +{ + switch (d.type) + { + case TcType::U16: doc[d.cmdKey] = *static_cast(d.storage); break; + case TcType::U32: doc[d.cmdKey] = *static_cast(d.storage); break; + case TcType::Bool: doc[d.cmdKey] = *static_cast (d.storage); break; + case TcType::Str: doc[d.cmdKey] = *static_cast (d.storage); break; + } +} + +void timerSettingsBuildSnapshot(JsonDocument &doc) +{ + for (size_t i = 0; i < TIMER_SETTINGS_DESC_COUNT; ++i) + { + const TimerSettingDesc &d = TIMER_SETTINGS_DESCS[i]; + if (!d.inSnapshot) continue; + timerSettingEmitValue(d, doc); + } +} + +void timerSettingsCaptureSnapshot(TcValue out[]) +{ + for (size_t i = 0; i < TIMER_SETTINGS_DESC_COUNT; ++i) + { + const TimerSettingDesc &d = TIMER_SETTINGS_DESCS[i]; + if (!d.inSnapshot) continue; // sync_* (local identity) excluded by construction + switch (d.type) + { + case TcType::U16: out[i].num = *static_cast(d.storage); break; + case TcType::U32: out[i].num = *static_cast(d.storage); break; + case TcType::Bool: out[i].b = *static_cast (d.storage); break; + case TcType::Str: out[i].str = *static_cast (d.storage); break; + } + } +} + +void timerSettingsRestoreSnapshot(const TcValue in[]) +{ + for (size_t i = 0; i < TIMER_SETTINGS_DESC_COUNT; ++i) + { + const TimerSettingDesc &d = TIMER_SETTINGS_DESCS[i]; + if (!d.inSnapshot) continue; + timerSettingStore(d, in[i]); // dispatch-by-type write; equality-skip return ignored + } +} + +const TimerSettingDesc *timerSettingByCmdKey(const char *cmdKey) +{ + for (size_t i = 0; i < TIMER_SETTINGS_DESC_COUNT; ++i) + if (strcmp(TIMER_SETTINGS_DESCS[i].cmdKey, cmdKey) == 0) + return &TIMER_SETTINGS_DESCS[i]; + return nullptr; +} + +// Inline RTTTL classifier/validator (issue #102). Pure, no globals touched. +namespace +{ + // RTTTL tunes for a single timer alarm/tick are short; cap so a pathological + // payload can't bloat the one-shot run state. + constexpr size_t kInlineRtttlMaxLen = 256; + + // True iff the comma-separated control section carries at least one RTTTL default + // token -- a token whose key is exactly d/o/b (duration/octave/beat). Checks the + // token PREFIX (not a substring), so "foo=4" is not mistaken for an "o=" default. + bool controlHasDefaultToken(const String &control) + { + int start = 0; + const int n = control.length(); + while (start <= n) + { + int comma = control.indexOf(',', start); + if (comma < 0) comma = n; + String tok = control.substring(start, comma); + tok.trim(); + tok.toLowerCase(); + if (tok.startsWith("d=") || tok.startsWith("o=") || tok.startsWith("b=")) return true; + if (comma == n) break; + start = comma + 1; + } + return false; + } +} + +bool timerMelodyIsInline(const String &s) +{ + // A bare melody file-name token is [A-Za-z0-9_-]* and never contains a colon; + // an inline RTTTL tune always carries ':' separators. Content is the only signal. + return s.indexOf(':') >= 0; +} + +bool timerMelodyValidateInline(const String &s) +{ + if (s.length() == 0 || s.length() > kInlineRtttlMaxLen) return false; + + // RTTTL is name:control:notes -- exactly two colons (name may be empty). + int c1 = s.indexOf(':'); + if (c1 < 0) return false; + int c2 = s.indexOf(':', c1 + 1); + if (c2 < 0) return false; + if (s.indexOf(':', c2 + 1) >= 0) return false; // a third colon is malformed + + String control = s.substring(c1 + 1, c2); + String notes = s.substring(c2 + 1); + if (control.length() == 0 || notes.length() == 0) return false; + + // The control section must carry at least one RTTTL default token (d=/o=/b=). + if (!controlHasDefaultToken(control)) return false; + + return true; +} + +// Relocated out of TimerManager (#142). Self-contained: inlines the +// h*3600+m*60+sec sum (timerHmsToSeconds below) rather than delegating, so the +// parse stays one readable pass -- behaviour-identical to the former static. +bool timerParseHMS(const String &in, uint32_t &outSeconds) +{ + String s = in; + s.trim(); + if (s.length() == 0) return false; + + // Split on ':' into up to three numeric fields. Colon count decides units: + // two colons = HH:MM:SS, one = MM:SS, none = bare seconds. Each field must be + // a non-empty run of digits. Fields are summed without a 0-59 cap (carry). + uint32_t fields[3] = {0, 0, 0}; + int count = 0; + int start = 0; + for (int i = 0; i <= s.length(); i++) + { + if (i == s.length() || s[i] == ':') + { + if (count >= 3) return false; // more than two colons + int len = i - start; + if (len == 0) return false; // empty field (e.g. "5:", ":30") + uint32_t v = 0; + for (int j = start; j < i; j++) + { + char c = s[j]; + if (c < '0' || c > '9') return false; // non-numeric + v = v * 10 + (uint32_t)(c - '0'); + } + fields[count++] = v; + start = i + 1; + } + } + + uint32_t h = 0, m = 0, sec = 0; + if (count == 3) { h = fields[0]; m = fields[1]; sec = fields[2]; } + else if (count == 2) { m = fields[0]; sec = fields[1]; } + else { sec = fields[0]; } + + outSeconds = h * 3600UL + m * 60UL + sec; + return true; +} + +// Relocated out of TimerManager (#142). Pure case-insensitive +// membership check over the three action verbs -- behaviour-identical. +bool timerIsValidAction(const String &s) +{ + String a = s; a.toLowerCase(); + return a == "start" || a == "pause" || a == "reset"; +} + +// Relocated out of TimerManager's statics (#143) so the command validator links the +// table family, not the singleton. Behaviour-identical to the former statics, all +// of which are gone (#204/#205 deleted the forwarders). + +bool timerIsValidIconName(const String &name) +{ + // Empty is valid (clears the icon). Otherwise [A-Za-z0-9_-], length cap 32. + if (name.length() == 0) return true; + if (name.length() > 32) return false; + for (size_t i = 0; i < name.length(); ++i) + { + char c = name[i]; + bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') + || (c >= '0' && c <= '9') || c == '_' || c == '-'; + if (!ok) return false; + } + return true; +} + +bool timerParseBuzzerMode(const String &s, BuzzerMode &out) +{ + uint8_t idx; + if (!timerEnumParse(TIMER_BUZZER_CODEC, TIMER_BUZZER_CODEC_COUNT, s, idx)) return false; + out = (BuzzerMode)idx; + return true; +} + +bool timerParseFinishedMode(const String &s, FinishedMode &out) +{ + uint8_t idx; + if (!timerEnumParse(TIMER_FINISHED_CODEC, TIMER_FINISHED_CODEC_COUNT, s, idx)) return false; + out = (FinishedMode)idx; + return true; +} + +String timerClock(uint32_t seconds, ClockStyle style) +{ + uint32_t h = seconds / 3600; + uint32_t m = (seconds % 3600) / 60; + uint32_t s = seconds % 60; + char buf[16]; + switch (style) + { + case ClockStyle::Trimmed: + if (h > 0) snprintf(buf, sizeof(buf), "%u:%02u:%02u", (unsigned)h, (unsigned)m, (unsigned)s); + else snprintf(buf, sizeof(buf), "%u:%02u", (unsigned)m, (unsigned)s); + break; + case ClockStyle::Padded: + snprintf(buf, sizeof(buf), "%02u:%02u:%02u", (unsigned)h, (unsigned)m, (unsigned)s); + break; + case ClockStyle::Compact: + if (seconds < 3600) snprintf(buf, sizeof(buf), "%u:%02u", (unsigned)(seconds / 60), (unsigned)s); + else if (seconds < 36000) snprintf(buf, sizeof(buf), "%u:%02u", (unsigned)h, (unsigned)m); + else snprintf(buf, sizeof(buf), "%02u:%02u", (unsigned)h, (unsigned)m); + break; + } + return String(buf); +} + +String timerFormatHMS(uint32_t seconds) +{ + return timerClock(seconds, ClockStyle::Trimmed); +} + +// Relocated out of TimerManager (#206), the singleton's last pure statics +// (secondsToHMS / hmsToSeconds) -- byte-identical. +void timerSecondsToHMS(uint32_t sec, uint32_t &h, uint32_t &m, uint32_t &s) +{ + h = sec / 3600; + m = (sec % 3600) / 60; + s = sec % 60; +} + +uint32_t timerHmsToSeconds(uint32_t h, uint32_t m, uint32_t s) +{ + return h * 3600UL + m * 60UL + s; +} + +// --------------------------------------------------------------------------- +// HA attribute-group projection (PRD #57 / issues #58, #59). The buzzer + finished +// HASelects were lit up first (#58); #59 extended the JSON-attributes opt-in to +// HASensor, lighting up the remaining + state sensors. realert_interval is listed +// first on the finished carrier so the folded payload is a superset of the legacy +// bespoke {"realert_interval":N}. remaining_publish_interval rides BOTH the +// remaining sensor (its own cadence) and the state sensor (a complete config view) +// -- one settings row, two carrier rows. bar_color carries the per-row formatter +// so it renders as "default"/"#RRGGBB" rather than its raw-int snapshot form. +// max_duration (PRD #66 / issue #68) is the first MULTI-CARRIER key whose +// representation differs PER CARRIER: it rides the Duration text entity in +// carrier-native clock form (formatMaxDurationHMS -> "24:00:00") AND the state +// sensor in raw seconds (no formatter -> 86400) -- one settings row, two carrier +// rows, two representations over the same persisted storage. +// carrier, cmdKey, format +const TimerAttrGroupDesc TIMER_ATTR_GROUP_DESCS[] = { + {TimerHaEntity::Duration, "max_duration", formatMaxDurationHMS}, + {TimerHaEntity::Finished, "realert_interval", nullptr}, + {TimerHaEntity::Finished, "finished_hold", nullptr}, + {TimerHaEntity::Buzzer, "countdown_seconds", nullptr}, + {TimerHaEntity::Buzzer, "melody_tick", nullptr}, + {TimerHaEntity::Buzzer, "melody_end", nullptr}, + {TimerHaEntity::Remaining, "remaining_publish_interval", nullptr}, + {TimerHaEntity::State, "max_duration", nullptr}, + {TimerHaEntity::State, "remaining_publish_interval", nullptr}, + {TimerHaEntity::State, "icon_enabled", nullptr}, + {TimerHaEntity::State, "bar_enabled", nullptr}, + {TimerHaEntity::State, "bar_color", timerFormatBarColor}, + {TimerHaEntity::State, "bar_bg_color", timerFormatBarBgColor}, + {TimerHaEntity::State, "sync_follow", nullptr}, + {TimerHaEntity::State, "sync_targets", nullptr}, +}; + +const size_t TIMER_ATTR_GROUP_DESC_COUNT = + sizeof(TIMER_ATTR_GROUP_DESCS) / sizeof(TIMER_ATTR_GROUP_DESCS[0]); + +void timerBuildAttributeGroup(TimerHaEntity carrier, JsonDocument &doc) +{ + for (size_t i = 0; i < TIMER_ATTR_GROUP_DESC_COUNT; ++i) + { + const TimerAttrGroupDesc &g = TIMER_ATTR_GROUP_DESCS[i]; + if (g.carrier != carrier) continue; + const TimerSettingDesc *d = timerSettingByCmdKey(g.cmdKey); + if (!d) continue; // table self-consistency is pinned by test T14 + if (g.format) g.format(*d, doc); + else timerSettingEmitValue(*d, doc); + } +} + +// =========================================================================== +// Member-backed config half (B1) -- PURE validation projection (#143). +// The validate predicates + the parallel {cmdKey, validate} table +// (TIMER_MEMBER_VALIDATORS) that TimerCommand::classify uses live HERE, with no +// singleton/MQTT dependency. The impure half -- the apply/emit/publish hooks, the +// full TIMER_MEMBER_CONFIG_DESCS table, and its snapshot/publish helpers -- lives in +// TimerSettingsApply.cpp (they route through TimerManager's setters and the MQTT +// seam). Both tables reference the SAME validate function pointers below, so a drift +// guard test pins them row-for-row: they cannot disagree about a member key. +// =========================================================================== +bool timerMemValidateBuzzer(JsonVariantConst v, TcValue &out) +{ + BuzzerMode m; + if (!timerParseBuzzerMode(v.as(), m)) return false; + out.num = (uint32_t)m; + return true; +} + +bool timerMemValidateFinished(JsonVariantConst v, TcValue &out) +{ + FinishedMode m; + if (!timerParseFinishedMode(v.as(), m)) return false; + out.num = (uint32_t)m; + return true; +} + +bool timerMemValidateIcon(JsonVariantConst v, TcValue &out) +{ + String s = v.as(); + if (!timerIsValidIconName(s)) return false; + out.str = s; + return true; +} + +const TimerMemberValidatorDesc TIMER_MEMBER_VALIDATORS[] = { + {"buzzer", timerMemValidateBuzzer}, + {"finished", timerMemValidateFinished}, + {"icon_idle", timerMemValidateIcon}, + {"icon_running", timerMemValidateIcon}, + {"icon_paused", timerMemValidateIcon}, + {"icon_finished", timerMemValidateIcon}, +}; + +const size_t TIMER_MEMBER_VALIDATOR_COUNT = + sizeof(TIMER_MEMBER_VALIDATORS) / sizeof(TIMER_MEMBER_VALIDATORS[0]); + +static_assert(sizeof(TIMER_MEMBER_VALIDATORS) / sizeof(TIMER_MEMBER_VALIDATORS[0]) == TIMER_MEMBER_CONFIG_DESC_CAP, + "TIMER_MEMBER_CONFIG_DESC_CAP must equal the member-config row count"); diff --git a/src/TimerSettings.h b/src/TimerSettings.h new file mode 100644 index 00000000..88ff9aac --- /dev/null +++ b/src/TimerSettings.h @@ -0,0 +1,281 @@ +#ifndef TimerSettings_h +#define TimerSettings_h + +#include +#include + +#include "TimerHa.h" // TimerHaEntity (the HA carrier each attribute group rides) +#include "TimerEnums.h" // BuzzerMode / FinishedMode for the relocated enum parsers (#143) + +// Persisted Timer settings: the single descriptor table that drives validation, +// apply, NVS persistence, dev.json overrides and the propagated config snapshot +// for the value-config Timer keys. Modelled on TimerHa.h ("one table, no drift"). +// +// This table is a SUPERSET, not "the config block": the `inSnapshot` column is the +// codified boundary CONTEXT.md describes in prose -- +// * config block = the inSnapshot == true rows (the propagated snapshot) +// * sync roles / local id = the inSnapshot == false rows (sync_follow/sync_targets, +// deliberately excluded from the snapshot per ADR-0006) +// +// Out of scope (the B1 boundary, see docs/adr/0007): duration/buzzer/finished and the +// four icon_ keys stay on TimerManager's publish-aware setters. They are config +// block too, but their snapshot/broadcast membership is governed by the shared +// member-config list in TimerManager.cpp, not by this table. + +enum class TcType : uint8_t { U16, U32, Bool, Str }; +enum class TcCheck : uint8_t { Bool, UIntRange, Name, Bespoke }; + +// A validated + coerced value, staged before any global is written so parseCommand +// keeps its atomic-reject contract (validate everything, then apply). +struct TcValue +{ + uint32_t num = 0; + bool b = false; + String str; +}; + +struct TimerSettingDesc +{ + const char *cmdKey; // POST/MQTT + snapshot key, e.g. "finished_hold" + const char *devKey; // dev.json key, e.g. "timer_finished_hold" + const char *nvsKey; // NVS "awtrix" key, e.g. "TFHOLD" + TcType type; + TcCheck check; + uint32_t lo, hi; // UIntRange bounds (ranges live HERE, once) + uint32_t dfltNum; // NVS default for U16/U32/Bool + const char *dfltStr; // NVS default for Str; also the Name empty-substitution value + // Bespoke validators (bar_color / sync_targets only); nullptr for declarative rows. + bool (*bespoke)(JsonVariantConst, TcValue &out); + bool inSnapshot; // member of the propagated config block iff true + void *storage; // typed by `type`: uint16_t* / uint32_t* / bool* / String* +}; + +extern const TimerSettingDesc TIMER_SETTINGS_DESCS[]; +extern const size_t TIMER_SETTINGS_DESC_COUNT; + +// Compile-time row count (TIMER_SETTINGS_DESC_COUNT is only known at runtime, so it +// cannot size a fixed array). A static_assert in TimerSettings.cpp pins it equal to +// the table extent, so it cannot drift. Used by the one-shot override controller +// (PRD #99 / issue #100) to stack a config snapshot buffer over the table. +constexpr size_t TIMER_SETTINGS_DESC_CAP = 13; + +// Validate + coerce one field into `out`. Never writes a global (pure parse). Strict +// JSON types match the legacy parseCommand: numbers reject bool/string/null; bools +// require a real JSON bool. Returns false on any violation. +bool timerSettingParse(const TimerSettingDesc &d, JsonVariantConst v, TcValue &out); + +// Write a previously-parsed value to the descriptor's storage (dispatch on type). +// Equality-skip: returns true iff the stored value actually changed, so callers +// can elide the "awtrix" flush for no-op writes (mirrors the member setters). +bool timerSettingStore(const TimerSettingDesc &d, const TcValue &v); + +// NVS round-trip for the whole table (caller brackets begin()/end()). +void timerSettingsLoadNvs(class Preferences &prefs); +void timerSettingsSaveNvs(class Preferences &prefs); + +// dev.json overrides: per-key best-effort (validate each present devKey, store iff +// valid, skip-and-continue otherwise -- NOT atomic; dev.json is a boot override layer). +void timerSettingsLoadDevJson(JsonObjectConst obj); + +// Emit ONE row's live value into `doc` under its cmdKey, dispatched by `type` and +// IGNORING snapshot membership. The single "storage -> JSON value" helper shared by +// the config snapshot (inSnapshot rows) and the HA attribute builder, so the two can +// never disagree about a value (PRD #57). +void timerSettingEmitValue(const TimerSettingDesc &d, JsonDocument &doc); + +// Emit the inSnapshot rows into `doc` (the propagated config block, table half). +void timerSettingsBuildSnapshot(JsonDocument &doc); + +// One-shot override (PRD #99 / issue #100): capture/restore the table half's config +// block (the inSnapshot rows; sync_* are inSnapshot=false and excluded by +// construction) into a caller-owned TcValue buffer of TIMER_SETTINGS_DESC_CAP slots, +// indexed by row. Capture reads each storage by type; restore writes it back via +// timerSettingStore. Generic over the table, so no per-key code is required. +void timerSettingsCaptureSnapshot(TcValue out[]); +void timerSettingsRestoreSnapshot(const TcValue in[]); + +// Lookup by command key (used by MenuManager to reuse a row's range bounds). +const TimerSettingDesc *timerSettingByCmdKey(const char *cmdKey); + +// --------------------------------------------------------------------------- +// Inline RTTTL classifier/validator (PRD #99 / issue #102) -- a pure, reusable pair +// reused by command validation and melody resolution. `melody_end`/`melody_tick` +// accept EITHER a bare file-name token (as today) OR an inline RTTTL tune; the two +// are distinguished by content. An inline tune is always one-shot (it has no +// persistable file form), so it is never written to a melody name global. + +// True iff `s` is an inline RTTTL tune rather than a bare melody file-name token. +// A bare token is [A-Za-z0-9_-]* (never contains a colon); an inline tune carries +// RTTTL structure, so the presence of a ':' is the discriminator. +bool timerMelodyIsInline(const String &s); + +// Validate an inline RTTTL tune's syntax: `name:control:notes` (exactly two colons), +// a non-empty control section carrying a d=/o=/b= token, a non-empty notes section, +// within a length cap. Returns false on any violation (a malformed inline tune is +// BadField/400, atomic-reject). Name may be empty. +bool timerMelodyValidateInline(const String &s); + +// --------------------------------------------------------------------------- +// Pure validation helpers relocated out of TimerManager's statics (#142) so the +// command validator links the descriptor-table family, not the singleton. The +// singleton's thin forwarders were deleted once production callers were gone +// (#204); these free functions are the only spellings. + +// Parse a duration string into seconds. Accepts bare seconds, MM:SS or HH:MM:SS; +// each field is a non-empty digit run; fields are summed without a 0-59 cap +// (carry); surrounding whitespace is trimmed. No clamp, no globals. Returns false +// (leaving outSeconds untouched) on empty input, an empty field, more than two +// colons, or a non-numeric field. +bool timerParseHMS(const String &s, uint32_t &outSeconds); + +// True iff `s` is a valid timer action verb -- case-insensitive, exactly one of +// {start, pause, reset}. No trim (a surrounding space rejects). No globals. +bool timerIsValidAction(const String &s); + +// More pure validators/formatters relocated into the table family (#143) so the +// command validator (TimerCommand::classify) links the family, not the singleton. + +// Bare icon/melody file-name char-rule: [A-Za-z0-9_-], length 0..32 (empty = clear, +// accepted). The validation predicate behind the table's Name check and +// TimerManager's private icon-slot validation. No globals. +bool timerIsValidIconName(const String &name); + +// String->enum over the buzzer / finished codec tables (ADR-0010), case-insensitive +// wire spelling or alias. The enum parse behind the member validators. No globals. +bool timerParseBuzzerMode(const String &s, BuzzerMode &out); +bool timerParseFinishedMode(const String &s, FinishedMode &out); + +// The one seconds->clock renderer (#153/#158). Three deliberate spellings the timer +// surfaces speak, collapsed into one function: +// Trimmed drop the hours group when zero; most-significant field unpadded, lower +// fields zero-padded ("24:00:00" / "1:00:00" / "0:45"). HA/config-read form. +// Padded always zero-padded HH:MM:SS ("24:00:00" / "01:00:00" / "00:00:45"). Menu +// / config-screen form. +// Compact two segments, seconds dropped past the hour mark: M:SS (<1h), H:MM (<10h), +// HH:MM (>=10h). The running-display form. No globals. +enum class ClockStyle { Trimmed, Padded, Compact }; +String timerClock(uint32_t seconds, ClockStyle style); + +// Trimmed clock string ("24:00:00" / "1:00:00" / "0:45"). Thin alias for +// timerClock(seconds, ClockStyle::Trimmed); the Duration wire/state spelling +// (state JSON, Duration publishes/echoes, the max_duration HA formatter). No globals. +String timerFormatHMS(uint32_t seconds); + +// Raw seconds <-> H/M/S integer decomposition, relocated out of TimerManager's +// last pure statics (#206). Pure math, no clamp (the on-device config editor — +// the one caller — owns its 99h display cap), no globals. +void timerSecondsToHMS(uint32_t sec, uint32_t &h, uint32_t &m, uint32_t &s); +uint32_t timerHmsToSeconds(uint32_t h, uint32_t m, uint32_t s); + +// --------------------------------------------------------------------------- +// HA attribute-group projection (PRD #57) -- the descriptor-table family's fifth +// member. One row per (carrier entity, settings key) projection: a persisted +// settings value surfaced as a read-only JSON attribute on the HA entity it is +// semantically about. A key may map to MULTIPLE carriers (one row each). The +// optional `format` hook overrides the raw emit for rows whose attribute +// representation deliberately differs from the config-snapshot one (e.g. +// bar_color's "#RRGGBB" string); nullptr means emit the raw live value via +// timerSettingEmitValue. One table, one generic builder -- adding or moving a +// knob's HA projection is a one-row change with no drift across publish paths. +struct TimerAttrGroupDesc +{ + TimerHaEntity carrier; // the HA entity that carries this attribute + const char *cmdKey; // joins TIMER_SETTINGS_DESCS by cmdKey (the attribute key, verbatim) + void (*format)(const TimerSettingDesc &d, JsonDocument &doc); // nullptr = raw emit +}; + +extern const TimerAttrGroupDesc TIMER_ATTR_GROUP_DESCS[]; +extern const size_t TIMER_ATTR_GROUP_DESC_COUNT; + +// Build one carrier's attribute object into `doc`: every table row mapped to +// `carrier`, emitted under its cmdKey (via the row's formatter, else the shared +// timerSettingEmitValue). `doc` is left empty for a carrier with no mapped rows. +void timerBuildAttributeGroup(TimerHaEntity carrier, JsonDocument &doc); + +// --------------------------------------------------------------------------- +// The config block's SECOND table: the member-backed half (B1, ADR-0007/0009). +// +// One row per member-backed config key (buzzer / finished / the four icon_). +// Unlike TIMER_SETTINGS_DESCS' DECLARATIVE rows, these carry function-pointer hooks +// -- exactly like TIMER_MENU_SLOTS' enum slots -- because their values are owned by +// TimerManager's publish-aware setters (equality-skip, _suspendPersist batching, MQTT +// publish), not by a typed storage pointer. This is NOT ADR-0007's rejected "B2 / fold +// into the declarative table"; it is a separate hook table, the fourth member of the +// descriptor-table family (TIMER_SETTINGS_DESCS, TIMER_HA_DESCRIPTORS, TIMER_MENU_SLOTS). +// +// Together the two tables ARE the config block. `duration` is member-backed too but is +// deliberately EXCLUDED: it is run-state, not config (CONTEXT.md), and its validation +// depends on the cross-field effectiveMaxDuration staged from the table half. +struct TimerMemberConfigDesc +{ + const char *cmdKey; // POST/MQTT + snapshot key + bool (*validate)(JsonVariantConst, TcValue &out); // pure: validate + coerce, no mutation + void (*apply)(const TcValue &); // routes via TimerManager's deep setter + void (*emit)(JsonDocument &doc); // writes the live value into the snapshot + void (*publish)(); // optional: live value onto the MQTT wire + // seam (issue #33); nullptr = key not + // individually published +}; + +extern const TimerMemberConfigDesc TIMER_MEMBER_CONFIG_DESCS[]; +extern const size_t TIMER_MEMBER_CONFIG_DESC_COUNT; + +// Compile-time row count for the member-config half (the runtime COUNT can't size a +// fixed array), used by TimerCommand::Plan to stage member values. A static_assert +// pins it to the table extent. Bump if a member-config row is added. +constexpr size_t TIMER_MEMBER_CONFIG_DESC_CAP = 6; + +// The member-config half's PURE validation projection (#143). TIMER_MEMBER_CONFIG_DESCS +// carries impure apply/emit/publish hooks (the TimerManager singleton + the MQTT wire +// seam), so the whole table can't link in the dependency-light command-validator env +// (native_validate). This parallel table carries ONLY the pure {cmdKey, validate} +// columns -- the exact same validate function pointers -- so TimerCommand::classify +// validates the member half without dragging in the apply machinery. A drift guard +// (test) pins the two tables row-for-row (same cmdKey, same validate) so they cannot +// disagree about what a member key is or how it validates. +struct TimerMemberValidatorDesc +{ + const char *cmdKey; + bool (*validate)(JsonVariantConst, TcValue &out); // pure: validate + coerce, no mutation +}; + +extern const TimerMemberValidatorDesc TIMER_MEMBER_VALIDATORS[]; +extern const size_t TIMER_MEMBER_VALIDATOR_COUNT; + +// The member-config validate predicates (pure; live in TimerSettings.cpp). Declared +// so BOTH tables reference the SAME function pointers: TIMER_MEMBER_VALIDATORS (pure +// half) and TIMER_MEMBER_CONFIG_DESCS' validate column (impure half). Sharing the +// pointer is what makes the drift guard exact -- the two tables cannot validate a key +// differently because they validate it with the same function. +bool timerMemValidateBuzzer(JsonVariantConst v, TcValue &out); +bool timerMemValidateFinished(JsonVariantConst v, TcValue &out); +bool timerMemValidateIcon(JsonVariantConst v, TcValue &out); + +// bar_color / bar_bg_color attribute formatters (external linkage, defined in the pure +// TU) shared by the attr-group table and the HTTP full-config dump (#143). +void timerFormatBarColor(const TimerSettingDesc &d, JsonDocument &doc); +void timerFormatBarBgColor(const TimerSettingDesc &d, JsonDocument &doc); + +// Emit each member-config key's live value into `doc` (the B1 half of the config snapshot). +void timerMemberConfigBuildSnapshot(JsonDocument &doc); + +// Run `cmdKey`'s declared publish hook (no-op if the key has none / is unknown). +// TimerManager's per-key publish methods dispatch through this, so the row is the +// single place "how key X goes out on the wire" is defined (issue #33 / PRD #28). +void timerMemberConfigPublish(const char *cmdKey); + +// --------------------------------------------------------------------------- +// HTTP GET /api/timer config mirror (PRD #73). Build the COMPLETE persisted +// configuration into `doc`: the full two-table dump an HTTP-only client reads +// back so it can confirm everything it can POST. Unlike timerSettingsBuildSnapshot +// it does NOT honour the inSnapshot filter -- the sync-role keys +// (sync_follow/sync_targets) ARE included -- and it appends the member-config half +// (buzzer/finished/the four icon_*) via timerMemberConfigBuildSnapshot. Pure: it +// reads only the descriptor storage, no I/O, no globals beyond that. Because it +// walks the same two tables that drive the control surface, the read surface +// cannot drift from what is writable (the drift-guard test pins this). Values are +// raw here; the two carrier-native renderings (friendly bar_color, +// max_duration_str) are layered on in issue #75. +void timerBuildFullConfig(JsonDocument &doc); + +#endif diff --git a/src/TimerSettingsApply.cpp b/src/TimerSettingsApply.cpp new file mode 100644 index 00000000..f9d95b5a --- /dev/null +++ b/src/TimerSettingsApply.cpp @@ -0,0 +1,178 @@ +#include "TimerSettings.h" + +#include +#include + +#include "TimerManager.h" // TimerManager singleton: the member-config setters/getters +#include "MQTTManager.h" // the (topic, payload) wire seam the publish hooks emit on +#include "TimerHa.h" // TimerHaEntity slots for the hooks' canonical topics + +// The IMPURE half of the descriptor-table family (#143). Everything here touches the +// TimerManager singleton, the MQTT wire seam, or Preferences -- so it is split out of +// the pure TimerSettings.cpp and linked only in device + full-suite builds, never in +// the dependency-light command-validator env (native_validate). The PURE half (the +// descriptor tables, parsers, member VALIDATORS) lives in TimerSettings.cpp; this file +// carries the member apply/emit/publish hooks, the full TIMER_MEMBER_CONFIG_DESCS +// table, the NVS round-trip, and the HTTP full-config dump. + +// =========================================================================== +// NVS round-trip for the whole settings table (caller brackets begin()/end()). +// =========================================================================== +void timerSettingsLoadNvs(Preferences &prefs) +{ + for (size_t i = 0; i < TIMER_SETTINGS_DESC_COUNT; ++i) + { + const TimerSettingDesc &d = TIMER_SETTINGS_DESCS[i]; + switch (d.type) + { + case TcType::U16: *static_cast(d.storage) = (uint16_t)prefs.getUInt(d.nvsKey, d.dfltNum); break; + case TcType::U32: *static_cast(d.storage) = prefs.getUInt(d.nvsKey, d.dfltNum); break; + case TcType::Bool: *static_cast (d.storage) = prefs.getBool(d.nvsKey, d.dfltNum != 0); break; + case TcType::Str: *static_cast (d.storage) = prefs.getString(d.nvsKey, d.dfltStr); break; + } + } +} + +void timerSettingsSaveNvs(Preferences &prefs) +{ + for (size_t i = 0; i < TIMER_SETTINGS_DESC_COUNT; ++i) + { + const TimerSettingDesc &d = TIMER_SETTINGS_DESCS[i]; + switch (d.type) + { + case TcType::U16: prefs.putUInt(d.nvsKey, *static_cast(d.storage)); break; + case TcType::U32: prefs.putUInt(d.nvsKey, *static_cast(d.storage)); break; + case TcType::Bool: prefs.putBool(d.nvsKey, *static_cast (d.storage)); break; + case TcType::Str: prefs.putString(d.nvsKey, *static_cast(d.storage)); break; + } + } +} + +// =========================================================================== +// Member-backed config half (B1) -- the impure apply/emit/publish hooks + the full +// table. Each hook routes through TimerManager's existing public setters/getters; the +// enum rows stage the enum cast in TcValue::num, the icon rows stage the name in +// TcValue::str. The validate column reuses the SAME pure predicates the validator +// table uses (timerMemValidate*, declared in the header) so the two cannot drift. +// =========================================================================== +namespace +{ + // -- buzzer -- + void memApplyBuzzer(const TcValue &v) { TimerManager.setBuzzerMode((BuzzerMode)v.num); } + void memEmitBuzzer (JsonDocument &doc) { doc["buzzer"] = TimerManager.buzzerModeString(); } + // Publish hook (issue #33): the live value onto the wire seam, payload the + // per-enum codec's canonical `wire` string -- never the numeric index, and + // deliberately not the HASelect `ha` label the retired setState path sent. + void memPublishBuzzer() + { + MQTTManager.publishTimerWire(MQTTManager.timerWireTopic(TimerHaEntity::Buzzer).c_str(), + TimerManager.buzzerModeString()); + } + + // -- finished -- + void memApplyFinished(const TcValue &v) { TimerManager.setFinishedMode((FinishedMode)v.num); } + void memEmitFinished (JsonDocument &doc) { doc["finished"] = TimerManager.finishedModeString(); } + // Publish hook: see memPublishBuzzer. + void memPublishFinished() + { + MQTTManager.publishTimerWire(MQTTManager.timerWireTopic(TimerHaEntity::Finished).c_str(), + TimerManager.finishedModeString()); + } + + // -- icon_ (shared validate + shared aggregate publish; the per-slot + // apply/emit fold into these two TimerState-indexed templates over iconByState[]). + // A fifth timer state wires its apply + emit for free from its row's tag + // instead of needing a hand-written pair that could be forgotten (#185). The + // snapshot/command key per state, ordered by TimerState (== each row's cmdKey). + static const char *const kIconCmdKeys[kTimerStateCount] = { + "icon_idle", "icon_running", "icon_paused", "icon_finished"}; + template + void memApplyIcon(const TcValue &v) { TimerManager.setIcon(S, v.str); } + template + void memEmitIcon (JsonDocument &doc) { doc[kIconCmdKeys[(size_t)S]] = TimerManager.getIcon(S); } + // Publish hook, shared by all four icon rows (issue #34): the icon keys have + // ONE wire artifact — the aggregate four-state JSON on the plain + // {MQTT_PREFIX}/timer/icons topic (not an HA entity data topic), payload + // byte-identical to the retired MQTTManager::publishTimerIcons composer. + void memPublishIcons() + { + DynamicJsonDocument doc(256); + doc["idle"] = TimerManager.getIconIdle(); + doc["running"] = TimerManager.getIconRunning(); + doc["paused"] = TimerManager.getIconPaused(); + doc["finished"] = TimerManager.getIconFinished(); + String payload; + serializeJson(doc, payload); + MQTTManager.publishTimerWire(MQTTManager.timerIconsTopic().c_str(), payload.c_str()); + } +} + +const TimerMemberConfigDesc TIMER_MEMBER_CONFIG_DESCS[] = { + {"buzzer", timerMemValidateBuzzer, memApplyBuzzer, memEmitBuzzer, memPublishBuzzer}, + {"finished", timerMemValidateFinished, memApplyFinished, memEmitFinished, memPublishFinished}, + {"icon_idle", timerMemValidateIcon, memApplyIcon, memEmitIcon, memPublishIcons}, + {"icon_running", timerMemValidateIcon, memApplyIcon, memEmitIcon, memPublishIcons}, + {"icon_paused", timerMemValidateIcon, memApplyIcon, memEmitIcon, memPublishIcons}, + {"icon_finished", timerMemValidateIcon, memApplyIcon, memEmitIcon, memPublishIcons}, +}; + +const size_t TIMER_MEMBER_CONFIG_DESC_COUNT = + sizeof(TIMER_MEMBER_CONFIG_DESCS) / sizeof(TIMER_MEMBER_CONFIG_DESCS[0]); + +void timerMemberConfigBuildSnapshot(JsonDocument &doc) +{ + for (size_t i = 0; i < TIMER_MEMBER_CONFIG_DESC_COUNT; ++i) + TIMER_MEMBER_CONFIG_DESCS[i].emit(doc); +} + +void timerMemberConfigPublish(const char *cmdKey) +{ + for (size_t i = 0; i < TIMER_MEMBER_CONFIG_DESC_COUNT; ++i) + { + const TimerMemberConfigDesc &d = TIMER_MEMBER_CONFIG_DESCS[i]; + if (strcmp(d.cmdKey, cmdKey) != 0) continue; + if (d.publish) d.publish(); + return; + } +} + +// =========================================================================== +// HTTP GET /api/timer config mirror (PRD #73). The complete persisted-config +// projection: both tables, no snapshot filter, raw values. Lives beside the impure +// member half it dumps. See the header for the full contract. +// =========================================================================== +void timerBuildFullConfig(JsonDocument &doc) +{ + // Table half: EVERY settings row, ignoring inSnapshot, so the sync-role keys + // (sync_follow/sync_targets) are part of the read mirror even though they are + // never propagated. Raw value per row via the shared single-row emitter, with + // the two deliberate carrier-native overrides (PRD #73 D2) reusing the family's + // shared bar formatters -- diverging from the raw propagation snapshot by design, + // yet never able to disagree in value (same storage). + for (size_t i = 0; i < TIMER_SETTINGS_DESC_COUNT; ++i) + { + const TimerSettingDesc &d = TIMER_SETTINGS_DESCS[i]; + if (strcmp(d.cmdKey, "bar_color") == 0) + { + timerFormatBarColor(d, doc); // "default" / uppercase "#RRGGBB", not the raw int + } + else if (strcmp(d.cmdKey, "bar_bg_color") == 0) + { + timerFormatBarBgColor(d, doc); // "none" / uppercase "#RRGGBB", not the raw int + } + else if (strcmp(d.cmdKey, "max_duration") == 0) + { + timerSettingEmitValue(d, doc); // raw seconds kept (e.g. 86400) + // ...plus a trimmed clock-string sibling, this endpoint's raw+_str + // duration precedent, reusing the exact timerFormatHMS the duration fields use. + doc["max_duration_str"] = timerFormatHMS(*static_cast(d.storage)); + } + else + { + timerSettingEmitValue(d, doc); + } + } + + // Member-backed half: buzzer/finished + the four icon_* live values. + timerMemberConfigBuildSnapshot(doc); +} diff --git a/src/TimerView.cpp b/src/TimerView.cpp new file mode 100644 index 00000000..26e8b612 --- /dev/null +++ b/src/TimerView.cpp @@ -0,0 +1,89 @@ +#include "TimerView.h" + +#include "TimerSettings.h" // timerClock / ClockStyle + +#include + +namespace +{ + // Display geometry owned by the view (app-local coordinates on the 32x8 + // panel). The painter (src/Apps.cpp) keeps only the font-/row-dependent bits + // (text baseline, bottom row, config underline step). + constexpr int16_t kScreenW = 32; + constexpr int16_t kTextX = 8; // text region starts right of the 8px icon + constexpr int16_t kTextWidth = 24; + constexpr int16_t kBarMaxLen = 23; + constexpr int16_t kBarX0 = 9; + + constexpr unsigned long kBlinkMs = 500; +} + +void TimerViewModel::formatTimerDisplay(uint32_t seconds, char *out, size_t outLen) +{ + snprintf(out, outLen, "%s", timerClock(seconds, ClockStyle::Compact).c_str()); +} + +TimerView TimerViewModel::compute(const TimerSnapshot &s) +{ + TimerView v = {}; + + const TimerState ts = s.state; + + // Non-config screens draw the icon (unless disabled) and center their text in + // the 24px area right of it. When the icon is hidden, text reflows to the full + // 32px panel. + v.showIcon = s.iconEnabled; + if (s.iconEnabled) { v.textRegionX0 = kTextX; v.textRegionW = kTextWidth; } // 8 / 24 + else { v.textRegionX0 = 0; v.textRegionW = kScreenW; } // full 32px + + // Finished screen: blinking "0:00", no bar. + if (ts == TimerState::Finished) + { + v.screen = TimerView::Screen::Finished; + snprintf(v.text, sizeof(v.text), "0:00"); + v.showText = ((s.nowMs / kBlinkMs) % 2 == 0); + return v; + } + + // Time screen: Idle shows the configured duration; Running/Paused show remaining. + v.screen = TimerView::Screen::Time; + const uint32_t duration = s.duration; + const uint32_t remaining = (ts == TimerState::Idle) ? duration : s.remaining; + formatTimerDisplay(remaining, v.text, sizeof(v.text)); + v.showText = true; + + // The bar divides by the duration captured when the run began, not the live + // configured duration, so editing the duration mid-run leaves the in-progress + // bar untouched (it re-arms on the next start/reset). See API-17 in + // TIMER_TEST_PLAN.md. Falls back to the configured duration if no snapshot. + uint32_t barDuration = s.runDuration; + if (barDuration == 0) barDuration = duration; + if (ts != TimerState::Idle && barDuration > 0) + { + // The bar also reflows when the icon is hidden: it spans the full panel + // instead of the 23px region right of the icon. Right edge stays anchored + // at col 31 either way (barX0 + barMaxLen == kScreenW). + const int16_t barX0 = s.iconEnabled ? kBarX0 : 0; // 9 or 0 + const int16_t barMaxLen = s.iconEnabled ? kBarMaxLen : kScreenW; // 23 or 32 + + // The background track is the full bar trough and persists for the whole + // Running/Paused window, independent of the foreground's len-rounds-to-0 + // gate below (ADR-0020). The painter AND-s in TIMER_BAR_ENABLED / a non-black + // bar_bg_color, exactly as it does for the foreground. + v.showBarTrack = true; + v.barTrackLen = (uint8_t)barMaxLen; + v.barTrackStartX = barX0; + + uint32_t len = ((uint32_t)barMaxLen * remaining) / barDuration; + if (len > (uint32_t)barMaxLen) + len = (uint32_t)barMaxLen; + if (len > 0) + { + v.showBar = true; + v.barLen = (uint8_t)len; + v.barStartX = barX0 + (barMaxLen - (int16_t)len); // right edge anchored at col 31 + } + } + + return v; +} diff --git a/src/TimerView.h b/src/TimerView.h new file mode 100644 index 00000000..86441d06 --- /dev/null +++ b/src/TimerView.h @@ -0,0 +1,88 @@ +#ifndef TimerView_h +#define TimerView_h + +#include + +#include "TimerEnums.h" // TimerState (carried by TimerSnapshot, no TimerManager dependency) + +// The explicit, per-frame input to TimerViewModel::compute(): a plain-data +// snapshot of everything the view reads, captured once from TimerManager by the +// painter (src/Apps.cpp). It carries no methods and no TimerManager dependency, +// so compute() is a pure function of this value and the host tests can construct +// it directly. See CONTEXT.md ("Timer View"). +struct TimerSnapshot +{ + TimerState state; // lifecycle state (Idle/Running/Paused/Finished) + uint32_t duration; // configured duration (seconds) + uint32_t remaining; // live remaining (seconds); compute uses it only when !Idle + uint32_t runDuration; // bar-denominator snapshot captured when the run began + bool iconEnabled; // TIMER_ICON_ENABLED: gates the icon + full-panel reflow + unsigned long nowMs; // millis(); drives the 500 ms Finished blink only +}; + +// A pure, per-frame description of what the Timer app should draw. It holds no +// pixels and no font metrics: it is fully determined by TimerManager state, the +// current time (for the Finished blink), and the icon-enabled display flag (which +// gates the icon and, when off, reflows text+bar to the full panel). TimerApp +// (src/Apps.cpp) is its painter — it maps this struct to DisplayManager / matrix +// calls and owns the font-dependent text centering. Keeping the view display-free +// is what lets the host tests cover the bar geometry, blink cadence and +// display-string selection that the renderer previously hid. See CONTEXT.md +// ("Timer View"). +struct TimerView +{ + enum class Screen : uint8_t { Config, Finished, Time }; + Screen screen; + + // Whether to draw the timer icon (false on Config, or when icon_enabled is off). + // When false, text and bar reflow to span the full 32px panel. + bool showIcon; + + // Text to draw, centered by the painter within [textRegionX0, +textRegionW). + char text[12]; + bool showText; // false only during the Finished-blink "off" phase + int16_t textRegionX0; // app-local left edge of the centering region + int16_t textRegionW; // width of the centering region + + // Progress bar (Time screen, running/paused, duration > 0). Right-anchored, + // drains from the left: invariant barStartX + barLen == panel width (32). Its + // max length depends on showIcon (icon on -> 23px right of the icon; icon off + // -> the full 32px panel). + bool showBar; + uint8_t barLen; // 0 .. (kBarMaxLen, or kScreenW when the icon is hidden) + int16_t barStartX; // app-local start column + + // Background track behind the bar (ADR-0020): the FULL bar extent, painted in + // bar_bg_color before the foreground segment. Unlike showBar it PERSISTS for the + // whole Running/Paused window -- including the final stretch where barLen rounds + // to 0 and showBar is false -- so the trough stays visible like the app's + // drawProgressBar background. Same reflow as the bar: icon on -> (9, 23), icon + // off -> (0, 32). + bool showBarTrack; + uint8_t barTrackLen; // kBarMaxLen, or kScreenW when the icon is hidden + int16_t barTrackStartX; // app-local start column (bar origin at full length) + + // Config-mode field underline (Config screen only). + bool showUnderline; + uint8_t underlineField; // 0 = HH, 1 = MM, 2 = SS (painter maps to X) +}; + +namespace TimerViewModel +{ + // Compute the view from an explicit snapshot of the timer state. Every value + // compute() reads comes from `s` — it never touches the TimerManager singleton. + // `s.nowMs` drives the 500 ms Finished blink only; `s.iconEnabled` + // (TIMER_ICON_ENABLED) gates the icon and, when false, reflows text + bar to + // the full panel. + TimerView compute(const TimerSnapshot &s); + + // The compact on-screen format, fitted to the 24px text region: + // < 1h -> "M:SS" (305 -> "5:05") + // 1..9h -> "H:MM" (seconds dropped to fit; 3661 -> "1:01") + // >= 10h -> "HH:MM" (36000 -> "10:00") + // Distinct from timerFormatHMS (the "H:MM:SS" wire string, which + // always carries seconds). See CONTEXT.md ("Timer Display String"). + void formatTimerDisplay(uint32_t seconds, char *out, size_t outLen); +} + +#endif diff --git a/src/icons.h b/src/icons.h index 290d308a..8b19648d 100644 --- a/src/icons.h +++ b/src/icons.h @@ -22,4 +22,5 @@ const uint16_t icon_1158[] = {1087, 0, 0, 1087, 1087, 0, 0, 1087, 1087, 1087, 10 const uint16_t icon_234[] = {0, 65535, 65535, 65535, 0, 0, 0, 0, 0, 65535, 0, 65535, 0, 0, 0, 0, 0, 65535, 63488, 65535, 0, 0, 0, 0, 0, 65535, 63488, 65535, 0, 0, 0, 0, 0, 65535, 63488, 65535, 0, 0, 0, 0, 65535, 63488, 63488, 63488, 65535, 0, 0, 0, 65535, 63488, 63488, 63488, 65535, 0, 0, 0, 0, 65535, 65535, 65535, 0, 0, 0, 0}; const uint16_t icon_2075[] = {0, 0, 61279, 0, 0, 0, 0, 0, 0, 65535, 59199, 50751, 0, 0, 0, 0, 65535, 61279, 59199, 50751, 44383, 0, 0, 0, 61279, 59199, 59199, 44383, 44383, 0, 0, 0, 61279, 50751, 50751, 44383, 31737, 0, 0, 0, 0, 44383, 44383, 31737, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; const uint16_t icon_1486[] = {0, 0, 2016, 2016, 2016, 0, 0, 0, 0, 2016, 2016, 2914, 2016, 2016, 0, 0, 0, 2016, 2914, 2914, 2914, 2016, 0, 0, 0, 2016, 2914, 2914, 2914, 2016, 0, 0, 0, 2016, 2914, 2914, 2914, 2016, 0, 0, 0, 2016, 2914, 2914, 2914, 2016, 0, 0, 0, 2016, 2914, 2914, 2914, 2016, 0, 0, 0, 2016, 2016, 2016, 2016, 2016, 0, 0}; +const uint16_t icon_timer[] = {0, 59196, 59196, 59196, 59196, 59196, 0, 0, 0, 59196, 3519, 3519, 3519, 59196, 0, 0, 0, 59196, 3519, 3519, 3519, 59196, 0, 0, 0, 0, 59196, 3519, 59196, 0, 0, 0, 0, 0, 0, 59196, 0, 0, 0, 0, 0, 0, 59196, 0, 59196, 0, 0, 0, 0, 59196, 0, 0, 0, 59196, 0, 0, 0, 59196, 59196, 59196, 59196, 59196, 0, 0}; #endif \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp index ab5377c6..7fc9ecb7 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -35,10 +35,12 @@ #include "DisplayManager.h" #include "PeripheryManager.h" #include "MQTTManager.h" +#include "TimerHaHost.h" #include "ServerManager.h" #include "Globals.h" #include "UpdateManager.h" #include "timer.h" +#include "TimerManager.h" TaskHandle_t taskHandle; volatile bool StopTask = false; @@ -69,6 +71,7 @@ void setup() PeripheryManager.setup(); ServerManager.loadSettings(); DisplayManager.setup(); + TimerManager.setup(); DisplayManager.HSVtext(9, 6, VERSION, true, 0); delay(500); xTaskCreatePinnedToCore(BootAnimation, "Task", 10000, NULL, 1, &taskHandle, 0); @@ -100,6 +103,7 @@ void setup() if (MQTT_HOST != "") { DisplayManager.HSVtext(4, 6, "MQTT...", true, 0); + TimerHaHost.reconcile(); MQTTManager.setup(); MQTTManager.tick(); } @@ -119,9 +123,12 @@ void loop() timer_tick(); ServerManager.tick(); DisplayManager.tick(); + TimerManager.tick(); + TimerManager.tickPresence(millis()); // peer presence beacon + registry aging (#111) PeripheryManager.tick(); if (ServerManager.isConnected) { MQTTManager.tick(); + TimerHaHost.refreshTargets(millis()); // dynamic HA Targets select republish (#112) } } From 82ce9f2d36d3c53125ff7497a0a506cfaff2552b Mon Sep 17 00:00:00 2001 From: ltloopy Date: Tue, 7 Jul 2026 09:20:30 -0500 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20native=20Timer=20app=20=E2=80=94=20?= =?UTF-8?q?on-device=20menu,=20MQTT/HTTP=20API,=20HA=20entities,=20multi-d?= =?UTF-8?q?evice=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A countdown Timer as a native app, driven through one validated command surface (atomic-reject: an invalid command changes nothing): - Timer app: per-state icons, depleting progress bar (optional background track + colors), blinking Finished screen with auto-clear / hold / re-alert modes, countdown beeps + end melody (built-in RTTTL defaults, /MELODIES/ files or inline RTTTL overrides). - Buttons: start/pause/resume/reset from the Timer app; Finished alert clearable from any app. - Onscreen menu: TIMER drill-in list (HH:MM:SS duration wheel, buzzer / finished modes, tuning knobs); over-wide menu labels scroll (marquee). - HTTP: POST /api/timer commands, GET /api/timer observation mirror with full config readback; TIMER key on /api/settings. - MQTT: {prefix}/timer command topic; retained timer_state / timer_dur / timer_rem state topics. - Home Assistant: 10 discovered entities (duration text, remaining/state sensors, buzzer/finished selects, start/pause/reset buttons, sync switch + dynamic sync-targets select), each projecting its persisted settings as read-only JSON attributes. - Multi-device sync (off by default): LAN clocks mirror start/pause/reset over UDP broadcast (port 4212, broker-free) with per-clock send targeting (sync_targets) and receive consent (sync_follow); config travels only inside a start and applies one-shot on the follower. - One-shot runs: save:false applies a command's config to the current run only; every observation surface keeps reporting the saved config. - show_timer master enable: false removes the app, unpublishes the HA entities (retained-empty discovery) and disables the command surface. All timer settings persist in their own NVS namespace. No changes to existing settings, platformio.ini, or CI. Co-Authored-By: Claude Fable 5 --- docs/_sidebar.md | 2 + docs/api.md | 193 +++++++++++++++++++++++++++++++++++- docs/apps.md | 73 ++++++++++++++ docs/dev.md | 18 ++++ docs/onscreen.md | 3 + src/Apps.cpp | 4 +- src/Globals.cpp | 4 +- src/MQTTManager.cpp | 21 ++-- src/MQTTManager.h | 11 +- src/MenuManager.cpp | 25 +++-- src/MenuManager.h | 4 +- src/Overlays.cpp | 4 +- src/PeerRegistry.cpp | 4 +- src/PeerRegistry.h | 7 +- src/PeripheryManager.cpp | 4 +- src/ServerManager.cpp | 4 +- src/ServerManager.h | 2 +- src/SyncEnvelope.cpp | 6 +- src/SyncEnvelope.h | 9 +- src/SyncSeenCache.cpp | 2 +- src/SyncSeenCache.h | 14 +-- src/SyncTargetsDebounce.cpp | 4 +- src/SyncTargetsDebounce.h | 13 ++- src/TimerCommand.cpp | 16 +-- src/TimerCommand.h | 20 ++-- src/TimerConfigEditor.cpp | 6 +- src/TimerConfigEditor.h | 12 +-- src/TimerEnums.cpp | 2 +- src/TimerEnums.h | 5 +- src/TimerHa.cpp | 6 +- src/TimerHa.h | 16 +-- src/TimerHaHost.cpp | 62 ++++++------ src/TimerHaHost.h | 11 +- src/TimerManager.cpp | 145 ++++++++++++++------------- src/TimerManager.h | 95 +++++++++--------- src/TimerMenu.cpp | 9 +- src/TimerMenu.h | 11 +- src/TimerMenuNav.cpp | 5 +- src/TimerMenuNav.h | 10 +- src/TimerRuntime.cpp | 6 +- src/TimerRuntime.h | 36 +++++-- src/TimerSettings.cpp | 47 +++++---- src/TimerSettings.h | 66 ++++++------ src/TimerSettingsApply.cpp | 17 ++-- src/TimerView.cpp | 6 +- src/TimerView.h | 12 +-- src/main.cpp | 4 +- 47 files changed, 667 insertions(+), 389 deletions(-) diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 359a874a..9ebca7fd 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -15,6 +15,7 @@ - Features - [Apps](apps.md) - [Native Apps](apps.md#native-apps) + - [Timer](apps.md#timer) - [Custom Apps](apps.md#custom-apps) - [Effects](effects.md) - [Icons](icons.md) @@ -33,4 +34,5 @@ - [Drawing](api.md#drawing-instructions) - [Colored textfragmentss](api.md#display-text-in-colored-fragments) - [App switching](api.md#switch-to-specific-app) + - [Timer Control](api.md#timer-control) - [Settings](api.md#change-settings) \ No newline at end of file diff --git a/docs/api.md b/docs/api.md index 9556a83f..25ee56ad 100644 --- a/docs/api.md +++ b/docs/api.md @@ -20,6 +20,7 @@ + [Dismiss Notification](#dismiss-notification) + [Switch Apps](#switch-apps) + [Switch to Specific App](#switch-to-specific-app) + * [Timer Control](#timer-control) * [Change Settings](#change-settings) + [JSON Properties](#json-properties-1) * [Update](#update) @@ -354,10 +355,191 @@ Directly transition to a desired app using its name. - `Temperature` - `Humidity` - `Battery` +- `Timer` For custom apps, employ the name you designated in the topic or HTTP parameter. In MQTT, if `[PREFIX]/custom/test` is your topic, then `test` would be the app's name. +## Timer Control + +Control the built-in Timer app. See the [Timer app overview](https://blueforcer.github.io/awtrix3/#/apps?id=timer) for behaviour details. + +| MQTT Topic | HTTP URL | Payload/Body | HTTP Method | +| ---------------- | ------------------------- | ------------ | ----------- | +| `[PREFIX]/timer` | `http://[IP]/api/timer` | JSON (see below) | POST | +| – | `http://[IP]/api/timer` | – (returns JSON snapshot, see [State observation](#state-observation)) | GET | + +All JSON properties are optional. When multiple are sent together, property setters apply first and then `action` runs — so `{"duration":600,"action":"start"}` starts a fresh 10-minute timer in one request. + +#### JSON Properties + +| Key | Type | Values | Description | +| --- | --- | --- | --- | +| `action` | string | `start` / `pause` / `reset` | Performs the action. `start` from `idle` also force-switches the display to the Timer app (gated on no active game). `start` is a no-op while already running. `start` from `finished` restarts the countdown. | +| `duration` | string or integer | A clock string `"HH:MM:SS"`/`"MM:SS"`, or a bare integer of seconds. Must be 1 .. `timer_max_duration`. | Sets the configured duration. Colon count decides units (`"3:00"` = 3 min, never 3 h); out-of-range fields carry (`"3:90"` = 270 s). A malformed string **or** an out-of-range value is **rejected** (HTTP `400`, see Responses), not clamped. Published back as a trimmed clock string (`300` → `5:00`). Persists across reboots. Mid-run writes buffer until next `start`/`reset`. | +| `buzzer` | string | `off` / `end` / `countdown` | Sets buzzer mode. Persists. | +| `finished` | string | `auto-clear` / `hold` / `re-alert` | Sets what happens at `00:00`. Persists. | +| `icon_idle` | string | Bare icon name resolved against `/ICONS/.{jpg,gif}`; empty clears the slot; capped at 32 chars | Icon shown in the **Idle** state and used as fallback for any other state whose slot is empty. Persists. | +| `icon_running` | string | Same. Empty clears (then falls back to `icon_idle`). | Icon shown while counting down. Persists. | +| `icon_paused` | string | Same. Empty clears (then falls back to `icon_idle`). | Icon shown while paused. Persists. | +| `icon_finished` | string | Same. Empty clears (then falls back to `icon_idle`). | Icon shown beneath the blinking `0:00`. Persists. | +| `finished_hold` | integer | 1–300 (s) | Auto-clear delay (only meaningful when `finished = "auto-clear"`). Persists. | +| `realert_interval` | integer | 5–300 (s) | Re-alert cadence (only meaningful when `finished = "re-alert"`). Persists. | +| `countdown_seconds` | integer | 0–30 (s) | Pre-expiry beep window (only meaningful when `buzzer = "countdown"`). Persists. | +| `max_duration` | integer | 1–604800 (s, 1 s .. 7 days) | Upper bound on accepted `duration` (ADR-0004). Persists. | +| `remaining_publish_interval` | integer | 1–60 (s) | How often `timer_rem` republishes while Running (ADR-0004). Persists. | +| `melody_tick` | string | **Either** a bare name resolved against `/MELODIES/.txt` (≤32 chars) **or** an inline RTTTL tune | RTTTL countdown-beep melody (ADR-0004). A bare name persists and obeys `save`; an **inline tune is always one-shot** (never saved, ADR-0017). | +| `melody_end` | string | Same. A bare empty string resets to default `"timer_end"`. | RTTTL end melody. Bare name persists & obeys `save`; inline tune always one-shot (ADR-0004 / ADR-0017). | +| `bar_enabled` | bool | `true` / `false` | Show/hide the Running/Paused progress bar (ADR-0004). Persists. | +| `icon_enabled` | bool | `true` / `false` | Show/hide the timer icon; when hidden the time text and bar reflow to span the full panel (ADR-0005). Persists. | +| `bar_color` | int or hex string | `0..0xFFFFFF` or `"#RRGGBB"` / `"RRGGBB"` | Progress-bar (foreground) color; `0` follows `TEXTCOLOR_888` (ADR-0004). Persists. | +| `bar_bg_color` | int or hex string | `0..0xFFFFFF` or `"#RRGGBB"` / `"RRGGBB"` | Progress-bar **background track** color (drawn behind the bar); `0` = black = no track (the default — bar looks unchanged). Unlike `bar_color`'s `0`, this is a literal black, not a sentinel (ADR-0020). Persists. | +| `sync_follow` | bool | `true` / `false` | Obey inbound multi-device timer-sync this clock is targeted by (follow consent gate, ADR-0006). Local identity — not propagated. Persists. | +| `sync_targets` | string | `""` / `all` / comma list of peer `uniqueID`s | Whom this clock commands on a local timer action (ADR-0006). Local identity — not propagated. Persists. | +| `save` | bool | `true` (default) / `false` | `save:false` makes the **whole command one-shot**: its config applies to the current run only and reverts to the saved settings when the timer next returns to Idle (a `reset` or auto-clear), writing nothing to flash. A non-boolean is rejected (atomic-reject). See the one-shot note below and ADR-0017. | + +`action` / `buzzer` / `finished` values are case-insensitive; `auto-clear`/`autoclear` and `re-alert`/`realert` are both accepted. Icon and **bare** melody values are **case-sensitive** (they map to filenames on LittleFS) and **capped at 32 characters** (alphanumeric, `_`, `-`). The icon loader checks `/ICONS/.jpg` then `/ICONS/.gif`; the melody loader reads `/MELODIES/.txt`. + +**One-shot commands (`save:false`) and inline melodies** (ADR-0017). By default every config key persists to flash and becomes your new default. Send `save:false` to run a timer **once** with custom parameters: its config applies to the current run only and reverts when the timer next returns to Idle, writing nothing to flash. While a one-shot run is active the **observation surface stays honest** — the `GET /api/timer` `config` mirror, the Home Assistant attribute entities, and device-to-device sync all keep reporting your **saved** configuration, never the one-off values (the top-level `duration`/`buzzer`/`finished` still show the live values). `melody_end`/`melody_tick` additionally accept an **inline RTTTL tune** (a literal melody string such as `"alarm:d=4,o=5,b=120:c,e,g"`, detected by its `:`-separated `d=`/`o=`/`b=` structure; a malformed tune is rejected `400`); an inline tune is **always one-shot** regardless of `save`, never persists, and **never appears** in the `config` mirror (which shows the saved bare name throughout). The on-device TIMER menu always persists. + +#### Responses + +`POST /api/timer` validates the whole command **atomically** — if any field is invalid, **nothing is applied**: + +| Status | Body | When | +| --- | --- | --- | +| `200 OK` | `OK` | Command accepted and applied. | +| `400 Bad Request` | `ErrorParsingJson` | Body isn't valid JSON (or exceeds the parse buffer). | +| `400 Bad Request` | `InvalidValue` | A field has an unusable value: malformed/out-of-range `duration`, or an unknown `buzzer`/`finished`/`action`/icon. | +| `409 Conflict` | `TimerDisabled` | The Timer feature is off (`SHOW_TIMER=false`); the command is understood but can't apply. | + +Out-of-range durations are **rejected**, not clamped (e.g. `"30:00:00"` when the max is 24 h → `400`). The `{prefix}/timer` MQTT topic applies the same validation but, being fire-and-forget, ignores invalid commands silently (no error is published); the retained `timer_dur` state reflects the unchanged value. See [ADR 0001](adr/0001-timer-command-validation-parity.md). + +#### Examples + +Start a 5-minute timer immediately (numeric seconds or the equivalent clock string): +```json +{"duration": 300, "action": "start"} +``` +```json +{"duration": "5:00", "action": "start"} +``` + +Change buzzer mode mid-run (takes effect immediately for the countdown ticks): +```json +{"buzzer": "countdown"} +``` + +Reset to idle (also clears the finished screen if visible): +```json +{"action": "reset"} +``` + +Set per-state icons (idle stays as fallback; Running uses an animated GIF): +```json +{"icon_idle": "64936", "icon_running": "74706"} +``` + +Clear an override so the slot falls back to the Idle icon: +```json +{"icon_paused": ""} +``` + +#### State observation + +`GET http://[IP]/api/timer` returns a read-only JSON snapshot of the live timer. This is an **observation** endpoint — it never mutates state and takes no body. It always responds `200 OK`; there is no `409` when the timer is disabled (the `enabled` field carries that bit instead). + +```json +{ + "state": "running", + "enabled": true, + "remaining": 174, + "remaining_str": "2:54", + "duration": 300, + "duration_str": "5:00", + "buzzer": "end", + "finished": "auto-clear", + "config": { + "finished_hold": 10, + "realert_interval": 15, + "countdown_seconds": 3, + "max_duration": 86400, + "max_duration_str": "24:00:00", + "remaining_publish_interval": 1, + "icon_enabled": true, + "bar_enabled": true, + "bar_color": "default", + "bar_bg_color": "none", + "melody_tick": "timer_tick", + "melody_end": "timer_end", + "sync_follow": true, + "sync_targets": "", + "buzzer": "end", + "finished": "auto-clear", + "icon_idle": "", + "icon_running": "", + "icon_paused": "", + "icon_finished": "" + } +} +``` + +The response has two parts: the stable top-level **run-state summary** (the eight fields below, byte-compatible with earlier firmware) and a nested **`config`** object mirroring the full persisted configuration. + +| Field | Type | Description | +| --- | --- | --- | +| `state` | string | `idle` / `running` / `paused` / `finished`. | +| `enabled` | bool | Mirrors the `TIMER` master enable (`SHOW_TIMER`). When `false` the timer is forced to `idle`; the snapshot is still returned. | +| `remaining` | integer | Seconds remaining, computed live at request time (wall-clock accurate). Frozen while `paused`; `0` while `finished`. | +| `remaining_str` | string | `remaining` as a trimmed clock string (same format as `duration_str`, e.g. `174` → `2:54`). | +| `duration` | integer | Configured duration in seconds. **Run-state, not config** — it stays top-level only and is deliberately absent from `config`. | +| `duration_str` | string | `duration` as a trimmed clock string (`300` → `5:00`, hours dropped when zero). | +| `buzzer` | string | Buzzer mode: `off` / `end` / `countdown`. Also mirrored inside `config`. | +| `finished` | string | Finished mode: `auto-clear` / `hold` / `re-alert`. Also mirrored inside `config`. | + +The `buzzer` / `finished` strings are the **canonical output spellings** (hyphenated, lowercase). The `POST` command parser additionally tolerates aliases on input (e.g. `autoclear`, `realert`), but the read endpoint always reports the canonical form. + +##### `config` — persisted configuration mirror + +The `config` object reports **every persisted Timer configuration key** — exactly the keys you can write via `POST /api/timer` — so an HTTP-only integrator can read the device's live configuration (and confirm a write applied) without Home Assistant or an MQTT subscription. It is projected from the same descriptor tables that drive the control surface and the HA attribute groups, so the read surface cannot drift from what is writable: adding a new persisted config key automatically makes it readable here. + +While a **one-shot** run (`save:false`, see above / ADR-0017) is active, `config` reports your **saved** configuration, never the one-off values — so a read-after-write check sees the saved truth, not the transient run-only override. The top-level `duration`/`buzzer`/`finished` continue to show the live one-shot values. An inline RTTTL tune never appears here; `config.melody_*` shows the saved bare name throughout. + +Values are **raw** by default (interval seconds as integers, melodies and `sync_targets` as strings, toggles as booleans), with two deliberate carrier-native renderings that follow this endpoint's own raw+`_str` duration precedent: + +- **`bar_color`** is rendered as `"#RRGGBB"` (uppercase) or `"default"` when it follows the text color, rather than a raw 24-bit integer. +- **`bar_bg_color`** is rendered as `"#RRGGBB"` (uppercase) or `"none"` when it is black (no track), rather than a raw integer — the `"none"` vs `"default"` wording reflects the deliberate sentinel asymmetry between the two colors (ADR-0020). +- **`max_duration`** is reported **both** raw (`86400`) **and** as `max_duration_str` (`"24:00:00"`, trimmed clock form). + +| `config` key | Type | Notes | +| --- | --- | --- | +| `finished_hold` | integer | Seconds the finished screen holds (hold mode). | +| `realert_interval` | integer | Seconds between re-alerts (re-alert mode). | +| `countdown_seconds` | integer | Length of the pre-end countdown tick window. | +| `max_duration` | integer | Maximum settable duration, raw seconds. | +| `max_duration_str` | string | `max_duration` as a trimmed clock string (carrier-native). | +| `remaining_publish_interval` | integer | Seconds between `remaining` MQTT pushes. | +| `icon_enabled` | bool | Whether per-state icons are shown. | +| `bar_enabled` | bool | Whether the progress bar is shown. | +| `bar_color` | string | `"#RRGGBB"` or `"default"` (carrier-native; not the raw integer). | +| `bar_bg_color` | string | `"#RRGGBB"` or `"none"` (carrier-native; not the raw integer). Background track behind the bar. | +| `melody_tick` | string | RTTTL melody name for countdown ticks. | +| `melody_end` | string | RTTTL melody name played at finish. | +| `sync_follow` | bool | Whether this clock follows synced peers (observable only; never HA-writable or propagated). | +| `sync_targets` | string | Multi-device sync targets: `""` (off), `all`, or a comma list of device IDs (observable only). | +| `buzzer` | string | Buzzer mode — mirrored here for completeness (also top-level). | +| `finished` | string | Finished mode — mirrored here for completeness (also top-level). | +| `icon_idle` / `icon_running` / `icon_paused` / `icon_finished` | string | The four per-state icon names (empty string = falls back to the Idle icon). | + +`config` never contains `duration` (run-state, reported top-level only) or `action` (a transient command verb, not persisted). The endpoint stays a pure observation read regardless: always `200 OK`, never mutating, no `409`. See [ADR 0015](adr/0015-http-observation-mirrors-full-config.md). + +> **Note on freshness vs. Home Assistant:** `remaining` here is computed at the moment of the request, so during `running` it can read a second or two lower than the HA `{id}_timer_rem` sensor, which is push-throttled to `remaining_publish_interval` (default 1 s). This is expected — the polled HTTP read is simply fresher than the throttled push sensor; the two are not in disagreement. + +When `HA_DISCOVERY` is enabled, Home Assistant also receives live updates of all timer properties via MQTT discovery sensors — an alternative path for observing the timer remotely. + +The current icon configuration is also published as a retained JSON message to `[PREFIX]/timer/icons` whenever it changes (and on MQTT connect). Subscribe to that topic to read back current icon slots without HA. Payload shape: `{"idle": "...", "running": "...", "paused": "...", "finished": "..."}`. The same four icon names are now also readable over HTTP under `config.icon_*` (above) — Home Assistant deliberately has no icon read path, since it cannot usefully render an AWTRIX icon file. + + ## Change Settings Adjust various settings related to the app display. @@ -400,11 +582,12 @@ You can adjust each property in the JSON object according to your preferences. I | `HUM_COL` | string/array of ints | Text color of the humidity app. Use 0 for global text color. | RGB array or hex color | N/A | | `BAT_COL` | string/array of ints | Text color of the battery app. Use 0 for global text color. | RGB array or hex color | N/A | | `SSPEED` | integer | Scroll speed modification. | Percentage of original scroll speed | 100 | -| `TIM` | boolean | Enable or disable the native time app (requires reboot). | `true`/`false` | true | -| `DAT` | boolean | Enable or disable the native date app (requires reboot). | `true`/`false` | true | -| `HUM` | boolean | Enable or disable the native humidity app (requires reboot). | `true`/`false` | true | -| `TEMP` | boolean | Enable or disable the native temperature app (requires reboot). | `true`/`false` | true | -| `BAT` | boolean | Enable or disable the native battery app (requires reboot). | `true`/`false` | true | +| `TIM` | boolean | Enable or disable the native time app. App rotation refreshes immediately. | `true`/`false` | true | +| `DAT` | boolean | Enable or disable the native date app. App rotation refreshes immediately. | `true`/`false` | true | +| `HUM` | boolean | Enable or disable the native humidity app. App rotation refreshes immediately. | `true`/`false` | true | +| `TEMP` | boolean | Enable or disable the native temperature app. App rotation refreshes immediately. | `true`/`false` | true | +| `BAT` | boolean | Enable or disable the native battery app. App rotation refreshes immediately. | `true`/`false` | true | +| `TIMER` | boolean | Master enable for the Timer app. When `false`: app hidden, HA entities suppressed, `/api/timer` and `{prefix}/timer` ignored, running timer reset. HA entity removal completes after next MQTT reconnect. | `true`/`false` | true | | `MATP` | boolean | Enable or disable the matrix. Similar to `power` endpoint but without the animation. | `true`/`false` | true | | `VOL` | integer | Allows to set the volume of the buzzer and DFplayer. | 0–30 | true | | `OVERLAY` | string | Sets a global effect overlay (cannot be used with app specific overlays). | Varies (see below) | N/A | diff --git a/docs/apps.md b/docs/apps.md index db4f650a..8712dbae 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -110,6 +110,79 @@ Due to differences in battery batches and the degradation of the cheap battery o - **`max_battery`**: Enter the `bat_raw` value when the battery is fully charged. +--- +## Timer + +The Timer app counts down a configurable duration with on-device and remote control. It is auto-hidden from the app rotation while idle by default, so it only takes a slot in the rotation when actively counting down, paused, or finished. + +#### Display + +- 8×8 icon on the left (the built-in colour `icon_timer` hourglass, the same glyph shown in the on-device menu; override by placing an 8×8 `/ICONS/timer.jpg` in the file manager). +- Time text in the middle: `MM:SS` for durations under one hour, `H:MM` between 1h and 10h, `HH:MM` for 10h+. +- A 1-pixel depleting progress bar across the bottom row (x=9..31) reflects `remaining / duration`. +- In the `Hold` and `Re-alert` finished modes the time text blinks at ~1 Hz to signal that user action is required. + +#### States + +| State | Visible behaviour | +| --- | --- | +| `idle` | App shows the configured duration with no progress bar | +| `running` | Time counts down; progress bar drains from the left (right edge anchored at column 31) | +| `paused` | Time and bar frozen at the current value | +| `finished` | Timer app pulls itself to the foreground, wakes the display if it was asleep, and blinks `0:00` at 500 ms with no progress bar. Depending on finished mode it auto-clears, holds, or re-alerts | + +#### Physical controls + +While the Timer app is the current app: +- **SELECT short** — start (idle), pause (running), resume (paused), restart (finished). +- **SELECT long** — reset to idle at the configured duration. Clears the finished screen if shown. + +While **any** app is current AND the timer is in the `finished` state, SELECT short and long both work as above (so you can react to the end alert without navigating back to the Timer app). + +#### Buzzer modes + +| Mode | Behaviour | +| --- | --- | +| `Off` | Silent in all phases | +| `End` | Single end tone at `00:00` | +| `Countdown` | Tick tone fires once a second for the final `timer_countdown_seconds` (dev.json, default 3), then the end tone at `00:00` | + +The end tone is loaded from `/MELODIES/timer_end.txt` if present, otherwise a built-in RTTTL fallback is used. The countdown tick tone uses `/MELODIES/timer_tick.txt` with the same fallback pattern. + +#### Finished modes + +| Mode | Behaviour | +| --- | --- | +| `Auto-clear` | Finished screen disappears after `timer_finished_hold` seconds (dev.json, default 10), then state returns to `idle` | +| `Hold` | Finished screen stays with a blinking `00:00` until cleared (SELECT button, or a Start/Reset command — from HA, the Start/Reset buttons) | +| `Re-alert` | Like `Hold`, but additionally re-plays the buzzer end tone every `timer_realert_interval` seconds (dev.json, default 15) | + +#### Setting the duration on the device + +While the Timer app is the current app and the timer is `idle`, **long-press SELECT** to open the **TIMER menu** (see [onscreen menu](onscreen.md)). Duration is its first item: select the `DURATION` leaf to get the `HH:MM:SS` wheel with a 1-pixel underline beneath the field you are currently editing. + +| Gesture | Effect | +| --- | --- | +| SELECT short | Cycles the active field: HH → MM → SS → HH | +| LEFT short | Decreases the active field by 1 (wraps within the field) | +| RIGHT short | Increases the active field by 1 (wraps within the field) | +| LEFT / RIGHT hold | After ~500 ms triggers auto-repeat at ~4 ticks per second until released | +| SELECT long | Saves the duration and returns to the menu list | + +The menu has **no idle timeout** — it never auto-exits while you are editing. The dialed value is silently clamped to `[1, timer_max_duration]` on save. Each field has independent bounds (HH 0–99, MM 0–59, SS 0–59) — incrementing seconds past 59 does **not** carry over into minutes. Use SELECT short to switch to the minutes field instead. While the timer is running or paused the `DURATION` leaf is read-only. + +You can also set the duration via Home Assistant or the [native API](https://blueforcer.github.io/awtrix3/#/api?id=timer-control). + +#### Customisation + +- Sounds: drop `timer_tick.txt` and `timer_end.txt` (RTTTL strings) into `/MELODIES/` via the web file manager. +- Icon: drop an 8×8 `timer.jpg` into `/ICONS/`. +- Behaviour knobs: see the [Timer-related dev.json keys](https://blueforcer.github.io/awtrix3/#/dev). + +#### Remote control + +The timer is fully controllable from Home Assistant (when `HA_DISCOVERY` is enabled — it exposes eight entities under the device card) and from the native MQTT/HTTP API. See [Timer Control](https://blueforcer.github.io/awtrix3/#/api?id=timer-control) for the API surface. + --- # Custom Apps diff --git a/docs/dev.md b/docs/dev.md index d04b82d3..9c608f1f 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -40,6 +40,24 @@ The JSON object has the following properties: | `new_year` | boolean | Displays fireworks and plays a jingle at newyear. | false | | `swap_buttons` | boolean | Swaps the left and right hardware button. | false | | `ldr_on_ground` | boolean | Sets the LDR configuration to LDR-on-ground. | false | +| `show_timer` | boolean | Master enable for the [Timer app](https://blueforcer.github.io/awtrix3/#/apps?id=timer). When `false`: app removed from rotation, 8 HA entities not published, `POST /api/timer` and the `{prefix}/timer` MQTT topic ignored, any running timer reset. On the `true → false` transition the firmware publishes empty retained discovery payloads so HA prunes the stale entities. Also exposed via `/api/settings` (`TIMER` key) and the on-device **APPS** menu. | `true` | +| `timer_max_duration` | integer | Upper bound on accepted `duration` values (range 1–604800). Out-of-range duration commands are rejected, not clamped (ADR-0001). Also caps the HH/MM/SS wheels in the Timer-app config mode. Promoted to `{prefix}/timer` / `/api/timer` per ADR-0004. | `86400` | +| `timer_remaining_publish_interval` | integer | Seconds between `timer_rem` republishes while Running (range 1–60). Drives the HA `{id}_timer_rem` sensor cadence. Promoted per ADR-0004. **Renamed from `timer_publish_interval` on this branch.** | `1` | +| `timer_finished_hold` | integer | Seconds the `00:00` Finished screen stays visible in Auto-clear finished mode (range 1–300). | `10` | +| `timer_realert_interval` | integer | Seconds between buzzer re-fires in Re-alert finished mode (range 5–300). | `15` | +| `timer_countdown_seconds` | integer | Tick window (seconds before zero) in Countdown buzzer mode (range 0–30). | `3` | +| `timer_icon_idle` | string | Bare icon name (resolved against `/ICONS/.{jpg,gif}`) for the Timer app's **Idle** state. Also used as fallback for any other state with an empty slot. Empty/absent = no override of the NVS-stored value. Capped at 32 chars. **Overrides NVS on every boot.** | `""` | +| `timer_icon_running` | string | Same, for the Running state. Empty falls back to `timer_icon_idle`. | `""` | +| `timer_icon_paused` | string | Same, for the Paused state. Empty falls back to `timer_icon_idle`. | `""` | +| `timer_icon_finished` | string | Same, for the Finished (blinking `0:00`) state. Empty falls back to `timer_icon_idle`. | `""` | +| `timer_melody_tick` | string | Bare melody name (resolved against `/MELODIES/.txt`) for countdown beeps. Empty resets to default `"timer_tick"`. Per ADR-0004. | `"timer_tick"` | +| `timer_melody_end` | string | Bare melody name for the end melody. Empty resets to default `"timer_end"`. Per ADR-0004. | `"timer_end"` | +| `timer_bar_enabled` | boolean | Show/hide the Running/Paused progress bar. Per ADR-0004. | `true` | +| `timer_icon_enabled` | boolean | Show/hide the timer icon; when hidden the time text and bar reflow to the full panel. Per ADR-0005. | `true` | +| `timer_bar_color` | integer | Hex color (0..0xFFFFFF) for the progress bar (foreground). `0` follows `TEXTCOLOR_888`. Per ADR-0004. | `0` | +| `timer_bar_bg_color` | integer | Hex color (0..0xFFFFFF) for the progress-bar **background track** drawn behind the bar. `0` = black = no track (literal, not a sentinel). Per ADR-0020. | `0` | +| `timer_sync_follow` | boolean | When `true`, this clock obeys inbound timer-sync packets it is targeted by (multi-device sync follow consent gate). Local identity — never propagated. Per ADR-0006. | `true` | +| `timer_sync_targets` | string | Whom this clock commands on a local timer action: `""` (sync off), `all`, or a comma list of peer device IDs (e.g. `awtrix_ab12,awtrix_cd34`). Local identity — never propagated. Per ADR-0006. | `""` | #### Example: diff --git a/docs/onscreen.md b/docs/onscreen.md index 3d1beac9..c6d8ac50 100644 --- a/docs/onscreen.md +++ b/docs/onscreen.md @@ -4,6 +4,8 @@ AWTRIX 3 provides a **onscreen menu** directly on your clock. Press and hold the middle button for 2 seconds to access the menu. Navigate through the items with the left and right buttons and choose the submenu with a push on the middle button. Hold down the middle button for 2s to exit the current menu and to save your setting. + +Any menu label too wide for the 32px panel **scrolls**: it holds briefly at the left so you can read the beginning, then scrolls left and loops continuously while the item stays selected (the same scroll speed as notifications and apps). Labels that fit stay centered and still. The scroll restarts from the left whenever you move to another item or re-open the menu. !> You can easily turn your AWTRIX matrix on or off by simply double-pressing the middle button if youre not in Menu. @@ -18,6 +20,7 @@ Hold down the middle button for 2s to exit the current menu and to save your set | `DATE` | Allows selection of date format. | | `WEEKDAY` | Allows selection of start of week. | | `TEMP` | Allows selection of temperature system (°C or °F). | +| `TIMER` | Configure the Timer app. A **drill-in list** consistent with the rest of the menu: left/right walks the named items (and **wraps**), a short press drills into the highlighted item's editor, and a long press steps back up one level. You can open it from the main menu or by long-pressing the middle button from the Timer app while it is idle; a long press out of the list returns you to wherever you came from (the Timer app, or the main menu), while `MAIN` always returns to the main menu. The list, in order: `DURATION` (the HH:MM:SS timer length), `BUZZER` (Off/End/Countdown), `COUNTDOWN` (countdown beep window, 0–30 s), `FINISH` (`CLEAR`/`HOLD`/`RE-ALERT`), `CLEAR DELAY` (auto-clear delay, 1–300 s), `RE-ALERT INTERVAL` (re-alert cadence, 5–300 s), `ICON` (show/hide the timer icon, ADR-0005), `PROGRESS BAR` (show/hide the progress bar), `MAIN` (back to the main menu). `DURATION` is the same HH:MM:SS wheel as before: a short press cycles the hour/minute/second field, left/right adjusts the active field (hold to repeat), and a long press saves the duration and returns to the list. While a timer is **running or paused** the `DURATION` leaf is read-only (it shows the current value, no field underline, and any press returns to the list). For the other items only the **bare value** is shown (e.g. `END`, `30`, `ON`): left/right cycles the enum, adjusts the number (step 5 for `CLEAR DELAY`/`RE-ALERT INTERVAL`, step 1 for `COUNTDOWN` — see [ADR 0003](adr/0003-timer-tuning-knobs-on-device.md) for these magnitudes and why), or toggles the boolean (`ICON`/`PROGRESS BAR` — both buttons flip it); a short press confirms and returns to the list, and a long press saves and returns to the list. Mode changes (`BUZZER`/`FINISH`) apply and publish to MQTT/HA immediately as you scroll; the duration commits on its own when you leave the `DURATION` leaf; all other edits persist together when you leave the list (long-press out of the list, or select `MAIN`). See [ADR 0016](adr/0016-timer-menu-drillin-navigation.md). | | `APPS` | Allows to enable or disable internal apps | | `SOUND` | Allows to enable or disable sound output | | `UPDATE` | Check and download new firmware if available. | diff --git a/src/Apps.cpp b/src/Apps.cpp index 01db3b25..f2b8fc06 100644 --- a/src/Apps.cpp +++ b/src/Apps.cpp @@ -534,10 +534,10 @@ void TimerApp(FastLED_NeoMatrix *matrix, MatrixDisplayUiState *state, int16_t x, matrix->drawFastHLine(underlineX + x, (kTimerScreenH - 1) + y, 8, TEXTCOLOR_888); } - // Background track behind the bar (ADR-0020): the full trough, painted first so + // Background track behind the bar: the full trough, painted first so // the foreground draws over it. Persists while the bar is active (even when the // foreground has drained to nothing). A black bar_bg_color (the default) is off - // pixels, so it is skipped -- the bar then looks exactly as it did pre-ADR-0020. + // pixels, so it is skipped -- the bar then looks as if no track existed. if (view.showBarTrack && TIMER_BAR_ENABLED && TIMER_BAR_BG_COLOR) matrix->drawFastHLine(view.barTrackStartX + x, (kTimerScreenH - 1) + y, view.barTrackLen, TIMER_BAR_BG_COLOR); diff --git a/src/Globals.cpp b/src/Globals.cpp index 3ac9877d..f3581934 100644 --- a/src/Globals.cpp +++ b/src/Globals.cpp @@ -217,7 +217,7 @@ void loadDevSettings() // boot override layer, so an invalid key is skipped, not atomic-rejected. timerSettingsLoadDevJson(doc.as()); - // Timer state icons stay member-backed (B1, ADR-0007); their dev.json shadows + // Timer state icons stay member-backed (B1); their dev.json shadows // seed the TimerManager members at setup(). if (doc.containsKey("timer_icon_idle")) TIMER_ICON_IDLE = doc["timer_icon_idle"].as(); if (doc.containsKey("timer_icon_running")) TIMER_ICON_RUNNING = doc["timer_icon_running"].as(); @@ -476,7 +476,7 @@ bool SHOW_TIMER = true; bool SHOW_TIMER_HA_PREV = true; // The 13 table-backed timer settings (max_duration, melodies, bar/sync, etc.) are // DEFINED in TimerSettings.cpp beside the descriptor table whose storage pointers -// reference them (#143). The extern decls stay in Globals.h. These four icon_ +// reference them. The extern decls stay in Globals.h. These four icon_ // values are member-backed (owned by TimerManager's setters, not the table), so they // stay here. String TIMER_ICON_IDLE = ""; diff --git a/src/MQTTManager.cpp b/src/MQTTManager.cpp index 8cc12672..2cd85a51 100644 --- a/src/MQTTManager.cpp +++ b/src/MQTTManager.cpp @@ -32,11 +32,11 @@ HASensor *battery = nullptr; HASensor *temperature, *humidity, *illuminance, *uptime, *strength, *version, *ram, *curApp, *myOwnID, *ipAddr = nullptr; HABinarySensor *btnleft, *btnmid, *btnright = nullptr; // The ten Timer HA carrier pointers and their resolved id buffers moved to -// TimerHaHost (issue #193 / PRD #191); the wire seam reaches ids via TimerHaHost.entityId(). +// TimerHaHost; the wire seam reaches ids via TimerHaHost.entityId(). bool connected; char matID[40], ind1ID[40], ind2ID[40], ind3ID[40], briID[40], btnAID[40], btnBID[40], btnCID[40], appID[40], tempID[40], humID[40], luxID[40], verID[40], ramID[40], upID[40], sigID[40], btnLID[40], btnMID[40], btnRID[40], transID[40], doUpdateID[40], batID[40], myID[40], sSpeed[40], effectID[40], ipAddrID[40]; // The SHOW_TIMER reconcile bookkeeping and its pending-cleanup latch moved to -// TimerHaHost (issue #195); MQTTManager now holds zero Timer-HA-specific state. +// TimerHaHost; MQTTManager now holds zero Timer-HA-specific state. // Forward declarations: the shared HA command callbacks are defined further down; // the base entities in setup() wire them, and each now delegates its Timer branch @@ -363,7 +363,7 @@ void onBrightnessCommand(uint8_t brightness, HALight *sender) DisplayManager.setBrightness(brightness); } -// onTimerDurationMessage moved to TimerHaHost (issue #193): the dedicated Timer +// onTimerDurationMessage moved to TimerHaHost: the dedicated Timer // duration text callback is registered on the Duration carrier by the host. void onNumberCommand(HANumeric number, HANumber *sender) @@ -472,8 +472,8 @@ void onMqttConnected() version->setValue(VERSION); // onConnected() also flushes the pending discovery cleanup reconcile() latched - // when SHOW_TIMER went off across a reboot (issue #195). - TimerHaHost.onConnected(); // Timer wire + attribute groups when SHOW_TIMER (issue #193) + // when SHOW_TIMER went off across a reboot. + TimerHaHost.onConnected(); // Timer wire + attribute groups when SHOW_TIMER } MQTTManager.publish("stats/effects", DisplayManager.getEffectNames().c_str()); @@ -783,7 +783,7 @@ void MQTTManager_::setup() // Resolve the Timer carrier ids and (when SHOW_TIMER) construct/register the // carriers — at the same sequence point as before (after the device's own // entities register), so the ArduinoHA registration/entity-cap drop order is - // unchanged. The carrier lifecycle now lives in TimerHaHost (issue #193). + // unchanged. The carrier lifecycle now lives in TimerHaHost. TimerHaHost.setup(); } else @@ -809,7 +809,7 @@ void MQTTManager_::tick() } } -// The Timer wire seam (issue #31): publishes the exact (topic, payload) the +// The Timer wire seam: publishes the exact (topic, payload) the // caller hands over — retained, like the HASensor::setValue path it replaces. // Gated on the Timer HA carriers existing (TimerHaHost.carriersReady() mirrors the // old timerDuration creation-sentinel check), which preserves the per-entity @@ -835,7 +835,7 @@ String MQTTManager_::timerWireTopic(TimerHaEntity slot) return String(topic); } -// A carrier entity's JSON-attributes topic (PRD #57, generalizing the issue-#51 +// A carrier entity's JSON-attributes topic (generalizing the formerly // finished-only topic): same inputs as timerWireTopic but the json_attr_t suffix, // so it matches the topic the carrier's HASelect advertised in discovery via // setJsonAttributes. The retained attribute value rides the wire seam to here. @@ -849,9 +849,8 @@ String MQTTManager_::timerWireAttrTopic(TimerHaEntity slot) return String(topic); } -// Canonical topic for the aggregate icons JSON (issue #34) — the one published -// Timer topic that is NOT an HA entity data topic. The host-test stub mirrors -// this from its fixture prefix, so tests pin the same spelling. +// Canonical topic for the aggregate icons JSON — the one published +// Timer topic that is NOT an HA entity data topic. String MQTTManager_::timerIconsTopic() { return MQTT_PREFIX + "/timer/icons"; diff --git a/src/MQTTManager.h b/src/MQTTManager.h index e5deb903..e96b837d 100644 --- a/src/MQTTManager.h +++ b/src/MQTTManager.h @@ -10,7 +10,7 @@ // (`_devicesTypesNb + 1 >= _maxDevicesTypesNb`), so the EFFECTIVE capacity is // kMaxHAEntities - 1 (= 39 here). Inventory: 25 base entities (incl. battery on // ulanzi) + 10 Timer entities (TIMER_HA_DESCRIPTOR_COUNT) = 35, leaving 4 spare -// slots. Raised from 34 (issue #125): at 34 the effective cap of 33 silently +// slots. Raised from 34: at 34 the effective cap of 33 silently // dropped the two Timer sync-control entities (the last to register). No clean // compile-time guard — the base count is build-flag conditional — so the runtime // guard is the DEBUG_MODE warning in TimerHaHost's carrier build via haRegistrationAtCap(). @@ -40,11 +40,10 @@ class MQTTManager_ bool isConnected(); String getValueForTopic(const String &topic); - // The Timer wire seam (issue #31 / PRD #28): the single chokepoint through - // which Timer MQTT output flows as (topic, payload) strings. On device it - // reaches the broker (retained, like the HA setValue path it replaces); the - // host-test stub records the pair. timerWireTopic() sources an entity's - // canonical data topic — byte-identical to what ArduinoHA emits. + // The Timer wire seam: the single chokepoint through + // which Timer MQTT output flows as (topic, payload) strings, published + // retained (like the HA setValue path it replaces). timerWireTopic() sources + // an entity's canonical data topic — byte-identical to what ArduinoHA emits. void publishTimerWire(const char *topic, const char *payload); String timerWireTopic(TimerHaEntity slot); String timerWireAttrTopic(TimerHaEntity slot); diff --git a/src/MenuManager.cpp b/src/MenuManager.cpp index 221837b2..cf7be066 100644 --- a/src/MenuManager.cpp +++ b/src/MenuManager.cpp @@ -89,12 +89,12 @@ uint8_t appsCount = 5; #endif uint8_t timerConfigCount = TIMER_MENU_SLOT_COUNT; -// The TIMER menu's drill-in navigation state machine (PRD #83 / issue #85). MAIN +// The TIMER menu's drill-in navigation state machine. MAIN // is the last slot (a Navigation row); the device keeps only drawing + the commit. TimerMenuNav timerNav(TIMER_MENU_SLOT_COUNT, TIMER_MENU_SLOT_COUNT - 1); -// The DURATION leaf reuses the existing display-free duration edit engine (#86), -// a menu-owned instance. The editor has no auto-apply timeout (#88); the menu only +// The DURATION leaf reuses the existing display-free duration edit engine, +// a menu-owned instance. The editor has no auto-apply timeout; the menu only // drives its hold-to-repeat, and the edited duration commits via setDuration on // leaf back-out (run-state, not the list -> main config batch). TimerConfigEditor timerDurationEditor; @@ -108,10 +108,9 @@ static bool timerNavOnDuration() // The one-shot TIMER-menu commit: a single PersistBatch window (enum edits // deferred during scroll in "timer" ns + table-row knob/toggle keys in "awtrix" // ns), then the HA attribute republish. Fires once on the list -> main-menu -// transition (long-press out of the list, or selecting MAIN). A config edit no -// longer propagates to peers (run-scoped config mirror, ADR-0018 superseding -// ADR-0006): config travels only bundled with a `start`. See ADR-0008/0015 and -// PRD #29/#45/#60. +// transition (long-press out of the list, or selecting MAIN). A config edit +// never propagates to peers on its own (run-scoped config mirror): config +// travels only bundled with a `start`. static void commitTimerMenu() { { @@ -259,21 +258,21 @@ String MenuManager_::menutext() } case TimerConfigMenu: // List focus: walk the named items (indicator over the list). Leaf focus: - // show the bare value only, no indicator (PRD #83). + // show the bare value only, no indicator. if (timerNav.focus() == TimerNavFocus::List) { DisplayManager.drawMenuIndicator(timerNav.index(), timerConfigCount, 0xFBC000); return timerMenuName(timerNav.index()); } // DURATION leaf: HH:MM:SS wheel with the active-field underline when - // editable; the static value (no underline) when read-only (#86). + // editable; the static value (no underline) when read-only. if (timerNavOnDuration()) { if (timerDurationEditor.isActive()) { // Drive the editor's hold-to-repeat from the raw button reads each // frame. The menu is timeout-free and the editor no longer has an - // auto-apply timeout (#88), so there is nothing else to handle. + // auto-apply timeout, so there is nothing else to handle. EasyButton *bL = PeripheryManager.buttonL; EasyButton *bR = PeripheryManager.buttonR; TimerConfigEditor::ButtonState buttons{bL && bL->isPressed(), @@ -612,12 +611,12 @@ void MenuManager_::selectButtonLong() // commits the edited duration (run-state, separate from the config // batch) then returns to the list. Out of the list it is the single // commit seam: main menu (origin = menu) or back to the Timer app - // (origin = app, #87). + // (origin = app). TimerNavOutcome o = timerNav.back(); if (o == TimerNavOutcome::CommitDuration) { // The normal set-duration commit path. A bare duration edit - // propagates NOTHING (#126): duration rides only with a start, so an + // propagates NOTHING: duration rides only with a start, so an // on-device length edit no longer moves a follower's displayed time. TimerManager.setDuration(timerDurationEditor.exit()); return; // stay in the TIMER menu, list focus @@ -648,7 +647,7 @@ void MenuManager_::selectButtonLong() void MenuManager_::openTimerMenuFromApp() { // Open the TIMER menu directly at the top of the list, origin = App so a - // long-press out of the list returns to the Timer app (issue #87). + // long-press out of the list returns to the Timer app. inMenu = true; currentState = TimerConfigMenu; timerNav.enter(TIMER_MENU_SLOT_COUNT, TIMER_MENU_SLOT_COUNT - 1, TimerNavOrigin::App); diff --git a/src/MenuManager.h b/src/MenuManager.h index 7e6c255e..f95fd4da 100644 --- a/src/MenuManager.h +++ b/src/MenuManager.h @@ -18,8 +18,8 @@ class MenuManager_ void selectButton(); void selectButtonLong(); - // Open the TIMER menu directly from the Timer app's idle long-press (PRD #83 / - // issue #87): list focus, first item, origin = App so a long-press out of the + // Open the TIMER menu directly from the Timer app's idle long-press: + // list focus, first item, origin = App so a long-press out of the // list returns to the Timer app rather than the main menu. void openTimerMenuFromApp(); }; diff --git a/src/Overlays.cpp b/src/Overlays.cpp index 7cbbf926..4307e6ca 100644 --- a/src/Overlays.cpp +++ b/src/Overlays.cpp @@ -24,11 +24,11 @@ void StatusOverlay(FastLED_NeoMatrix *matrix, MatrixDisplayUiState *state, GifPl void MenuOverlay(FastLED_NeoMatrix *matrix, MatrixDisplayUiState *state, GifPlayer *gifPlayer) { - // Marquee state for over-wide menu labels (PRD #96). The position resets to the + // Marquee state for over-wide menu labels. The position resets to the // left whenever the displayed string changes (navigating to another item, or a // leaf value ticking under the cursor) and on menu (re)entry, so the readable // hold always plays from the start and a label is never caught mid-scroll. - // This is frame-stateful device drawing, kept inline per PRD #83's boundary. + // This is frame-stateful device drawing, deliberately kept inline here. static String lastText; static float scrollPos = 0; // x of the label's left edge while scrolling static int scrollDelay = 0; // pre-roll hold counter (frames), as notifications diff --git a/src/PeerRegistry.cpp b/src/PeerRegistry.cpp index 3d3419fc..7ce761d3 100644 --- a/src/PeerRegistry.cpp +++ b/src/PeerRegistry.cpp @@ -1,8 +1,8 @@ #include "PeerRegistry.h" // A bounded set of {uniqueID, lastSeen} learned from inbound presence beacons; -// the backend the dynamic HA Targets select (#112) consumes. Own id is never -// stored; entries age out past kPeerTtlMs. See ADR-0019 / ADR-0021. +// the backend the dynamic HA Targets select consumes. Own id is never +// stored; entries age out past kPeerTtlMs. void PeerRegistry::record(const String &src, unsigned long nowMs) { diff --git a/src/PeerRegistry.h b/src/PeerRegistry.h index 2cac0717..713ff076 100644 --- a/src/PeerRegistry.h +++ b/src/PeerRegistry.h @@ -3,7 +3,7 @@ #include -// Peer presence / peer registry (#111 / ADR-0019, extracted per ADR-0021). +// Peer presence / peer registry (extracted). // // The set of *other clocks currently on the LAN*, keyed by the stable uniqueID // (the targeting key; the mutable hostname is unsuitable). Pure RAM/LAN-derived @@ -15,9 +15,8 @@ // nothing about beacon cadence, the UDP transport, or AP mode; that scheduling + // I/O stays on TimerManager (one place owns "when do I announce myself"). The // methods take an injected `nowMs` and the own-id is injected via setOwnId(), so -// there is no global read — the whole contract is host-testable in isolation, -// without standing up the TimerManager singleton. See CONTEXT.md -// "Peer presence / peer registry". +// there is no global read — the whole contract is testable in isolation, +// without standing up the TimerManager singleton. class PeerRegistry { public: diff --git a/src/PeripheryManager.cpp b/src/PeripheryManager.cpp index 8d134760..f813de0c 100644 --- a/src/PeripheryManager.cpp +++ b/src/PeripheryManager.cpp @@ -19,7 +19,7 @@ #include #include #include "TimerManager.h" -#include "TimerCommand.h" // TimerCommand::Action for the runStateAction seam (#222) +#include "TimerCommand.h" // TimerCommand::Action for the runStateAction seam const int buzzerPin = 2; // Buzzer an GPIO2 const int baudRate = 50; // Nachrichtenübertragungsrate const char *message = "HELLO"; // Die Nachricht, die gesendet werden soll @@ -235,7 +235,7 @@ void select_button_pressed_long() if (CURRENT_APP == "Timer" && ts == TimerState::Idle) { // Open the TIMER menu (origin = App) instead of the bare duration - // wheel; the wheel is now reachable only as the DURATION leaf (#87). + // wheel; the wheel is now reachable only as the DURATION leaf. MenuManager.openTimerMenuFromApp(); return; } diff --git a/src/ServerManager.cpp b/src/ServerManager.cpp index 40d3e68f..ea9f84fa 100644 --- a/src/ServerManager.cpp +++ b/src/ServerManager.cpp @@ -24,7 +24,7 @@ char incomingPacket[255]; // Propagation surface: dedicated UDP socket for device-to-device timer sync. A // separate port (and buffer) from discovery so a full config snapshot fits and the -// FIND_AWTRIX traffic is never parsed as JSON. See docs/adr/0006. +// FIND_AWTRIX traffic is never parsed as JSON. WiFiUDP syncUdp; const uint16_t kTimerSyncPort = 4212; char syncBuffer[1024]; @@ -390,7 +390,7 @@ void ServerManager_::sendTimerSync(const String &payload) IPAddress bcast = WiFi.broadcastIP(); if ((uint32_t)bcast == 0) bcast = IPAddress(255, 255, 255, 255); // Redundant best-effort send; receivers dedup by (src,seq). Spacing rides out - // transient congestion without acks/per-target state (ADR-0006). + // transient congestion without acks/per-target state. for (uint8_t i = 0; i < 3; ++i) { syncUdp.beginPacket(bcast, kTimerSyncPort); diff --git a/src/ServerManager.h b/src/ServerManager.h index 0ed8c359..8cac304d 100644 --- a/src/ServerManager.h +++ b/src/ServerManager.h @@ -17,7 +17,7 @@ class ServerManager_ void erase(); void sendTCP(String message); // Broadcast a timer-sync packet on the LAN (propagation surface). Sent 3x for - // best-effort delivery; receivers dedup by (src,seq). See docs/adr/0006. + // best-effort delivery; receivers dedup by (src,seq). void sendTimerSync(const String &payload); bool isConnected; IPAddress myIP; diff --git a/src/SyncEnvelope.cpp b/src/SyncEnvelope.cpp index e6e8958a..6e4dbfe0 100644 --- a/src/SyncEnvelope.cpp +++ b/src/SyncEnvelope.cpp @@ -1,7 +1,7 @@ #include "SyncEnvelope.h" -// The Timer-sync wire envelope + pure inbound receive gate. See SyncEnvelope.h and -// CONTEXT.md "Propagation surface". Extracted from TimerManager per ADR-0023. +// The Timer-sync wire envelope + pure inbound receive gate. See SyncEnvelope.h. +// Extracted from TimerManager. namespace SyncEnvelope { @@ -36,7 +36,7 @@ namespace SyncEnvelope if (src.length() == 0 || src == ctx.ownId) return {Decision::Ignore, String(), 0}; // malformed / own echo - // Presence beacon (#111 / ADR-0019): harvest the sender's uniqueID UNGATED — + // Presence beacon: harvest the sender's uniqueID UNGATED — // presence is informational, not a command, so it bypasses the follow/target // gate. It carries no action/duration/config; short-circuit here. if (packet["presence"].as()) diff --git a/src/SyncEnvelope.h b/src/SyncEnvelope.h index ced59184..9969b534 100644 --- a/src/SyncEnvelope.h +++ b/src/SyncEnvelope.h @@ -4,9 +4,8 @@ #include #include -// Timer-sync wire envelope + inbound receive gate (PRD #28, extracted from -// TimerManager per ADR-0023 — the third cut in the #131 trajectory, after -// PeerRegistry/ADR-0021 and SyncSeenCache/ADR-0022). +// Timer-sync wire envelope + inbound receive gate (extracted from +// TimerManager — the third such cut, after PeerRegistry and SyncSeenCache). // // Two halves, both stateless (free functions in a namespace — there is no set to // own, unlike the two class siblings): @@ -18,7 +17,7 @@ // inline gate in applySyncCommand exactly. The DEDUP step deliberately stays // OUT of classify: SyncSeenCache::seen() is test-and-record (stateful), so it // cannot live in a pure function — TimerManager runs it as the single stateful -// guard before re-entry (see ADR-0023). +// guard before re-entry. // // * build() — the send-side envelope constructor. The bare overload writes // {src,seq} (the presence beacon); the targeted overload also parses the @@ -27,7 +26,7 @@ // // classify's context is {ownId, follow} ONLY — never this clock's own target // list. A follower obeys on the SENDER's tgt + its OWN follow consent (the -// two-axis Sync-roles invariant in CONTEXT.md); the receiver's own send-target +// two-axis sync-roles invariant); the receiver's own send-target // list has no role here. That list belongs to build() (the send axis). // // NOTE the wire-gate targetsMe() here is a DIFFERENT concept from TimerHa's diff --git a/src/SyncSeenCache.cpp b/src/SyncSeenCache.cpp index aefac5d3..91dd453c 100644 --- a/src/SyncSeenCache.cpp +++ b/src/SyncSeenCache.cpp @@ -3,7 +3,7 @@ // A bounded (src,seq,atMs) ring learned from inbound sync commands; the backend // for "apply each of the 3x redundant sends exactly once". Entries age out past // kTtlMs (so a sender reboot — seq restart — self-clears), and the existing entry -// is NOT refreshed on a hit (first-seen ages out; the dedup semantic). See ADR-0022. +// is NOT refreshed on a hit (first-seen ages out; the dedup semantic). bool SyncSeenCache::seen(const String &src, uint32_t seq, unsigned long nowMs) { diff --git a/src/SyncSeenCache.h b/src/SyncSeenCache.h index a8957bf3..104da33b 100644 --- a/src/SyncSeenCache.h +++ b/src/SyncSeenCache.h @@ -3,23 +3,23 @@ #include -// Timer-sync dedup set (PRD #28, extracted from TimerManager per ADR-0022 as a -// deliberate sibling of PeerRegistry/ADR-0021). +// Timer-sync dedup set (extracted from TimerManager as a deliberate +// sibling of PeerRegistry). // // The set of *recently-applied sync commands*, keyed by (src, seq): the backing // store for "apply each of the 3x redundant sends exactly once". Pure RAM, // bounded, entries age out after the TTL — the same shape as PeerRegistry, but -// deliberately NOT merged under a shared generic (the reasoning is recorded in -// ADR-0022). It is simpler than PeerRegistry: there is no own-id (own echoes are +// deliberately NOT merged under a shared generic (two 40-line classes are +// cheaper than one generic). It is simpler than PeerRegistry: there is no own-id (own echoes are // dropped earlier in applySyncCommand), no sorted export, and no separate // consumer query — the one call site test-and-records in a single atomic op. // // This module is the *set* only — check + record + age. It deliberately knows // nothing about the UDP transport or the parseCommand re-entry; that I/O stays on -// TimerManager (the cut-line mirrors ADR-0021: the algorithm leaves, the +// TimerManager (the cut-line mirrors PeerRegistry's: the algorithm leaves, the // transport stays). The method takes an injected `nowMs`, so there is no global -// read — the whole contract is host-testable in isolation, without standing up -// the TimerManager singleton. See CONTEXT.md "Propagation surface". +// read — the whole contract is testable in isolation, without standing up +// the TimerManager singleton. class SyncSeenCache { public: diff --git a/src/SyncTargetsDebounce.cpp b/src/SyncTargetsDebounce.cpp index 73c36eaf..17bbc3d2 100644 --- a/src/SyncTargetsDebounce.cpp +++ b/src/SyncTargetsDebounce.cpp @@ -1,8 +1,8 @@ #include "SyncTargetsDebounce.h" // The settle-window state machine for the dynamic Targets select's republish -// (issue #199 / PRD #192). The transition table lives in the header; the effects -// live in TimerHaHost. See test/test_synctargets for the row-for-row pinning. +// decision. The transition table lives in the header; the effects +// live in TimerHaHost. SyncTargetsDebounce::Action SyncTargetsDebounce::step(const char *currentOpts, unsigned long nowMs) { diff --git a/src/SyncTargetsDebounce.h b/src/SyncTargetsDebounce.h index 55f6f648..72650996 100644 --- a/src/SyncTargetsDebounce.h +++ b/src/SyncTargetsDebounce.h @@ -3,9 +3,9 @@ #include -// The Targets-select republish debounce decision (issue #199 / PRD #192), carved out -// of TimerHaHost::refreshTargets as a stateful host-testable module — the -// SyncSeenCache/ADR-0022 sibling shape: injected `nowMs`, no globals, no clock, no +// The Targets-select republish debounce decision, carved out +// of TimerHaHost::refreshTargets as a stateful, isolated module — the +// SyncSeenCache sibling shape: injected `nowMs`, no globals, no clock, no // ArduinoHA. A peer-membership change must hold for the settle window before the // Targets select's options are republished, so a burst of beacon churn yields one // republish; this class is that decision ONLY. The effects (rebuild options, publish @@ -13,8 +13,8 @@ // connected) stay in TimerHaHost, BEFORE step() — so window state ages across // MQTT-down gaps and an expired window republishes on the first connected tick. // -// Transition table (byte-for-byte the TimerHaHost debounce it was carved from; the -// test list in test/test_synctargets mirrors it row for row). State: sig (last +// Transition table (byte-for-byte the TimerHaHost debounce it was carved +// from). State: sig (last // adopted options signature), dirty (settle window open), sinceMs (window start). // Input per step: cur (current options), now. // @@ -34,8 +34,7 @@ // - seed() adopts and closes the window (no spurious first republish). // - (now - sinceMs) is unsigned wrap arithmetic, safe across millis() rollover. // -// Consumed by TimerHaHost: refreshTargets() steps it, createCarriers() seeds it -// (issue #200, ADR-0027). +// Consumed by TimerHaHost: refreshTargets() steps it, createCarriers() seeds it. class SyncTargetsDebounce { public: diff --git a/src/TimerCommand.cpp b/src/TimerCommand.cpp index 7a0ea880..3d958788 100644 --- a/src/TimerCommand.cpp +++ b/src/TimerCommand.cpp @@ -2,8 +2,8 @@ #include // strcmp -// The pure Timer command plan. See TimerCommand.h and docs/adr/0001. Lifted verbatim -// (behaviour-identical) from TimerManager::parseCommand's validation pass (#143); the +// The pure Timer command plan. See TimerCommand.h. Lifted verbatim +// (behaviour-identical) from TimerManager::parseCommand's validation pass; the // shell now drives apply from the returned Plan instead of re-deriving these flags. namespace TimerCommand @@ -13,10 +13,10 @@ namespace TimerCommand Plan p; // ok defaults false; arrays default-zeroed // -- Table settings: validate + coerce each present row into staging. Nothing is - // kept until every field below validates too (atomic-reject, ADR-0001). + // kept until every field below validates too (atomic-reject). // max_duration is range-defining for duration: capture its staged value so a // payload that raises the ceiling AND sets a duration within it in the same - // call is accepted atomically (ADR-0001 addendum). melody_end/melody_tick + // call is accepted atomically. melody_end/melody_tick // accept an inline RTTTL tune (detected by content): validated here, staged as // RAM, and the table row excluded from the bare-name store (always one-shot). uint32_t effectiveMaxDuration = ctx.savedMaxDuration; @@ -66,7 +66,7 @@ namespace TimerCommand } // -- Member-backed config half (B1): validate + coerce each present row via its - // pure validator. A bad member field rejects the whole command (ADR-0001). + // pure validator. A bad member field rejects the whole command. for (size_t i = 0; i < TIMER_MEMBER_VALIDATOR_COUNT; ++i) { const TimerMemberValidatorDesc &d = TIMER_MEMBER_VALIDATORS[i]; @@ -86,8 +86,8 @@ namespace TimerCommand : Action::Reset; } - // -- save flag (PRD #99 / issue #100): payload-level bool, default true. A - // non-boolean rejects the whole command (atomic-reject, ADR-0001). + // -- save flag: payload-level bool, default true. A + // non-boolean rejects the whole command (atomic-reject). bool saveFlag = true; if (packet.containsKey("save")) { @@ -96,7 +96,7 @@ namespace TimerCommand saveFlag = sv.as(); } - // -- one-shot decision (ADR-0017/0018). An inline melody is always one-shot; a + // -- one-shot decision. An inline melody is always one-shot; a // remote-applied command is ALWAYS one-shot regardless of the leader's save. p.oneShot = !saveFlag || p.haveInlineEnd || p.haveInlineTick || ctx.remoteApply; diff --git a/src/TimerCommand.h b/src/TimerCommand.h index be9a702c..1d502928 100644 --- a/src/TimerCommand.h +++ b/src/TimerCommand.h @@ -8,30 +8,30 @@ #include "TimerHa.h" // TimerHaEntity (the attr-carrier dirty set) // The Timer command plan -- the PURE atomic-reject validation decision lifted out of -// the 244-line TimerManager::parseCommand (PRD #28, ADR-0001), mirroring the -// SyncEnvelope sibling (ADR-0023). classify() runs the entire validation pass once, +// the 244-line TimerManager::parseCommand, mirroring the +// SyncEnvelope sibling. classify() runs the entire validation pass once, // mutating NOTHING, and returns a Plan that carries everything the impure shell needs // to apply the command without re-reading the packet. // // classify reads NO globals: its only non-packet input is a Context filled by the // shell from the globals it owns (the saved max_duration ceiling + the _remoteApply // flag). That discipline -- mirroring SyncEnvelope's {ownId, follow} -- is what lets -// it link against the dependency-light descriptor-table family alone ([env:native_validate], -// no singleton / no stubs) and be host-tested by constructing any ceiling / remote -// scenario as a plain Context value. +// it link against the dependency-light descriptor-table family alone (no +// singleton), so any ceiling / remote scenario can be exercised as a plain +// Context value. // -// The one-shot override STORE stays in TimerManager (ADR-0024); classify computes only +// The one-shot override STORE stays in TimerManager; classify computes only // the oneShot DECISION. apply ordering matters: the shell must write the table rows // (landing a raised max_duration) BEFORE calling setDuration(), which re-clamps against -// the GLOBAL ceiling (ADR-0001 addendum) -- classify validates a duration against the +// the GLOBAL ceiling -- classify validates a duration against the // staged ceiling, so a too-early setDuration would silently re-clamp it. namespace TimerCommand { // The only non-packet input classify reads -- filled by the shell from the globals // it owns. savedMaxDuration is the current ceiling (TIMER_MAX_DURATION); a command // that also raises max_duration re-bases the ceiling for its own duration check. - // remoteApply forces the command one-shot (a follower mirrors but never persists, - // ADR-0018). + // remoteApply forces the command one-shot (a follower mirrors but never + // persists). struct Context { uint32_t savedMaxDuration; @@ -64,7 +64,7 @@ namespace TimerCommand bool haveDuration = false; uint32_t durationSec = 0; - // Inline RTTTL melodies (issue #102): validated tunes staged as RAM strings; the + // Inline RTTTL melodies: validated tunes staged as RAM strings; the // shell assigns them to endRtttl/tickRtttl after the bare-name resolve. Always // one-shot (no persistable file form), so either forces oneShot. bool haveInlineEnd = false; diff --git a/src/TimerConfigEditor.cpp b/src/TimerConfigEditor.cpp index 5c0e5212..1fde656d 100644 --- a/src/TimerConfigEditor.cpp +++ b/src/TimerConfigEditor.cpp @@ -1,10 +1,10 @@ #include "TimerConfigEditor.h" #include "Globals.h" // TIMER_MAX_DURATION (cap math) -#include "TimerSettings.h" // timerSecondsToHMS / timerHmsToSeconds (pure math free functions, #206) +#include "TimerSettings.h" // timerSecondsToHMS / timerHmsToSeconds (pure math free functions) namespace { - // Hold-to-repeat timing (was TimerManager.cpp's anon namespace before issue #23). + // Hold-to-repeat timing (was TimerManager.cpp's anon namespace). constexpr unsigned long kBtnLongPressMs = 500; // hold this long before auto-repeat begins constexpr unsigned long kBtnRepeatMs = 250; // cadence between auto-repeat steps } @@ -69,7 +69,7 @@ void TimerConfigEditor::tick(unsigned long nowMs, ButtonState buttons) // Hold-to-repeat: derive held time from nowMs vs a per-button press-start. // While held past the long-press threshold, step once immediately, then once - // per repeat-cadence window. The editor never auto-applies on idle (#88). + // per repeat-cadence window. The editor never auto-applies on idle. repeatHeld(buttons.leftPressed, nowMs, leftPressStartMs_, leftRepeatMs_, -1); repeatHeld(buttons.rightPressed, nowMs, rightPressStartMs_, rightRepeatMs_, +1); } diff --git a/src/TimerConfigEditor.h b/src/TimerConfigEditor.h index 481d95ad..933de324 100644 --- a/src/TimerConfigEditor.h +++ b/src/TimerConfigEditor.h @@ -11,10 +11,8 @@ // without a real clock or buttons. Duration flows in via enter() and back out via // exit(); the caller commits the result through setDuration(). // -// The no-input auto-apply timeout was removed with PRD #83 / issue #88 (the TIMER -// menu — now the editor's only host — is timeout-free); only hold-to-repeat remains. -// See docs/adr/0011-timer-config-editor-extraction.md (extraction) and -// docs/adr/0012-timer-config-timing-in-editor.md (timing; auto-apply half removed). +// There is no no-input auto-apply timeout (the TIMER menu — the editor's only +// host — is timeout-free); only hold-to-repeat remains. class TimerConfigEditor { public: @@ -42,7 +40,7 @@ class TimerConfigEditor // member defaults but makes construction standard-independent: a class with default // member initializers is not an aggregate under C++11, so the call site's two-arg // brace-init (TimerManager.cpp) would otherwise need the C++14 relaxed-aggregate - // rule and break the gnu++11 device build. See docs/adr/0013 and issue #25. + // rule and break the gnu++11 device build. struct ButtonState { bool leftPressed = false; @@ -52,7 +50,7 @@ class TimerConfigEditor }; // Drive button hold-to-repeat: 500 ms long-press threshold then 250 ms cadence - // (left = -1 / right = +1). No timeout — the editor never auto-applies (#88). + // (left = -1 / right = +1). No timeout — the editor never auto-applies. void tick(unsigned long nowMs, ButtonState buttons); bool isActive() const { return active_; } @@ -74,7 +72,7 @@ class TimerConfigEditor uint8_t mm_ = 0; uint8_t ss_ = 0; - // Per-button hold-to-repeat bookkeeping (issue #23). 0 = unpressed / no repeat yet. + // Per-button hold-to-repeat bookkeeping. 0 = unpressed / no repeat yet. unsigned long leftPressStartMs_ = 0; unsigned long rightPressStartMs_ = 0; unsigned long leftRepeatMs_ = 0; diff --git a/src/TimerEnums.cpp b/src/TimerEnums.cpp index 9f50d540..90764ecd 100644 --- a/src/TimerEnums.cpp +++ b/src/TimerEnums.cpp @@ -3,7 +3,7 @@ // One row per enum value, in enum-value order so the array index IS the enum // value. Wire spellings are the canonical MQTT/HTTP/sync contract; menu/ha are the // on-device and Home Assistant labels; aliases are extra accepted input spellings -// (parse only -- never emitted). See docs/adr/0010. +// (parse only -- never emitted). const TimerEnumCodec TIMER_BUZZER_CODEC[] = { {"off", "OFF", "Off", nullptr}, {"end", "END", "End", nullptr}, diff --git a/src/TimerEnums.h b/src/TimerEnums.h index 0ffa6362..5cf9038d 100644 --- a/src/TimerEnums.h +++ b/src/TimerEnums.h @@ -5,8 +5,7 @@ // The Timer's two label-bearing enums and their per-enum CODEC TABLE: the fifth // member of the descriptor-table family (alongside TIMER_SETTINGS_DESCS, -// TIMER_MEMBER_CONFIG_DESCS, TIMER_HA_DESCRIPTORS, TIMER_MENU_SLOTS). See -// docs/adr/0010. +// TIMER_MEMBER_CONFIG_DESCS, TIMER_HA_DESCRIPTORS, TIMER_MENU_SLOTS). // // One row per enum value, INDEXED BY THE ENUM'S NUMERIC VALUE -- the enum value // *is* the row index (the convention kBuzzerLabels/kFinishedLabels already used, @@ -20,7 +19,7 @@ // reused by TimerManager, TimerMenu and TimerHa without forming a dependency cycle // (TimerManager.h includes THIS header for the enums, never the reverse). // -// B1 boundary is unchanged (ADR-0007/0009): this only consolidates how labels / +// B1 boundary is unchanged: this only consolidates how labels / // strings are ENCODED. The MQTT raw-uint8_t-index publish path is untouched -- the // published index is the enum value, which is exactly this table's row index. diff --git a/src/TimerHa.cpp b/src/TimerHa.cpp index ff220c11..1272a4ee 100644 --- a/src/TimerHa.cpp +++ b/src/TimerHa.cpp @@ -14,7 +14,7 @@ // Build a select's "`;`"-joined HA option list from a per-enum codec table's HA // column, in enum-value order (the table's row index IS the enum value, pinned by -// its static_assert — see docs/adr/0010). One join site, no hand-written list to +// its static_assert —). One join site, no hand-written list to // drift from the enum labels. The returned String has static lifetime below, so // the descriptor's `options` pointer stays valid for the program's life. static String joinHaOptions(const TimerEnumCodec *table, size_t count) @@ -66,7 +66,7 @@ static const char HAtimerResetID[] PROGMEM = {"%s_timer_reset"}; static const char HAtimerResetIcon[] PROGMEM = {"mdi:restore"}; static const char HAtimerResetName[] PROGMEM = {"Timer reset"}; -// Sync-control entities (issue #110): the two sync settings — until now read-only +// Sync-control entities: the two sync settings — until now read-only // HA attributes on the state sensor — become writable. The Follow switch carries // the receive-consent toggle; the Targets select ships STATIC Off/All only (it // becomes dynamic in a later peer-discovery slice). @@ -94,7 +94,7 @@ const TimerHaDescriptor TIMER_HA_DESCRIPTORS[TIMER_HA_DESCRIPTOR_COUNT] = { {TimerHaEntity::SyncTargets, "select", HAtimerSyncTargetsID, HAtimerSyncTargetsIcon, HAtimerSyncTargetsName, HAtimerSyncTargetsOptions, nullptr, nullptr}, }; -// --- Dynamic SyncTargets select (#112) --------------------------------------- +// --- Dynamic SyncTargets select --------------------------------------- // The select's options and id<->index mapping are derived at runtime from the // CURRENT peer-id list. See the contract in TimerHa.h. diff --git a/src/TimerHa.h b/src/TimerHa.h index 7d2c8719..7d83853e 100644 --- a/src/TimerHa.h +++ b/src/TimerHa.h @@ -9,7 +9,7 @@ // (no ArduinoHA, no Globals) so it compiles on the host and the contract below can // be unit-tested. The ArduinoHA `new HAX + setters` apply and the discovery // teardown both read TIMER_HA_DESCRIPTORS in MQTTManager — one table, no drift. -// See CONTEXT.md ("Timer HA Presence") and docs/timer.md. +// See docs/timer.md. enum class TimerHaEntity : uint8_t { @@ -26,7 +26,7 @@ enum class TimerHaEntity : uint8_t COUNT }; -// The static option indices for the SyncTargets select (issue #110). The select +// The static option indices for the SyncTargets select. The select // ships ONLY these two base options ("Off"/"All"); a specific-ID CSV set out-of-band // is reflected as unknown (-1) — see timerSyncTargetsIndexForValue. enum class TimerSyncTargetsOption : int8_t @@ -83,11 +83,11 @@ void formatTimerHaDataTopic(const char *dataPrefix, const char *deviceUniqueId, void formatTimerHaAttrTopic(const char *dataPrefix, const char *deviceUniqueId, const char *entityId, char *out, size_t outLen); -// Dynamic SyncTargets select (#112). The select consumes the peer registry, so its +// Dynamic SyncTargets select. The select consumes the peer registry, so its // option list and id<->index mapping are built at runtime from the CURRENT set of // discovered peer ids (already sorted) rather than the static "Off;All" table field. -// All three helpers are pure (char* / out-buffer, no String/Globals) so they are -// host-testable alongside the rest of this contract. +// All three helpers are pure (char* / out-buffer, no String/Globals), like the +// rest of this contract. // Build the option list "Off;All" followed by ";" for each of the n peer ids, // in the given order, into out[outLen] (truncated like snprintf). n==0 -> "Off;All". @@ -107,10 +107,10 @@ bool timerSyncTargetsValueForIndex(int8_t index, const char *const *ids, size_t // ArduinoHA's HAMqtt::addDeviceType drops an entity when // `_devicesTypesNb + 1 >= _maxDevicesTypesNb`, so the EFFECTIVE capacity is // maxEntities - 1, not maxEntities (an off-by-one that silently dropped the two -// Timer sync-control entities — issue #125). This helper encodes that guard once, -// host-tested, so the DEBUG_MODE registration check and ArduinoHA agree: it returns +// Timer sync-control entities). This helper encodes that guard once, +// so the DEBUG_MODE registration check and ArduinoHA agree: it returns // true when `registered` entities already fill the effective cap, i.e. the next -// addDeviceType would be rejected. Pure (no ArduinoHA/Globals) so it is host-testable. +// addDeviceType would be rejected. Pure (no ArduinoHA/Globals). bool haRegistrationAtCap(uint8_t registered, uint8_t maxEntities); #endif diff --git a/src/TimerHaHost.cpp b/src/TimerHaHost.cpp index 8afa9b65..09bbb2c5 100644 --- a/src/TimerHaHost.cpp +++ b/src/TimerHaHost.cpp @@ -9,7 +9,7 @@ #include "SyncTargetsDebounce.h" // The ArduinoHA client + device stay owned by the general MQTT translation unit -// (MQTTManager.cpp); the host reaches them via extern (no injection). See ADR-0026. +// (MQTTManager.cpp); this file reaches them via extern (no injection). extern HADevice device; extern HAMqtt mqtt; @@ -27,7 +27,7 @@ static HASensorNumber *timerRemaining = nullptr; static HASensor *timerStateSensor = nullptr; static HASelect *timerBuzzer = nullptr, *timerFinishedSel = nullptr; static HAButton *timerStartBtn = nullptr, *timerPauseBtn = nullptr, *timerResetBtn = nullptr; -// Sync-control entities (issue #110): the writable Follow switch and dynamic +// Sync-control entities: the writable Follow switch and dynamic // Off/All Targets select that make the two sync settings controllable from HA. static HASwitch *timerSyncFollowSw = nullptr; static HASelect *timerSyncTargetsSel = nullptr; @@ -43,12 +43,12 @@ static char timerHaIds[TIMER_HA_DESCRIPTOR_COUNT][40]; // The id buffer for a given Timer HA slot. See timerHaIds above. static char *timerHaId(TimerHaEntity slot) { return timerHaIds[static_cast(slot)]; } -// --- Dynamic Targets select (#112) debounce state (private to the host) ------ +// --- Dynamic Targets select debounce state (private to the host) ------ // Option-string sizing for the dynamic Targets select. constexpr size_t kSyncTargetPeerCap = 16; constexpr size_t kSyncTargetOptsCap = 8 + kSyncTargetPeerCap * 33 + 1; // "Off;All" + ";"... // The settle-window republish decision (signature, dirty flag, window start) lives in -// the host-tested SyncTargetsDebounce module (issue #199/#200, ADR-0027); this instance +// the SyncTargetsDebounce module; this instance // is the only debounce state. createCarriers() seeds it; refreshTargets() steps it. static SyncTargetsDebounce syncTargetsDebounce; @@ -67,10 +67,10 @@ static size_t buildCurrentSyncTargetsOptions(String *idsOut, const char **ptrsOu // --- SHOW_TIMER reconcile bookkeeping (private to the host) ------------------ // Set by reconcile() when SHOW_TIMER latched off across a reboot; onConnected() // consumes it once MQTT is up to prune the Timer carriers' discovery. Private host -// state — nothing outside the host touches it (issue #195). SHOW_TIMER_HA_PREV, by +// state — nothing outside the host touches it. SHOW_TIMER_HA_PREV, by // contrast, is a persisted (NVS-backed) shared global the settings-apply path writes // independently, so it stays a Globals-owned flag reconcile() reads/writes via extern -// — exactly like SHOW_TIMER itself. See ADR-0026. +// — exactly like SHOW_TIMER itself. static bool pendingTimerHADiscoveryCleanup = false; // The dedicated Timer duration text callback (registered on the Duration carrier). @@ -81,7 +81,7 @@ static void onTimerDurationMessage(const char *message, uint16_t length, HAText for (uint16_t i = 0; i < length; i++) in += message[i]; in.trim(); - // Route the raw HH:MM:SS text through parseCommand (issue #109): it owns the + // Route the raw HH:MM:SS text through parseCommand: it owns the // parse/validate (timerParseHMS + range, reject-not-clamp) — the HA layer no longer // duplicates that logic. A rejected input applies nothing (atomic-reject). TimerManager.timerHaApply(TimerHaEntity::Duration, in); @@ -117,10 +117,10 @@ static void createCarriers() timerDuration->setRetain(true); timerDuration->onMessage(onTimerDurationMessage); timerDuration->setState(timerFormatHMS(TimerManager.getDuration()).c_str(), true); - // Opt the Duration text entity into JSON attributes (HAText opt-in, issue #67) + // Opt the Duration text entity into JSON attributes (HAText opt-in) // so its discovery config advertises json_attr_t; its retained {max_duration} // object — the cap in carrier-native clock form ("24:00:00") — rides the wire - // seam to that topic (PRD #66 / issue #68). No TIMER_HA_DESCRIPTORS change. + // seam to that topic. No TIMER_HA_DESCRIPTORS change. timerDuration->setJsonAttributes(true); timerRemaining = new HASensorNumber(timerHaId(TimerHaEntity::Remaining), HASensorNumber::PrecisionP0); @@ -131,7 +131,7 @@ static void createCarriers() timerRemaining->setCurrentValue((uint32_t)TimerManager.getRemaining()); // Opt the remaining sensor into JSON attributes (HASensorNumber inherits the // opt-in from HASensor): its retained {remaining_publish_interval} object rides - // the wire seam to json_attr_t (PRD #57 / issue #59). No descriptor change. + // the wire seam to json_attr_t. No descriptor change. timerRemaining->setJsonAttributes(true); timerStateSensor = new HASensor(timerHaId(TimerHaEntity::State)); @@ -139,7 +139,7 @@ static void createCarriers() timerStateSensor->setName(dState.name); // Opt the state sensor into JSON attributes so its discovery config advertises // json_attr_t; the retained config-view object (max_duration, bar_color as - // "#RRGGBB", sync roles, …) rides the wire seam to that topic (issue #59). + // "#RRGGBB", sync roles, …) rides the wire seam to that topic. timerStateSensor->setJsonAttributes(true); timerBuzzer = new HASelect(timerHaId(TimerHaEntity::Buzzer)); @@ -150,7 +150,7 @@ static void createCarriers() timerBuzzer->setState((uint8_t)TimerManager.getBuzzerMode(), true); // Opt the buzzer select into JSON attributes so its discovery config advertises // json_attr_t; its retained {countdown_seconds, melody_tick, melody_end} object - // rides the wire seam to that topic (PRD #57 / issue #58). No descriptor change. + // rides the wire seam to that topic. No descriptor change. timerBuzzer->setJsonAttributes(true); timerFinishedSel = new HASelect(timerHaId(TimerHaEntity::Finished)); @@ -161,7 +161,7 @@ static void createCarriers() timerFinishedSel->setState((uint8_t)TimerManager.getFinishedMode(), true); // Opt the finished select into JSON attributes so its discovery config // advertises json_attr_t; the retained {"realert_interval":N} then rides the - // wire seam to that topic (issue #51 / PRD #17). No TIMER_HA_DESCRIPTORS change. + // wire seam to that topic. No TIMER_HA_DESCRIPTORS change. timerFinishedSel->setJsonAttributes(true); timerStartBtn = new HAButton(timerHaId(TimerHaEntity::Start)); @@ -179,12 +179,12 @@ static void createCarriers() timerResetBtn->setName(dReset.name); timerResetBtn->onCommand(onButtonCommand); - // Sync-control entities (issue #110). The two sync settings — until now + // Sync-control entities. The two sync settings — until now // read-only attributes on the state sensor — become writable here. Both // callbacks route through TimerManager::timerHaApply -> parseCommand, so they // inherit the atomic-reject validation and NVS persistence the rest of the // control surface has. sync_* are local identity (inSnapshot=false) and never - // propagate to peers — see ADR-0006/0014. + // propagate to peers —. const TimerHaDescriptor &dSyncF = timerHaDescriptor(TimerHaEntity::SyncFollow); const TimerHaDescriptor &dSyncT = timerHaDescriptor(TimerHaEntity::SyncTargets); @@ -195,7 +195,7 @@ static void createCarriers() timerSyncFollowSw->setState(TIMER_SYNC_FOLLOW, true); timerSyncTargetsSel = new HASelect(timerHaId(TimerHaEntity::SyncTargets)); - // Dynamic options (issue #112): "Off;All" plus each currently-discovered peer id, + // Dynamic options: "Off;All" plus each currently-discovered peer id, // not the static dSyncT.options table field — the select tracks the peer registry. String stIds[kSyncTargetPeerCap]; const char *stPtrs[kSyncTargetPeerCap]; @@ -213,7 +213,7 @@ static void createCarriers() timerSyncTargetsSel->setState( timerSyncTargetsIndexForValue(TIMER_SYNC_TARGETS.c_str(), stPtrs, stN), true); - // Issue #125: every `new HAX` above auto-registered with HAMqtt via the + // Every `new HAX` above auto-registered with HAMqtt via the // HABaseDeviceType ctor. ArduinoHA drops entities once registration reaches the // effective cap (kMaxHAEntities - 1; see haRegistrationAtCap) — which silently // dropped these two sync-control entities until the cap was raised. Log the count @@ -233,7 +233,7 @@ void TimerHaHost_::setup() // Resolve every Timer entity's unique id from the Timer HA Presence table into // the slot-keyed timerHaIds buffers (also read by teardown), each through the one // formatTimerHaEntityId() helper so create and teardown agree per slot. Row i - // describes slot i (pinned by test_U32). Same MAC suffix MQTTManager::setup() + // describes slot i. Same MAC suffix MQTTManager::setup() // feeds its own entities, so the ids are unchanged by the move. uint8_t mac[6]; WiFi.macAddress(mac); @@ -258,17 +258,17 @@ void TimerHaHost_::onConnected() } if (SHOW_TIMER) { - TimerManager.publishAllWire(); // every wire artifact, derived from the member table (issue #41) - TimerManager.publishAllAttributeGroups(); // every carrier's read-only attribute object (PRD #57) + TimerManager.publishAllWire(); // every wire artifact, derived from the member table + TimerManager.publishAllAttributeGroups(); // every carrier's read-only attribute object } } // Reconcile SHOW_TIMER against its persisted last-seen value at boot (before MQTT // connects). This is the host equivalent of the free reconcileTimerHAState() that -// lived in MQTTManager (issue #195): if the timer was toggled off while powered down, +// lived in MQTTManager: if the timer was toggled off while powered down, // latch the one-shot discovery cleanup onConnected() flushes; and persist the new // last-seen value whenever it changed. SHOW_TIMER_HA_PREV stays a shared persisted -// global (settings-apply writes it too), reached via extern — see ADR-0026. +// global (settings-apply writes it too), reached via extern —. void TimerHaHost_::reconcile() { if (SHOW_TIMER_HA_PREV && !SHOW_TIMER) @@ -298,8 +298,8 @@ void TimerHaHost_::enable() for (HABaseDeviceType *dt : timerTypes) mqtt.publishConfigForDeviceType(dt); - TimerManager.publishAllWire(); // every wire artifact, derived from the member table (issue #41) - TimerManager.publishAllAttributeGroups(); // every carrier's read-only attribute object (PRD #57) + TimerManager.publishAllWire(); // every wire artifact, derived from the member table + TimerManager.publishAllAttributeGroups(); // every carrier's read-only attribute object } void TimerHaHost_::remove() @@ -316,12 +316,12 @@ void TimerHaHost_::remove() } // Clear each carrier's retained json_attr_t object too, so pruning the // discovery config does not leave an orphaned attribute payload behind on the - // broker (issue #60). Rides the same wire seam the attribute publish does. + // broker. Rides the same wire seam the attribute publish does. TimerManager.clearAllAttributeGroups(); } // Re-publish the Targets select's discovery when peer-registry membership changes, -// debounced so a burst of beacon churn yields one republish (issue #112). No-op when +// debounced so a burst of beacon churn yields one republish. No-op when // the timer HA entities are absent or MQTT is down (discovery re-publishes at the next // connect). On a settled change it rebuilds the options, re-publishes the discovery // config so Home Assistant sees the new list, and re-applies the selected state. @@ -353,7 +353,7 @@ bool TimerHaHost_::tryHandleButton(HAButton *sender) { if (sender == timerStartBtn) { - // Route through parseCommand (issue #109): start/pause/reset re-enter the + // Route through parseCommand: start/pause/reset re-enter the // control surface, so HA buttons drive peer clocks (run-state propagation) // the same way the MQTT topic does. parseCommand also owns the // start-from-idle switch-to-Timer-app behaviour, so it isn't duplicated here. @@ -377,7 +377,7 @@ bool TimerHaHost_::tryHandleSwitch(bool state, HASwitch *sender) { if (sender == timerSyncFollowSw) { - // Route through parseCommand (issue #110): the Follow switch re-enters the + // Route through parseCommand: the Follow switch re-enters the // control surface, so it gets the strict-bool atomic-reject validation and // NVS persistence. sync_follow is local identity and never propagates. TimerManager.timerHaApply(TimerHaEntity::SyncFollow, String(state ? 1 : 0)); @@ -392,7 +392,7 @@ bool TimerHaHost_::tryHandleSelect(HASelect *sender, int8_t index) { if (sender == timerBuzzer) { - // Route through parseCommand (issue #109): the HA select re-enters the + // Route through parseCommand: the HA select re-enters the // control surface like every other edit, so it gets atomic-reject // validation and the per-enum codec wire spelling — no deep setter here. TimerManager.timerHaApply(TimerHaEntity::Buzzer, String(index)); @@ -408,9 +408,9 @@ bool TimerHaHost_::tryHandleSelect(HASelect *sender, int8_t index) } if (sender == timerSyncTargetsSel) { - // Dynamic select (issue #112): resolve the chosen option index through the + // Dynamic select: resolve the chosen option index through the // CURRENT peer-id list to its sync_targets value (Off -> "", All -> "all", a - // peer option -> that id), then route through parseCommand (#110) for the + // peer option -> that id), then route through parseCommand for the // bespoke validator + NVS persistence. Echo the canonical applied value back // against the same list (Off/All/peer, or unknown for a multi-ID CSV / an id // no longer present — the read-only attribute stays authoritative). sync_targets diff --git a/src/TimerHaHost.h b/src/TimerHaHost.h index 52106a93..ca8a95ab 100644 --- a/src/TimerHaHost.h +++ b/src/TimerHaHost.h @@ -9,10 +9,9 @@ // and teardown, the dedicated Timer duration text callback, and the Timer branches // of the shared ArduinoHA select/switch/button callbacks (reached via tryHandle*). // It is a device-bound adapter — it constructs real ArduinoHA objects against the -// HADevice/HAMqtt globals still owned by MQTTManager (reached via extern) — so it is -// compiled only on device (excluded from every native env's build_src_filter, exactly -// like MQTTManager.cpp). The pure builder half lives in TimerHa (topics, descriptors, -// options, haRegistrationAtCap). See PRD #191 / issue #193 / ADR-0026. +// HADevice/HAMqtt globals still owned by MQTTManager (reached via extern), exactly +// like MQTTManager.cpp. The pure builder half lives in TimerHa (topics, descriptors, +// options, haRegistrationAtCap). struct TimerHaHost_ { @@ -34,7 +33,7 @@ struct TimerHaHost_ void remove(); // Re-publish the dynamic Targets select's discovery when peer-registry membership - // changes, debounced (issue #112). Called every device loop; a no-op when the + // changes, debounced. Called every device loop; a no-op when the // carriers are absent or MQTT is down. See the debounce state in TimerHaHost.cpp. void refreshTargets(unsigned long nowMs); @@ -45,7 +44,7 @@ struct TimerHaHost_ bool tryHandleButton(HAButton *sender); // Accessors for the Timer wire seam that deliberately stays in MQTTManager - // (ADR-0026): whether the carriers exist, and a carrier's resolved unique id. + //: whether the carriers exist, and a carrier's resolved unique id. // They are the seam's surface, not a transition step. bool carriersReady() const; const char *entityId(TimerHaEntity slot) const; diff --git a/src/TimerManager.cpp b/src/TimerManager.cpp index 0cdc5440..ef2f3e3e 100644 --- a/src/TimerManager.cpp +++ b/src/TimerManager.cpp @@ -1,8 +1,8 @@ #include "TimerManager.h" #include "TimerSettings.h" -#include "TimerCommand.h" // the pure atomic-reject command plan (classify, #143) +#include "TimerCommand.h" // the pure atomic-reject command plan (classify) #include "TimerHa.h" -#include "SyncEnvelope.h" // wire envelope + pure inbound receive gate (ADR-0023) +#include "SyncEnvelope.h" // wire envelope + pure inbound receive gate #include "Globals.h" #include "PeripheryManager.h" #include "DisplayManager.h" @@ -15,14 +15,14 @@ namespace { // Sized to hold the full config snapshot (~18 keys) plus the _sync envelope on - // the propagation surface, with headroom for ArduinoJson's larger 64-bit slots - // (host tests). The HTTP/MQTT control surfaces never approach it. + // the propagation surface, with headroom for ArduinoJson's larger 64-bit + // slots. The HTTP/MQTT control surfaces never approach it. constexpr uint16_t kTimerCmdJsonSize = 2048; const char *FALLBACK_END_RTTTL = "timer:d=4,o=5,b=120:c,8p,c,8p,c"; const char *FALLBACK_TICK_RTTTL = "tick:d=16,o=6,b=200:c"; - // The config-block keys that stay member-backed (B1 boundary, ADR-0007/0009) now + // The config-block keys that stay member-backed (B1 boundary) now // live in TIMER_MEMBER_CONFIG_DESCS (TimerSettings.cpp), the config block's second // table. Validation, apply, snapshot-emit and the broadcast trigger all loop that // one table, so they cannot drift apart. @@ -65,7 +65,7 @@ void TimerManager_::loadMelodiesCached() tickRtttl = PeripheryManager.resolveRtttl(tickName, FALLBACK_TICK_RTTTL); } -// One-shot override (PRD #99 / issue #100). captureSnapshot records the SAVED config +// One-shot override. captureSnapshot records the SAVED config // before a save:false command applies on top of it; restoreSnapshot writes it back // when the timer returns to Idle. The table half (Family A inSnapshot rows) is captured // generically over TIMER_SETTINGS_DESCS; the member-backed half (Family B) is captured as @@ -123,7 +123,7 @@ void TimerManager_::returnToIdle() _overrideActive = false; } -// Honest observation carriers (issue #101): swap the SAVED config block into live +// Honest observation carriers: swap the SAVED config block into live // storage for the duration of a carrier projection, then restore the effective // (one-shot) values. Only the config-block state the projections read is swapped — // the table inSnapshot rows (sync_* excluded by construction) and the member-backed @@ -173,14 +173,14 @@ void TimerManager_::setup() _dirty = false; // just loaded from NVS: RAM matches it, nothing pending // A (re)boot knows no peers and has emitted no beacon yet — the peer registry is - // pure RAM/LAN-derived state, repopulated by inbound beacons (#111 / ADR-0019). + // pure RAM/LAN-derived state, repopulated by inbound beacons. _registry.setOwnId(uniqueID); _registry.clear(); _lastPresenceMs = 0; _presenceEverSent = false; // A (re)boot has applied no sync command yet; the dedup set is pure RAM - // (ADR-0022), repopulated by inbound commands. + //, repopulated by inbound commands. _seen.clear(); loadMelodiesCached(); @@ -210,7 +210,7 @@ void TimerManager_::persistIfDirty() String TimerManager_::validateIconName(const String &name) { - // Coercing wrapper around the family's char-rule (#143): accepted name passes + // Coercing wrapper around the family's char-rule: accepted name passes // through (empty = clear), a rejected one coerces to "" (and logs). Single source // of the char-rule is timerIsValidIconName, so this cannot drift from the parser. if (timerIsValidIconName(name)) return name; @@ -239,7 +239,7 @@ void TimerManager_::setIcon(TimerState s, const String &name, bool publish) void TimerManager_::publishIcons() { - // The four icon rows declare ONE shared aggregate publish hook (issue #34), + // The four icon rows declare ONE shared aggregate publish hook, // so dispatching any icon key reaches the same declaration. timerMemberConfigPublish("icon_idle"); } @@ -266,7 +266,7 @@ const char *TimerManager_::getStateString() const } // Canonical wire spellings are a single row read from the per-enum codec table -// (src/TimerEnums.cpp) -- the enum value is the row index. See docs/adr/0010. +// (src/TimerEnums.cpp) -- the enum value is the row index. const char *TimerManager_::buzzerModeString() const { return buzzerCodec(buzzerMode).wire; @@ -281,7 +281,7 @@ String TimerManager_::getStateJson() const { // Bumped from the old 512-byte fixed buffer to the shared command-size // constant the snapshot/broadcast paths use, to hold the added config mirror - // (PRD #73: ~20 keys incl. two melodies, a CSV sync_targets, four icon names). + // (~20 keys incl. two melodies, a CSV sync_targets, four icon names). DynamicJsonDocument doc(kTimerCmdJsonSize); uint32_t remaining = computeCurrentRemaining(); doc["state"] = getStateString(); @@ -293,13 +293,13 @@ String TimerManager_::getStateJson() const doc["buzzer"] = buzzerModeString(); doc["finished"] = finishedModeString(); - // Persisted-config mirror (PRD #73): the complete two-table dump under a + // Persisted-config mirror: the complete two-table dump under a // nested `config` object, so an HTTP-only client reads back everything it can // POST. Built in a temp doc by the pure table-walking projection, then // deep-copied in -- duration stays top-level only (run-state, not config). DynamicJsonDocument cfg(kTimerCmdJsonSize); // During a one-shot override the `config` mirror reports the SAVED config, while - // the top-level run-state above stays effective (issue #101 / ADR-0015). + // the top-level run-state above stays effective. withConfigView(View::Saved, [&] { timerBuildFullConfig(cfg); }); doc["config"] = cfg.as(); @@ -308,10 +308,10 @@ String TimerManager_::getStateJson() const return out; } -// Apply the run-state bookkeeping a returned Transition names (issue #180). The +// Apply the run-state bookkeeping a returned Transition names. The // pure engine decides the phase change; this owns the state mutation — including -// enterRunning's runStart* capture and the ADR-0024 override restore behind -// ToIdle — so the override store stays in the singleton (PRD #28 US7). Runs +// enterRunning's runStart* capture and the one-shot override restore behind +// ToIdle — so the override store stays in the singleton. Runs // BEFORE the effects, so the publishes carry the new phase/remaining. void TimerManager_::applyTransition(const TimerRuntime::Result &r, unsigned long now, const TimerRuntime::Inputs &in) @@ -340,7 +340,7 @@ void TimerManager_::applyTransition(const TimerRuntime::Result &r, unsigned long lastRealertMs = now; break; case TimerRuntime::Transition::ToIdle: - returnToIdle(); // one-shot: restore saved config (ADR-0024 store stays here) + returnToIdle(); // one-shot: restore saved config (override store lives here) state = TimerState::Idle; remainingSec = in.durationSec; break; @@ -352,7 +352,7 @@ void TimerManager_::applyTransition(const TimerRuntime::Result &r, unsigned long } } -// Thin adapter for the input-driven lifecycle verbs (issue #180): resolve the +// Thin adapter for the input-driven lifecycle verbs: resolve the // inputs, let TimerRuntime::step() decide the transition + ordered effects, apply // the run-state bookkeeping, then execute the effects. The same shape tick() uses, // so start/pause/reset/setDuration and the countdown step share one seam. @@ -368,7 +368,7 @@ void TimerManager_::runCommand(TimerRuntime::Command cmd) } // Resolve the environment gates + config the pure engine reads into an Inputs -// value (issue #179). tick() shares this across the Running and Finished branches, +// value. tick() shares this across the Running and Finished branches, // so the globals/singletons the engine deliberately doesn't name are touched in // exactly one place. TimerRuntime::Inputs TimerManager_::buildInputs() const @@ -394,7 +394,7 @@ TimerRuntime::Inputs TimerManager_::buildInputs() const // Executes one effect against the hardware managers / wire seam. The full effect // vocabulary is handled so the later slices can lean on it; the Finished path emits -// only a subset (issue #178). +// only a subset. void TimerManager_::applyEffect(const TimerRuntime::Effect &e) { switch (e.kind) @@ -429,7 +429,7 @@ void TimerManager_::pause() { runCommand(TimerRuntime::Command::Pause); } void TimerManager_::reset() { runCommand(TimerRuntime::Command::Reset); } -// The run-state seam (issue #221 / #213): the verb + its peer mirror, paired in one +// The run-state seam: the verb + its peer mirror, paired in one // place. broadcastRunState self-no-ops under _remoteApply and sync-off, so this is // safe to call unconditionally from any locally-driven path. void TimerManager_::runStateAction(TimerCommand::Action a) @@ -448,7 +448,7 @@ void TimerManager_::setDuration(uint32_t seconds) // Clamp + no-op guard stay in the adapter (input validation, not a transition); // durationSec must be committed before runCommand so buildInputs() feeds the // engine the NEW duration (the Idle reload / paused-edit reset load it into - // remaining). The engine decides the phase transition (issue #180); the duration + // remaining). The engine decides the phase transition; the duration // persist + republish are unconditional adapter concerns. if (seconds < 1) seconds = 1; if (TIMER_MAX_DURATION > 0 && seconds > TIMER_MAX_DURATION) seconds = TIMER_MAX_DURATION; @@ -477,10 +477,10 @@ void TimerManager_::setFinishedMode(FinishedMode m, bool persist) publishFinishedMode(); } -// Thin adapter over the pure engine (issue #179): resolve the inputs, let +// Thin adapter over the pure engine: resolve the inputs, let // TimerRuntime::step() decide the ordered effects + the run-state bookkeeping for // both the Running and Finished branches, then apply the bookkeeping and execute -// the effects in order. The state mutation (and the ADR-0024 override restore +// the effects in order. The state mutation (and the one-shot override restore // behind ToIdle) stays here; effects run after it so the publishes carry the new // phase/remaining, exactly as the imperative tick did. void TimerManager_::tick() @@ -496,7 +496,7 @@ void TimerManager_::tick() // always advances remaining to the freshly-computed value and re-anchors the // publish throttle; the Finished branch advances the re-alert anchor. The phase // change itself (ToFinished / auto-clear ToIdle) is applied by applyTransition, - // the same seam start/pause/reset/setDuration use (issue #180). + // the same seam start/pause/reset/setDuration use. if (state == TimerState::Running) { remainingSec = in.newRemaining; @@ -527,7 +527,7 @@ TimerCmdResult TimerManager_::parseCommand(const char *json) } // -- Validation: the whole atomic-reject pass runs once in the pure - // TimerCommand::classify, mutating nothing (ADR-0001, extracted #143). The shell + // TimerCommand::classify, mutating nothing. The shell // fills the Context from the globals it owns (the saved ceiling + _remoteApply) // and drives apply from the returned Plan -- the packet is never re-read below. -- TimerCommand::Plan plan = @@ -536,7 +536,7 @@ TimerCmdResult TimerManager_::parseCommand(const char *json) if (!plan.ok) return TimerCmdResult::BadField; // first invalid field; nothing applied // -- Command is known-good: only now disturb device state. -- - // One-shot override (issue #100): before applying, snapshot the saved config so + // One-shot override: before applying, snapshot the saved config so // returnToIdle() can restore it. Only the first one-shot in a run captures (latest- // command-wins, single snapshot); a later one-shot applies on top of the same baseline. if (plan.oneShot && !_overrideActive) @@ -548,7 +548,7 @@ TimerCmdResult TimerManager_::parseCommand(const char *json) // -- Apply, inside ONE PersistBatch window; its scope exit commits the whole config // (both NVS namespaces, each at most once). Table rows FIRST, so a raised // TIMER_MAX_DURATION lands before setDuration() re-clamps against the GLOBAL - // ceiling (ADR-0001 addendum -- a duration classify accepted against the staged + // ceiling (a duration classify accepted against the staged // ceiling would otherwise be silently clamped to the old one). Then duration // (run-state, B1), then the member-backed applies via their publish-aware setters. -- bool melodyChanged = false; @@ -568,14 +568,14 @@ TimerCmdResult TimerManager_::parseCommand(const char *json) } if (melodyChanged) loadMelodiesCached(); - // Inline melodies (issue #102): use the validated tune directly as the resolved RAM, + // Inline melodies: use the validated tune directly as the resolved RAM, // after any bare-name re-resolve above so it wins. The saved name globals are // untouched (config mirror keeps the saved name); the snapshot captured the saved- // resolved RAM, so returnToIdle() reverts these on return to Idle. if (plan.haveInlineEnd) endRtttl = plan.inlineEnd; if (plan.haveInlineTick) tickRtttl = plan.inlineTick; - // Rebaseline (issue #100, story 21): a normal (save:true) config command arriving + // Rebaseline (story 21): a normal (save:true) config command arriving // during an active one-shot run commits the live config — including prior one-shot // values — as the new saved baseline and ends the override, so a later revert leaves // the promoted truth in place. A pure action/duration command does NOT rebaseline (it @@ -589,26 +589,26 @@ TimerCmdResult TimerManager_::parseCommand(const char *json) _overrideActive = false; } - // Settings projected as read-only HA attributes (PRD #57): republish each affected + // Settings projected as read-only HA attributes: republish each affected // carrier's bag when any of its mapped keys was in the command (classify computed the // dirty set). Fires on the remote-apply path too (not _remoteApply-gated), keeping - // each synced peer's HA attributes consistent (generalizes #52). Suppressed under a - // one-shot command so the retained bags keep reporting the saved config (#101). + // each synced peer's HA attributes consistent. Suppressed under a + // one-shot command so the retained bags keep reporting the saved config. if (!plan.oneShot) { for (size_t c = 0; c < (size_t)TimerHaEntity::COUNT; ++c) if (plan.attrCarrierDirty[c]) publishAttributeGroup((TimerHaEntity)c); } - // Run-state dispatch rides the runStateAction seam (issue #221): the verb and its + // Run-state dispatch rides the runStateAction seam: the verb and its // peer mirror are paired there, so this path can't emit one without the other. - // The propagation contract is unchanged (run-scoped config mirror, ADR-0018, - // superseding ADR-0006): a `start` emits the ONE combined packet (action + - // duration + effective config snapshot), pause/reset propagate run-state only, - // and a config/bare-duration edit propagates NOTHING (#126); broadcastRunState - // itself no-ops under _remoteApply (one-hop) and sync-off. The broadcast fires - // BEFORE the switch-to-app below (deliberate, #213 grilling): the broadcast reads - // state/duration/effective config, switchToApp touches display only. + // The propagation contract is the run-scoped config mirror: a `start` emits + // the ONE combined packet (action + duration + effective config snapshot), + // pause/reset propagate run-state only, and a config/bare-duration edit + // propagates NOTHING; broadcastRunState itself no-ops under _remoteApply + // (one-hop) and sync-off. The broadcast deliberately fires BEFORE the + // switch-to-app below: the broadcast reads state/duration/effective config, + // switchToApp touches display only. bool fromIdle = (state == TimerState::Idle); runStateAction(plan.action); if (plan.action == TimerCommand::Action::Start && fromIdle && @@ -621,7 +621,7 @@ TimerCmdResult TimerManager_::parseCommand(const char *json) return TimerCmdResult::Ok; } -// HA control adapter (issue #109): each HA timer callback re-enters the control +// HA control adapter: each HA timer callback re-enters the control // surface through parseCommand, exactly as the propagation surface does, rather // than poking a deep setter. The minimal JSON each entity builds is the SAME shape // the {prefix}/timer MQTT topic accepts, so HA edits inherit atomic-reject @@ -635,7 +635,7 @@ TimerCmdResult TimerManager_::timerHaApply(TimerHaEntity entity, const String &r case TimerHaEntity::Buzzer: { // The select callback hands us the chosen option index; map it through the - // per-enum codec (ADR-0010) so the emitted wire string is the one + // per-enum codec so the emitted wire string is the one // parseCommand accepts — the two cannot drift. long idx = rawValue.toInt(); if (idx < 0 || (size_t)idx >= TIMER_BUZZER_CODEC_COUNT) return TimerCmdResult::BadField; @@ -665,7 +665,7 @@ TimerCmdResult TimerManager_::timerHaApply(TimerHaEntity entity, const String &r doc["sync_follow"] = (rawValue.toInt() != 0); break; case TimerHaEntity::SyncTargets: - // The dynamic select (issue #112) hands us the RESOLVED sync_targets value, + // The dynamic select hands us the RESOLVED sync_targets value, // not an index: "" (Off), "all" (All), or a discovered peer id. MQTTManager // maps the chosen option index through the CURRENT id list before calling, so // the option set tracks the registry. The bespoke sync_targets validator @@ -689,13 +689,13 @@ void TimerManager_::onShowTimerChange(bool prev, bool now) // State is run-state, not a member-config row, so it goes through the wire // seam directly: the exact (topic, payload) the broker receives, byte-identical -// to the retired HASensor::setValue path (issue #31 / PRD #28). +// to the retired HASensor::setValue path. void TimerManager_::publishState() { MQTTManager.publishTimerWire(MQTTManager.timerWireTopic(TimerHaEntity::State).c_str(), getStateString()); } -// Remaining is run-state too (issue #32): straight through the seam, payload a +// Remaining is run-state too: straight through the seam, payload a // plain decimal string — byte-identical to the retired HASensorNumber // (PrecisionP0) setValue path. void TimerManager_::publishRemaining() @@ -703,20 +703,20 @@ void TimerManager_::publishRemaining() MQTTManager.publishTimerWire(MQTTManager.timerWireTopic(TimerHaEntity::Remaining).c_str(), String(remainingSec).c_str()); } -// Duration is run-state too (issue #34): straight through the seam, payload the +// Duration is run-state too: straight through the seam, payload the // trimmed-HMS clock string — byte-identical to the retired HAText::setState path. void TimerManager_::publishDuration() { MQTTManager.publishTimerWire(MQTTManager.timerWireTopic(TimerHaEntity::Duration).c_str(), timerFormatHMS(durationSec).c_str()); } -// The enum keys are member-config rows (issue #33): dispatch through the row's +// The enum keys are member-config rows: dispatch through the row's // declared publish hook so validate/apply/emit/publish stay co-located and the // table is the single definition of how each key goes out on the wire. void TimerManager_::publishBuzzerMode() { timerMemberConfigPublish("buzzer"); } void TimerManager_::publishFinishedMode() { timerMemberConfigPublish("finished"); } -// A carrier's read-only JSON attribute object (PRD #57 / issue #58): the bag is +// A carrier's read-only JSON attribute object: the bag is // built table-driven by timerBuildAttributeGroup, serialized, and ridden onto the // carrier's json_attr_t topic by the wire seam — the same path every other Timer // value takes. HA reads it because the TimerHaHost carrier build opted the carrier @@ -726,11 +726,10 @@ void TimerManager_::publishFinishedMode() { timerMemberConfigPublish("finished") void TimerManager_::publishAttributeGroup(TimerHaEntity carrier) { // 512: the state sensor's bag is the largest (eight config-view keys incl. - // two strings), which overflows 256 on a 64-bit host (issue #59). + // two strings), which overflows 256 on a 64-bit host. DynamicJsonDocument doc(512); // A republish (e.g. on reconnect) during a one-shot override serializes the SAVED - // config, so HA attribute bags never show transient one-off values (issue #101 / - // ADR-0014). + // config, so HA attribute bags never show transient one-off values. withConfigView(View::Saved, [&] { timerBuildAttributeGroup(carrier, doc); }); if (doc.as().size() == 0) return; String payload; @@ -749,8 +748,8 @@ void TimerManager_::publishAllAttributeGroups() } // Teardown mirror of publishAllAttributeGroups: empty the retained json_attr_t -// topic of every distinct carrier so disabling the Timer (discovery teardown, -// issue #60) leaves no orphaned attribute object on the broker. Same carrier +// topic of every distinct carrier so disabling the Timer (discovery teardown) +// leaves no orphaned attribute object on the broker. Same carrier // dedupe and same wire seam — an empty retained payload is the MQTT clear. // publishTimerWire's creation-sentinel gate makes this no-op when no entity ever // existed (nothing was advertised, so nothing to clear). @@ -763,7 +762,7 @@ void TimerManager_::clearAllAttributeGroups() }); } -// Full wire refresh (issue #41, closing PRD #28). The run-state trio is a fixed +// Full wire refresh. The run-state trio is a fixed // set (state/remaining/duration are run-state, not table rows); the config half // is DERIVED from TIMER_MEMBER_CONFIG_DESCS, so a row added with a publish hook // is republished on connect / discovery-enable without touching this function. @@ -783,14 +782,14 @@ void TimerManager_::publishAllWire() } // --------------------------------------------------------------------------- -// Propagation surface (device-to-device timer sync). See CONTEXT.md and -// docs/adr/0006-timer-multi-device-sync.md. +// Propagation surface (device-to-device timer sync). See docs/timer.md +// "Multi-device sync". // --------------------------------------------------------------------------- void TimerManager_::addSyncEnvelope(JsonObject &sync) { // Thin forwarder: this class owns the monotonic _syncSeq counter (injected as - // seq); the envelope shape + target-CSV parsing live in SyncEnvelope (ADR-0023). + // seq); the envelope shape + target-CSV parsing live in SyncEnvelope. SyncEnvelope::build(sync, uniqueID, ++_syncSeq, TIMER_SYNC_TARGETS); } @@ -798,15 +797,15 @@ void TimerManager_::buildConfigSnapshot(JsonDocument &doc, View view) const { // Config block only — never action/duration (run-state) or sync_* (local identity, // inSnapshot=false). Two tables, one config block: TIMER_SETTINGS_DESCS' inSnapshot - // rows and TIMER_MEMBER_CONFIG_DESCS (the member-backed half, B1, ADR-0007/0009). + // rows and TIMER_MEMBER_CONFIG_DESCS (the member-backed half, B1). // Each table also feeds the parseCommand broadcast trigger, so the snapshot can't // drift from what fires a broadcast. // View::Saved masks an active one-shot override so the snapshot reports the SAVED // config — a follower never receives transient one-off values it has no notion of - // reverting (issue #101 / ADR-0006; broadcastConfig is itself suppressed during an - // override, issue #100, so this is also defensive). View::Effective takes live + // reverting (broadcastConfig is itself suppressed during an + // override, so this is also defensive). View::Effective takes live // storage as-is so a leader's own one-shot run mirrors to followers — the snapshot - // reports what is actually running (ADR-0018 §4). + // reports what is actually running. withConfigView(view, [&] { timerSettingsBuildSnapshot(doc); timerMemberConfigBuildSnapshot(doc); @@ -818,10 +817,10 @@ void TimerManager_::broadcastRunState(const char *action) if (_remoteApply) return; // one-hop: never re-emit an applied remote command if (TIMER_SYNC_TARGETS.length() == 0) return; // sync off - // Only an action ever reaches here — start / pause / reset (#126). A `start` is the + // Only an action ever reaches here — start / pause / reset. A `start` is the // SOLE duration-bearing packet: it carries the leader's effective `duration` plus the // leader's EFFECTIVE config snapshot, bundled into one combined packet — the - // run-scoped config mirror (ADR-0018). Config no longer travels on a config edit, and + // run-scoped config mirror. Config no longer travels on a config edit, and // a bare duration edit propagates nothing; both ride one combined packet with the // start so a follower mirrors the leader for that run. The combined packet needs the // full kTimerCmdJsonSize buffer (config snapshot + envelope); pause/reset stay @@ -838,7 +837,7 @@ void TimerManager_::broadcastRunState(const char *action) // EFFECTIVE config: a leader's own one-shot run mirrors to followers, so the // snapshot reports what is actually running. sync_* are inSnapshot=false and // excluded by construction; inline melodies never travel (the saved bare name - // globals back the snapshot, ADR-0017 / ADR-0018 §4). + // globals back the snapshot). buildConfigSnapshot(doc, View::Effective); String out; serializeJson(doc, out); @@ -864,7 +863,7 @@ void TimerManager_::broadcastConfig() DynamicJsonDocument doc(kTimerCmdJsonSize); JsonObject sync = doc.createNestedObject("_sync"); addSyncEnvelope(sync); - buildConfigSnapshot(doc, View::Saved); // propagated config reports SAVED (ADR-0006 / ADR-0017) + buildConfigSnapshot(doc, View::Saved); // propagated config reports SAVED String out; serializeJson(doc, out); ServerManager.sendTimerSync(out); @@ -877,8 +876,8 @@ void TimerManager_::applySyncCommand(const char *json) DynamicJsonDocument doc(kTimerCmdJsonSize); if (deserializeJson(doc, json)) return; - // The echo/presence/follow/target decision is a PURE function (SyncEnvelope, - // ADR-0023): this shell only deserializes, then acts on the Decision. Dedup is + // The echo/presence/follow/target decision is a PURE function (SyncEnvelope): + // this shell only deserializes, then acts on the Decision. Dedup is // deliberately NOT in classify — SyncSeenCache::seen() is stateful (test-and- // record), so it stays here as the single guard before re-entry. SyncEnvelope::Decision d = @@ -887,7 +886,7 @@ void TimerManager_::applySyncCommand(const char *json) switch (d.kind) { case SyncEnvelope::Decision::HarvestPresence: - // Presence harvest (#111 / ADR-0019): record the sender ungated, apply no + // Presence harvest: record the sender ungated, apply no // timer state. classify already bypassed the follow/target gate. _registry.record(d.src, millis()); break; @@ -907,8 +906,8 @@ void TimerManager_::applySyncCommand(const char *json) } // --------------------------------------------------------------------------- -// Peer presence beacon (#111 / ADR-0019). The peer SET lives in PeerRegistry -// (extracted per ADR-0021); this class keeps only the beacon cadence + UDP send. +// Peer presence beacon. The peer SET lives in PeerRegistry +// (extracted); this class keeps only the beacon cadence + UDP send. // --------------------------------------------------------------------------- void TimerManager_::broadcastPresence() diff --git a/src/TimerManager.h b/src/TimerManager.h index 61befdee..1e6b5477 100644 --- a/src/TimerManager.h +++ b/src/TimerManager.h @@ -4,17 +4,17 @@ #include #include -#include "TimerEnums.h" // TimerState + BuzzerMode / FinishedMode + their codec tables (ADR-0010) -#include "TimerRuntime.h" // pure run-state engine: step() returns effects this class applies (issue #178) +#include "TimerEnums.h" // TimerState + BuzzerMode / FinishedMode + their codec tables +#include "TimerRuntime.h" // pure run-state engine: step() returns effects this class applies #include "TimerHa.h" // TimerHaEntity (the HA carrier publishAttributeGroup targets) -#include "TimerSettings.h" // TcValue + TIMER_SETTINGS_DESC_CAP (one-shot override snapshot, PRD #99) -#include "TimerCommand.h" // TimerCommand::Action (the runStateAction verb, issue #221) -#include "PeerRegistry.h" // the LAN peer set (extracted from this class, ADR-0019/0021) -#include "SyncSeenCache.h" // the sync dedup set (extracted from this class, ADR-0022) +#include "TimerSettings.h" // TcValue + TIMER_SETTINGS_DESC_CAP (one-shot override snapshot) +#include "TimerCommand.h" // TimerCommand::Action (the runStateAction verb) +#include "PeerRegistry.h" // the LAN peer set (extracted from this class) +#include "SyncSeenCache.h" // the sync dedup set (extracted from this class) // Result of parseCommand. All control surfaces share one validation policy // (reject invalid input atomically); only the HTTP API surfaces this as a -// status code — MQTT ignores it. See docs/adr/0001-timer-command-validation-parity.md. +// status code — MQTT ignores it. enum class TimerCmdResult : uint8_t { Ok = 0, BadJson = 1, BadField = 2, Disabled = 3 }; // Globals.cpp's full "awtrix"-namespace flush (declared in Globals.h, repeated @@ -27,13 +27,19 @@ void saveSettings(); // override snapshots and SavedConfigScope swaps, so "swap == copy this struct" holds. // Deliberately excludes durationSec (run-state) and the resolved melody RAM // (endRtttl/tickRtttl -- the saved melody NAME is a table key); those revert with the -// override but are not config the observation carriers project as "saved". See ADR-0024. +// override but are not config the observation carriers project as "saved". // Number of TimerState values (Idle/Running/Paused/Finished) — the width of the // state-indexed icon array. TimerState has no COUNT member; this is its stand-in. static constexpr size_t kTimerStateCount = 4; struct TimerMemberConfig { + // The explicit constructor keeps the two-value brace-init valid in C++11 + // (a class with default member initializers is not a C++11 aggregate). + TimerMemberConfig() = default; + TimerMemberConfig(BuzzerMode buzzer_, FinishedMode finished_) + : buzzer(buzzer_), finished(finished_) {} + BuzzerMode buzzer = BuzzerMode::End; FinishedMode finished = FinishedMode::AutoClear; String iconByState[kTimerStateCount]; // indexed by TimerState @@ -72,25 +78,24 @@ class TimerManager_ // -- Propagation surface (device-to-device timer sync) -- // While true, an inbound sync packet is being applied via parseCommand; the // broadcast* methods early-return so a received command is never re-emitted - // (one-hop topology). See CONTEXT.md "Propagation surface". + // (one-hop topology). bool _remoteApply = false; uint32_t _syncSeq = 0; // per-command sequence; only needs uniqueness within the dedup window // Emit one UDP broadcast mirroring a locally-accepted action to peers. No-ops // when sync is off (empty target list) or while applying an inbound packet - // (_remoteApply). Private since #222: every local run-state actor goes through + // (_remoteApply). Private: every local run-state actor goes through // runStateAction(), so the verb+mirror pairing is not a caller obligation. void broadcastRunState(const char *action); // Bounded recently-seen (src,seq) dedup set so the 3x redundant send is applied - // once. Extracted to its own host-testable module (SyncSeenCache, ADR-0022); the + // once. Extracted to its own self-contained module (SyncSeenCache); the // UDP transport + parseCommand re-entry stay here. TTL-based, so a sender reboot // (seq restart) self-clears by ageing out. SyncSeenCache _seen; - // -- Peer presence registry (#111 / ADR-0019, extracted to PeerRegistry per - // ADR-0021) -- - // The LAN peer set lives in its own host-testable module (PeerRegistry); this + // -- Peer presence registry (extracted to PeerRegistry) -- + // The LAN peer set lives in its own self-contained module (PeerRegistry); this // class keeps only the beacon cadence + UDP send. Harvested UNGATED from // inbound presence beacons (presence is informational, not a command — it // bypasses the follow/target gate and applies no timer state). @@ -100,7 +105,7 @@ class TimerManager_ bool _presenceEverSent = false; void broadcastPresence(); // emit one {_sync,presence:true} beacon - // -- One-shot override (save:false), PRD #99 / issue #100 -- + // -- One-shot override (save:false) -- // A save:false command applies its config for the CURRENT RUN only: the saved // config is snapshotted, the command applies live, and returnToIdle() restores // the snapshot when the timer next returns to Idle (reset or auto-clear). While @@ -123,7 +128,7 @@ class TimerManager_ TimerMemberConfig snapshotMemberConfig() const; void restoreMemberConfig(const TimerMemberConfig &c); - // Honest observation carriers (issue #101). RAII: while an override is active, + // Honest observation carriers. RAII: while an override is active, // present the SAVED config block (table inSnapshot rows + member-backed half) in // live storage so a carrier projection reads saved values, then restore the // effective (one-shot) values on scope exit. No-op when no override is active. @@ -146,15 +151,15 @@ class TimerManager_ }; // Which config a serializer reports: Saved opens a SavedConfigScope so an active - // one-shot override is masked (broadcastConfig / ADR-0006); Effective takes live - // storage as-is so a leader's own one-shot run mirrors to followers (ADR-0018 §4). + // one-shot override is masked (broadcastConfig); Effective takes live + // storage as-is so a leader's own one-shot run mirrors to followers. enum class View { Saved, Effective }; // The one seam every config-honesty serializer names its view through: runs // `serialize` with live storage swapped to `view` (View::Saved opens a // SavedConfigScope masking the one-shot override; View::Effective is a no-op). // Callers (GET mirror, HA bags, propagated snapshot) must name a View to compile, - // so no path silently omits the scope (ADR-0015 / ADR-0017). + // so no path silently omits the scope. template void withConfigView(View view, Serialize &&serialize) const { @@ -165,11 +170,11 @@ class TimerManager_ void addSyncEnvelope(JsonObject &sync); // forwards to SyncEnvelope::build (injects _syncSeq) uint32_t computeCurrentRemaining() const; - TimerRuntime::Inputs buildInputs() const; // resolve engine inputs from the environment (issue #179) - void runCommand(TimerRuntime::Command cmd); // start/pause/reset/setDuration adapter: step() + apply (issue #180) + TimerRuntime::Inputs buildInputs() const; // resolve engine inputs from the environment + void runCommand(TimerRuntime::Command cmd); // start/pause/reset/setDuration adapter: step() + apply void applyTransition(const TimerRuntime::Result &r, unsigned long now, const TimerRuntime::Inputs &in); // run-state bookkeeping for a returned Transition - void applyEffect(const TimerRuntime::Effect &e); // the effects-adapter seam (issue #178) + void applyEffect(const TimerRuntime::Effect &e); // the effects-adapter seam void persist(); void persistIfDirty(); void loadMelodiesCached(); @@ -178,7 +183,7 @@ class TimerManager_ public: // RAII guard that makes the persist-batching window a visible lexical scope - // (PRD #29) — the ONE commit seam shared by both batch call sites: + // — the ONE commit seam shared by both batch call sites: // parseCommand's apply block and the TIMER menu's long-press commit // (MenuManager). While the guard lives, member-backed persistence is // suspended; scope exit commits the whole Timer config, flushing both NVS @@ -217,7 +222,7 @@ class TimerManager_ } // A table-backed ("awtrix"-namespace) value changed inside the window. void markTableDirty() { tableDirty = true; } - // Mark this window one-shot: scope exit skips both NVS flushes (issue #100). + // Mark this window one-shot: scope exit skips both NVS flushes. void setTransient() { transient = true; } PersistBatch(const PersistBatch &) = delete; @@ -237,7 +242,7 @@ class TimerManager_ void pause(); void reset(); - // The run-state seam (issue #221 / #213): dispatch the verb (start/pause/reset) + // The run-state seam: dispatch the verb (start/pause/reset) // AND mirror it to peers via broadcastRunState with the matching action string — // the pairing every accepted action must make, folded into one entry so a call // site can't emit the verb without the mirror. Action::None is a no-op. Safe on @@ -247,13 +252,12 @@ class TimerManager_ void setDuration(uint32_t seconds); // persist=false applies + publishes live but defers the NVS write (the TIMER // menu's deferred-to-commit path; mirrors setIcon*'s publish flag). Every other - // caller uses the default and persists immediately. See docs/adr/0008. + // caller uses the default and persists immediately. void setBuzzerMode(BuzzerMode m, bool persist = true); void setFinishedMode(FinishedMode m, bool persist = true); // The parse/validate/format statics that once sat here are gone: deleted or - // relocated into the descriptor-table free functions (#204/#205/#206, - // TimerSettings.h; see docs/timer.md) — those are the only spellings. + // relocated into the descriptor-table free functions ( // TimerSettings.h; see docs/timer.md) — those are the only spellings. // The one shared icon setter: validate/reject/equality-skip/assign/persist/publish // for one state's slot. The four named setters below are thin delegators over it. @@ -266,27 +270,27 @@ class TimerManager_ void publishIcons(); - // Republish the current run-state string through the wire seam (issue #31). + // Republish the current run-state string through the wire seam. // Public because MQTTManager re-emits it on connect / discovery enable; it // is the only path that puts the state key on the wire. void publishState(); - // Same for the remaining-seconds key (issue #32): the only path that puts + // Same for the remaining-seconds key: the only path that puts // remaining on the wire; the periodic republish throttle stays in tick(). void publishRemaining(); - // Same for the duration key (issue #34): the only path that puts the + // Same for the duration key: the only path that puts the // trimmed-HMS duration on the wire. Public for the same connect / // discovery-enable republish sites. void publishDuration(); - // Same for the two enum keys (issue #33), dispatching through their member- + // Same for the two enum keys, dispatching through their member- // config rows' publish hooks: the only paths that put buzzer/finished on // the wire. Public for the same connect / discovery-enable republish sites. void publishBuzzerMode(); void publishFinishedMode(); - // Full wire refresh (issue #41): republish every Timer wire artifact once — + // Full wire refresh: republish every Timer wire artifact once — // the run-state trio plus every member-config row's publish hook, derived // from TIMER_MEMBER_CONFIG_DESCS so a new published row cannot be skipped. // The single call the connect / discovery-enable republish sites make. @@ -294,8 +298,7 @@ class TimerManager_ // Publish one HA carrier's read-only JSON attribute object — the carrier's // mapped settings keys built via timerBuildAttributeGroup — onto its - // json_attr_t topic via the wire seam (PRD #57 / issue #58, generalizing the - // bespoke realert_interval publish of issue #51). Retained, so HA repopulates + // json_attr_t topic via the wire seam. Retained, so HA repopulates // after a restart for free. No-op for a carrier with no mapped keys. void publishAttributeGroup(TimerHaEntity carrier); @@ -307,22 +310,22 @@ class TimerManager_ // Clear (empty retained payload) every distinct carrier's json_attr_t topic — // the teardown mirror of publishAllAttributeGroups(). The discovery teardown // (TimerHaHost::remove, SHOW_TIMER true->false) calls this so disabling the - // Timer leaves no orphaned attribute object retained on the broker (issue #60). + // Timer leaves no orphaned attribute object retained on the broker. void clearAllAttributeGroups(); TimerCmdResult parseCommand(const char *json); - // -- Home Assistant control adapter (issue #109) -- + // -- Home Assistant control adapter -- // Route a single HA timer callback through parseCommand instead of a deep // setter, so HA edits get the SAME atomic-reject validation, the same // propagation, and the same codec strings as the {prefix}/timer MQTT surface. // Each entity builds the minimal JSON command it represents and hands it to // parseCommand, mirroring the sync receive path. Display-free (no ArduinoHA, - // no MQTT client) so the HA->parseCommand path is host-testable. + // no MQTT client), so the HA->parseCommand path has no device dependency. // // rawValue per entity: // * Buzzer / Finished : the selected select-option INDEX as a decimal - // string; mapped through the per-enum codec (ADR-0010) to the canonical + // string; mapped through the per-enum codec to the canonical // wire spelling, so emitted and accepted JSON cannot drift. // * Duration : the raw HH:MM:SS text; parseCommand owns the // parse/validate (timerParseHMS + range), so that logic is NOT duplicated here. @@ -333,7 +336,7 @@ class TimerManager_ // -- Propagation surface -- // Run-state and config travel on separate packets; broadcastRunState is private - // (the runStateAction seam owns the verb+mirror pairing). See docs/adr/0006. + // (the runStateAction seam owns the verb+mirror pairing). void broadcastConfig(); // Validate, gate (echo/follow/target/dedup), then apply an inbound sync packet // through parseCommand under the _remoteApply guard. A presence beacon @@ -341,14 +344,14 @@ class TimerManager_ // before any command path (it carries no action/config and changes no state). void applySyncCommand(const char *json); - // Peer presence (#111 / ADR-0019). Called from the device loop with the current + // Peer presence. Called from the device loop with the current // millis(): emits a presence beacon at most once per kPresenceIntervalMs when on // a real network (NEVER in AP mode — broadcasts unconditionally otherwise so a // standalone clock is still discoverable), and ages out stale peers each call. void tickPresence(unsigned long nowMs); - // Peer registry observers (consumed by the dynamic HA Targets select in #112). + // Peer registry observers (consumed by the dynamic HA Targets select). // Thin forwarders onto the extracted PeerRegistry, so consumers (MQTTManager) - // are unchanged by the extraction (ADR-0021). + // are unchanged by the extraction. int peerCount() const { return _registry.count(); } bool hasPeer(const String &id) const { return _registry.has(id); } size_t peerIds(String *out, size_t cap) const { return _registry.ids(out, cap); } @@ -364,7 +367,7 @@ class TimerManager_ // The raw per-state slot (no Idle fallback — that lives in getIconForState). // The one indexed read the snapshot/emit hook uses; the four named getters - // delegate here, mirroring how setIcon unified the setters (#184/#185). + // delegate here, mirroring how setIcon unified the setters. const String &getIcon(TimerState s) const { return iconByState[(size_t)s]; } const String &getIconIdle() const { return getIcon(TimerState::Idle); } const String &getIconRunning() const { return getIcon(TimerState::Running); } @@ -378,13 +381,13 @@ class TimerManager_ // getStateString() so the one true spelling of each enum lives in one place. // (The command parser additionally tolerates non-hyphen aliases on input.) // Public so the member-config table's emit hooks (TimerSettings.cpp) can read - // them when building the propagated config snapshot. See docs/adr/0009. + // them when building the propagated config snapshot. const char *buzzerModeString() const; const char *finishedModeString() const; // Live read-only snapshot for the GET /api/timer observation surface. // Reports computeCurrentRemaining() (wall-clock fresh), not the throttled - // cached value. See docs/api.md and CONTEXT.md "observation surface". + // cached value. See docs/api.md. String getStateJson() const; }; diff --git a/src/TimerMenu.cpp b/src/TimerMenu.cpp index 4501c7fc..c55f7ba2 100644 --- a/src/TimerMenu.cpp +++ b/src/TimerMenu.cpp @@ -7,8 +7,8 @@ namespace { // Enum hooks. The setters defer the NVS write (persist=false): the TIMER menu // applies + publishes live during scroll and persists on the long-press - // commit, where MenuManager opens the PersistBatch guard (ADR-0008 as amended - // by PRD #29 / #45). Non-capturing lambdas decay to the row's function pointers. + // commit, where MenuManager opens the PersistBatch guard. Non-capturing + // lambdas decay to the row's function pointers. uint8_t getBuzzer() { return (uint8_t)TimerManager.getBuzzerMode(); } void setBuzzer(uint8_t v) { TimerManager.setBuzzerMode((BuzzerMode)v, /*persist=*/false); } uint8_t getFinished() { return (uint8_t)TimerManager.getFinishedMode(); } @@ -17,11 +17,10 @@ namespace // idx order is the on-screen list order (left/right walks it, wrapping). DURATION // is first (the HH:MM:SS wheel, delegating to TimerConfigEditor); MAIN is the lone -// Navigation row and sits last (the device reads it as the back-to-main item; PRD -// #83). +// Navigation row and sits last (the device reads it as the back-to-main item). // kind, name, cmdKey, step, codec, labelCount, getEnum, setEnum // The two EnumCycle slots read their bare leaf value from the per-enum codec -// table's menu column (ADR-0010) -- no private label copy to drift from it. +// table's menu column -- no private label copy to drift from it. const TimerMenuSlot TIMER_MENU_SLOTS[] = { {TimerMenuKind::Duration, "DURATION", nullptr, 0, nullptr, 0, nullptr, nullptr}, {TimerMenuKind::EnumCycle, "BUZZER", nullptr, 0, TIMER_BUZZER_CODEC, (uint8_t)BuzzerMode::COUNT, getBuzzer, setBuzzer}, diff --git a/src/TimerMenu.h b/src/TimerMenu.h index 14cca237..2866ae70 100644 --- a/src/TimerMenu.h +++ b/src/TimerMenu.h @@ -11,10 +11,9 @@ // TIMER_SETTINGS_DESCS and TIMER_HA_DESCRIPTORS). One row per list item; the // TimerMenuNav state machine walks it and MenuManager keeps only the drawing + // commit. This header is display-free (no DisplayManager) so the name/value/adjust -// logic is host-testable. See CONTEXT.md ("TIMER menu slot table"), docs/adr/0008 -// and docs/adr/0016 (drill-in navigation). +// logic is testable in isolation. // -// Two slot families, mirroring the B1 boundary (ADR-0007): +// Two slot families, mirroring the B1 boundary: // * table-backed slots (SteppedRange / BoolToggle) reuse their TIMER_SETTINGS_DESCS // row by cmdKey for storage + range -- they cannot drift from the settings table. // * the two enum slots (buzzer / finished) are member-backed: they carry bespoke @@ -24,7 +23,7 @@ // Slot kinds. The value kinds (EnumCycle / SteppedRange / BoolToggle) drill into a // leaf editor; Duration drills into the HH:MM:SS wheel (delegating to the existing // TimerConfigEditor edit engine, no settings-storage row); Navigation is the lone -// non-value row (MAIN) that walks the device back to the main menu (PRD #83). +// non-value row (MAIN) that walks the device back to the main menu. enum class TimerMenuKind : uint8_t { Duration, EnumCycle, SteppedRange, BoolToggle, Navigation }; struct TimerMenuSlot @@ -55,9 +54,9 @@ String timerMenuValue(uint8_t slot); // How the leaf for `slot` behaves, given the timer's current state `st`. The // Duration row is an editable HH:MM:SS wheel only when the timer is Idle; while -// Running/Paused it is read-only (PRD #83 user story 27). Every other row is a +// Running/Paused it is read-only. Every other row is a // plain Value leaf. The device hands the result to TimerMenuNav, so the Idle-only -// gating decision is host-testable. +// gating decision lives in display-free code. TimerNavLeaf timerMenuLeafKind(uint8_t slot, TimerState st); // Adjust the slot by one step in the given direction (dir > 0 = right/increment, diff --git a/src/TimerMenuNav.cpp b/src/TimerMenuNav.cpp index 6d7bc0e6..ce0e7d2c 100644 --- a/src/TimerMenuNav.cpp +++ b/src/TimerMenuNav.cpp @@ -1,9 +1,8 @@ #include "TimerMenuNav.h" -// Display-free TIMER-menu navigation state machine (PRD #83 / issues #85, #86). +// Display-free TIMER-menu navigation state machine. // See the header for the input->outcome contract. The implementation is pure logic -// over {focus, index, origin, leaf, bounds} -- no device, no display, no storage -- -// which is exactly what makes the interaction model host-testable. +// over {focus, index, origin, leaf, bounds} -- no device, no display, no storage. TimerMenuNav::TimerMenuNav() : _itemCount(1), _mainIndex(0), _index(0), diff --git a/src/TimerMenuNav.h b/src/TimerMenuNav.h index a20a0eae..90fb0b23 100644 --- a/src/TimerMenuNav.h +++ b/src/TimerMenuNav.h @@ -3,13 +3,13 @@ #include -// Display-free navigation state machine for the TIMER global menu (PRD #83 / -// issue #85). It owns the whole interaction model behind a small interface: +// Display-free navigation state machine for the TIMER global menu. +// It owns the whole interaction model behind a small interface: // list/leaf focus, the selected index, and the entry origin. Button inputs map // to OUTCOMES that the device layer (MenuManager) acts on; the device keeps only // drawing (the list indicator / the leaf value) and the single commit. Because -// it depends on nothing but Arduino.h, the interaction model is host-testable -// under test_timer without a device. See CONTEXT.md and docs/adr/0016. +// it depends on nothing but Arduino.h, the interaction model is testable +// without a device. // // The list is the TIMER menu's slot rows. Value rows (enum / number / bool) drill // into a leaf editor; the lone navigation row (MAIN) returns to the main menu. @@ -22,7 +22,7 @@ enum class TimerNavOrigin : uint8_t { Menu, App }; // The kind of leaf a value row drills into. Most rows are a plain Value leaf // (cycle/step/toggle). DURATION is a field editor (HH/MM/SS wheel) when the timer // is Idle, and read-only otherwise -- the device maps slot+timer-state to this via -// timerMenuLeafKind() (host-tested), so the gating decision lives in testable code. +// timerMenuLeafKind(), so the gating decision lives in display-free code. enum class TimerNavLeaf : uint8_t { Value, DurationEditable, DurationReadOnly }; // Outcomes the device layer acts on. Anything not listed (plain list movement) is diff --git a/src/TimerRuntime.cpp b/src/TimerRuntime.cpp index 0a45df50..25c28519 100644 --- a/src/TimerRuntime.cpp +++ b/src/TimerRuntime.cpp @@ -39,10 +39,10 @@ namespace TimerRuntime fx.push_back({EffectKind::SetBrightness}); // brightness 0 } - // The input-driven lifecycle transitions (issue #180): start/pause/reset/ + // The input-driven lifecycle transitions: start/pause/reset/ // setDuration decided as pure phase transitions over the same effect seam. The // adapter owns the run-state bookkeeping each Transition names (enterRunning's - // runStart* capture, the ADR-0024 override restore behind ToIdle); here we only + // runStart* capture, the one-shot override restore behind ToIdle); here we only // decide the phase change and the ordered effects. static Result stepCommand(const State &s, const Inputs &in) { @@ -116,7 +116,7 @@ namespace TimerRuntime { // Auto-clear: stop the sound, return to Idle, republish, and (when // the panel is dark) drop brightness to 0. The adapter maps ToIdle - // to returnToIdle()+state/remaining so ADR-0024's store stays there. + // to returnToIdle()+state/remaining so the override store stays there. r.effects.push_back({EffectKind::StopSound}); r.effects.push_back({EffectKind::PublishState}); r.effects.push_back({EffectKind::PublishRemaining}); diff --git a/src/TimerRuntime.h b/src/TimerRuntime.h index b6c23289..2542265f 100644 --- a/src/TimerRuntime.h +++ b/src/TimerRuntime.h @@ -6,16 +6,16 @@ #include "TimerEnums.h" // TimerState / FinishedMode (the run-state the engine keys on) -// Pure timer run-state engine (PRD #28, issues #178/#179). step() is a total +// Pure timer run-state engine. step() is a total // function of (state, nowMs, inputs) that RETURNS the ordered effects plus the // run-state bookkeeping the adapter must apply, instead of performing anything; // the TimerManager adapter executes each returned effect in order against // PeripheryManager / DisplayManager / the wire seam and applies the bookkeeping. // // No hardware headers: the engine names PeripheryManager/DisplayManager nowhere, -// so it is host-compilable on its own. The [env:native_runtime] link — which -// pulls in TimerRuntime.cpp and links no TimerManager singleton — is the -// architectural claim, exactly as native_sync/native_validate are for theirs. +// so it compiles on its own and links no TimerManager singleton — that +// dependency-free link is the architectural claim, exactly as it is for +// SyncEnvelope and TimerCommand. namespace TimerRuntime { // Which pre-loaded tune the adapter should play. The engine stays free of the @@ -23,8 +23,8 @@ namespace TimerRuntime enum class Tone : uint8_t { End, Tick }; // What the adapter is asking the engine to decide. Tick is the wall-clock - // countdown step (issues #178/#179); the rest are the input-driven lifecycle - // commands (issue #180) — the same public verbs TimerManager exposes. Default + // countdown step; the rest are the input-driven lifecycle + // commands — the same public verbs TimerManager exposes. Default // is Tick so an Inputs built without naming a command is the countdown step. enum class Command : uint8_t { Tick, Start, Pause, Reset, SetDuration }; @@ -46,15 +46,21 @@ namespace TimerRuntime // trivially copyable and gnu++11-friendly for the device build. struct Effect { - EffectKind kind; + // The (non-explicit) kind constructor keeps the engine's one-value + // push_back({EffectKind::X}) brace-inits valid in C++11 (a class with + // default member initializers is not a C++11 aggregate). + Effect() = default; + Effect(EffectKind kind_) : kind(kind_) {} + + EffectKind kind = EffectKind::PublishState; Tone tone = Tone::End; // valid iff kind == PlayTone uint8_t brightness = 0; // valid iff kind == SetBrightness }; // The run-state bookkeeping the adapter applies AFTER executing the effects. // The engine decides the transition; the adapter owns the state mutation (and - // the override-store restore behind ToIdle) so ADR-0024's store stays in the - // manager (PRD #28 US7). + // the override-store restore behind ToIdle) so the override store stays in the + // manager. enum class Transition : uint8_t { None, // no phase change @@ -106,8 +112,16 @@ namespace TimerRuntime struct State { - TimerState phase; - uint32_t remainingSec; // the previous remaining (before this tick) + // The explicit constructor keeps the adapter's five-value brace-init valid in + // C++11 (a class with default member initializers is not a C++11 aggregate). + State() = default; + State(TimerState phase_, uint32_t remainingSec_, unsigned long lastPublishMs_, + unsigned long enteredFinishedMs_, unsigned long lastRealertMs_) + : phase(phase_), remainingSec(remainingSec_), lastPublishMs(lastPublishMs_), + enteredFinishedMs(enteredFinishedMs_), lastRealertMs(lastRealertMs_) {} + + TimerState phase = TimerState::Idle; + uint32_t remainingSec = 0; // the previous remaining (before this tick) unsigned long lastPublishMs = 0; // Running publish throttle anchor unsigned long enteredFinishedMs = 0; // auto-clear hold anchor unsigned long lastRealertMs = 0; // re-alert interval anchor diff --git a/src/TimerSettings.cpp b/src/TimerSettings.cpp index bedf7e81..0e66ffa1 100644 --- a/src/TimerSettings.cpp +++ b/src/TimerSettings.cpp @@ -3,14 +3,13 @@ #include #include // snprintf (timerFormatHMS, the bar-color formatters) -// The descriptor-table family is the PURE Timer validation surface (#143): it links +// The descriptor-table family is the PURE Timer validation surface: it links // against ArduinoJson + the enum codec tables ONLY -- no Globals.h (FastLED), no // TimerManager singleton, no MQTTManager, no Preferences. That dependency-light link -// is what lets TimerCommand::classify be host-tested in [env:native_validate] with no -// stubs. The impure half -- the member-config apply/emit/publish hooks, the NVS -// round-trip, and the full-config dump, all of which DO touch the singleton / MQTT / -// Preferences -- lives in TimerSettingsApply.cpp, linked only in device + full-suite -// builds. The two share this header. +// is what lets TimerCommand::classify be exercised in isolation. The impure +// half -- the member-config apply/emit/publish hooks, the NVS round-trip, and the +// full-config dump, all of which DO touch the singleton / MQTT / Preferences -- +// lives in TimerSettingsApply.cpp. The two share this header. // // The 13 persisted timer-setting globals are DEFINED here (moved out of Globals.cpp; // Globals.h keeps the extern decls so every other caller is source-unchanged) so the @@ -25,8 +24,8 @@ String TIMER_MELODY_END = "timer_end"; bool TIMER_BAR_ENABLED = true; bool TIMER_ICON_ENABLED = true; uint32_t TIMER_BAR_COLOR = 0; -uint32_t TIMER_BAR_BG_COLOR = 0; // 0 = black = no track (literal off; see ADR-0020) -bool TIMER_SYNC_FOLLOW = true; // fresh clock is a follower; standalone is opt-out (#124) +uint32_t TIMER_BAR_BG_COLOR = 0; // 0 = black = no track (literal off) +bool TIMER_SYNC_FOLLOW = true; // fresh clock is a follower; standalone is opt-out String TIMER_SYNC_TARGETS = ""; namespace @@ -87,7 +86,7 @@ namespace return false; } - // max_duration carrier-native HA-attribute formatter (PRD #66 / issue #68): + // max_duration carrier-native HA-attribute formatter: // renders the stored cap (raw seconds, a U32) as the trimmed H:MM:SS clock // string the Duration text entity's OWN state speaks, by reusing the exact // formatHMS the Duration state uses ("24:00:00", "1:00:00", "0:45"). This is @@ -98,7 +97,7 @@ namespace void formatMaxDurationHMS(const TimerSettingDesc &d, JsonDocument &doc) { uint32_t v = *static_cast(d.storage); - doc[d.cmdKey] = timerFormatHMS(v); // family-local pure formatter (#143) + doc[d.cmdKey] = timerFormatHMS(v); // family-local pure formatter } // sync_targets: strict string type, then the comma-list rule above. @@ -112,11 +111,11 @@ namespace } } -// bar_color / bar_bg_color HA-attribute formatters (PRD #57 / issue #59, ADR-0020). +// bar_color / bar_bg_color HA-attribute formatters. // External linkage (not file-local) so both the attribute-group table here and the // HTTP full-config dump in TimerSettingsApply.cpp render the stored 0xRRGGBB int the -// same way (#143) -- they cannot disagree about the string form. bar_color's off -// sentinel (0) is "default" (follow text color, ADR-0004); bar_bg_color's off is +// same way -- they cannot disagree about the string form. bar_color's off +// sentinel (0) is "default" (follow text color); bar_bg_color's off is // "none" (black = no track, literal off); any other value is uppercase "#RRGGBB". void timerFormatBarColor(const TimerSettingDesc &d, JsonDocument &doc) { @@ -236,7 +235,7 @@ bool timerSettingStore(const TimerSettingDesc &d, const TcValue &v) } // timerSettingsLoadNvs / timerSettingsSaveNvs (Preferences round-trip) moved to the -// impure TimerSettingsApply.cpp (#143): they touch Preferences, which this pure TU +// impure TimerSettingsApply.cpp: they touch Preferences, which this pure TU // deliberately does not link. void timerSettingsLoadDevJson(JsonObjectConst obj) @@ -306,7 +305,7 @@ const TimerSettingDesc *timerSettingByCmdKey(const char *cmdKey) return nullptr; } -// Inline RTTTL classifier/validator (issue #102). Pure, no globals touched. +// Inline RTTTL classifier/validator. Pure, no globals touched. namespace { // RTTTL tunes for a single timer alarm/tick are short; cap so a pathological @@ -363,7 +362,7 @@ bool timerMelodyValidateInline(const String &s) return true; } -// Relocated out of TimerManager (#142). Self-contained: inlines the +// Relocated out of TimerManager. Self-contained: inlines the // h*3600+m*60+sec sum (timerHmsToSeconds below) rather than delegating, so the // parse stays one readable pass -- behaviour-identical to the former static. bool timerParseHMS(const String &in, uint32_t &outSeconds) @@ -406,7 +405,7 @@ bool timerParseHMS(const String &in, uint32_t &outSeconds) return true; } -// Relocated out of TimerManager (#142). Pure case-insensitive +// Relocated out of TimerManager. Pure case-insensitive // membership check over the three action verbs -- behaviour-identical. bool timerIsValidAction(const String &s) { @@ -414,9 +413,9 @@ bool timerIsValidAction(const String &s) return a == "start" || a == "pause" || a == "reset"; } -// Relocated out of TimerManager's statics (#143) so the command validator links the +// Relocated out of TimerManager's statics so the command validator links the // table family, not the singleton. Behaviour-identical to the former statics, all -// of which are gone (#204/#205 deleted the forwarders). +// of which are gone (the thin forwarders were deleted). bool timerIsValidIconName(const String &name) { @@ -478,7 +477,7 @@ String timerFormatHMS(uint32_t seconds) return timerClock(seconds, ClockStyle::Trimmed); } -// Relocated out of TimerManager (#206), the singleton's last pure statics +// Relocated out of TimerManager, the singleton's last pure statics // (secondsToHMS / hmsToSeconds) -- byte-identical. void timerSecondsToHMS(uint32_t sec, uint32_t &h, uint32_t &m, uint32_t &s) { @@ -493,15 +492,15 @@ uint32_t timerHmsToSeconds(uint32_t h, uint32_t m, uint32_t s) } // --------------------------------------------------------------------------- -// HA attribute-group projection (PRD #57 / issues #58, #59). The buzzer + finished -// HASelects were lit up first (#58); #59 extended the JSON-attributes opt-in to +// HA attribute-group projection. The buzzer + finished +// HASelects were lit up first; the JSON-attributes opt-in then extended to // HASensor, lighting up the remaining + state sensors. realert_interval is listed // first on the finished carrier so the folded payload is a superset of the legacy // bespoke {"realert_interval":N}. remaining_publish_interval rides BOTH the // remaining sensor (its own cadence) and the state sensor (a complete config view) // -- one settings row, two carrier rows. bar_color carries the per-row formatter // so it renders as "default"/"#RRGGBB" rather than its raw-int snapshot form. -// max_duration (PRD #66 / issue #68) is the first MULTI-CARRIER key whose +// max_duration is the first MULTI-CARRIER key whose // representation differs PER CARRIER: it rides the Duration text entity in // carrier-native clock form (formatMaxDurationHMS -> "24:00:00") AND the state // sensor in raw seconds (no formatter -> 86400) -- one settings row, two carrier @@ -542,7 +541,7 @@ void timerBuildAttributeGroup(TimerHaEntity carrier, JsonDocument &doc) } // =========================================================================== -// Member-backed config half (B1) -- PURE validation projection (#143). +// Member-backed config half (B1) -- PURE validation projection. // The validate predicates + the parallel {cmdKey, validate} table // (TIMER_MEMBER_VALIDATORS) that TimerCommand::classify uses live HERE, with no // singleton/MQTT dependency. The impure half -- the apply/emit/publish hooks, the diff --git a/src/TimerSettings.h b/src/TimerSettings.h index 88ff9aac..822ec98a 100644 --- a/src/TimerSettings.h +++ b/src/TimerSettings.h @@ -5,19 +5,19 @@ #include #include "TimerHa.h" // TimerHaEntity (the HA carrier each attribute group rides) -#include "TimerEnums.h" // BuzzerMode / FinishedMode for the relocated enum parsers (#143) +#include "TimerEnums.h" // BuzzerMode / FinishedMode for the relocated enum parsers // Persisted Timer settings: the single descriptor table that drives validation, // apply, NVS persistence, dev.json overrides and the propagated config snapshot // for the value-config Timer keys. Modelled on TimerHa.h ("one table, no drift"). // -// This table is a SUPERSET, not "the config block": the `inSnapshot` column is the -// codified boundary CONTEXT.md describes in prose -- +// This table is a SUPERSET, not "the config block": the `inSnapshot` column +// codifies the config/identity boundary -- // * config block = the inSnapshot == true rows (the propagated snapshot) // * sync roles / local id = the inSnapshot == false rows (sync_follow/sync_targets, -// deliberately excluded from the snapshot per ADR-0006) +// deliberately excluded from the snapshot) // -// Out of scope (the B1 boundary, see docs/adr/0007): duration/buzzer/finished and the +// Out of scope (the B1 boundary): duration/buzzer/finished and the // four icon_ keys stay on TimerManager's publish-aware setters. They are config // block too, but their snapshot/broadcast membership is governed by the shared // member-config list in TimerManager.cpp, not by this table. @@ -56,7 +56,7 @@ extern const size_t TIMER_SETTINGS_DESC_COUNT; // Compile-time row count (TIMER_SETTINGS_DESC_COUNT is only known at runtime, so it // cannot size a fixed array). A static_assert in TimerSettings.cpp pins it equal to // the table extent, so it cannot drift. Used by the one-shot override controller -// (PRD #99 / issue #100) to stack a config snapshot buffer over the table. +// to stack a config snapshot buffer over the table. constexpr size_t TIMER_SETTINGS_DESC_CAP = 13; // Validate + coerce one field into `out`. Never writes a global (pure parse). Strict @@ -80,13 +80,13 @@ void timerSettingsLoadDevJson(JsonObjectConst obj); // Emit ONE row's live value into `doc` under its cmdKey, dispatched by `type` and // IGNORING snapshot membership. The single "storage -> JSON value" helper shared by // the config snapshot (inSnapshot rows) and the HA attribute builder, so the two can -// never disagree about a value (PRD #57). +// never disagree about a value. void timerSettingEmitValue(const TimerSettingDesc &d, JsonDocument &doc); // Emit the inSnapshot rows into `doc` (the propagated config block, table half). void timerSettingsBuildSnapshot(JsonDocument &doc); -// One-shot override (PRD #99 / issue #100): capture/restore the table half's config +// One-shot override: capture/restore the table half's config // block (the inSnapshot rows; sync_* are inSnapshot=false and excluded by // construction) into a caller-owned TcValue buffer of TIMER_SETTINGS_DESC_CAP slots, // indexed by row. Capture reads each storage by type; restore writes it back via @@ -98,7 +98,7 @@ void timerSettingsRestoreSnapshot(const TcValue in[]); const TimerSettingDesc *timerSettingByCmdKey(const char *cmdKey); // --------------------------------------------------------------------------- -// Inline RTTTL classifier/validator (PRD #99 / issue #102) -- a pure, reusable pair +// Inline RTTTL classifier/validator -- a pure, reusable pair // reused by command validation and melody resolution. `melody_end`/`melody_tick` // accept EITHER a bare file-name token (as today) OR an inline RTTTL tune; the two // are distinguished by content. An inline tune is always one-shot (it has no @@ -116,10 +116,10 @@ bool timerMelodyIsInline(const String &s); bool timerMelodyValidateInline(const String &s); // --------------------------------------------------------------------------- -// Pure validation helpers relocated out of TimerManager's statics (#142) so the +// Pure validation helpers relocated out of TimerManager's statics so the // command validator links the descriptor-table family, not the singleton. The -// singleton's thin forwarders were deleted once production callers were gone -// (#204); these free functions are the only spellings. +// singleton's thin forwarders were deleted once production callers were gone; +// these free functions are the only spellings. // Parse a duration string into seconds. Accepts bare seconds, MM:SS or HH:MM:SS; // each field is a non-empty digit run; fields are summed without a 0-59 cap @@ -132,7 +132,7 @@ bool timerParseHMS(const String &s, uint32_t &outSeconds); // {start, pause, reset}. No trim (a surrounding space rejects). No globals. bool timerIsValidAction(const String &s); -// More pure validators/formatters relocated into the table family (#143) so the +// More pure validators/formatters relocated into the table family so the // command validator (TimerCommand::classify) links the family, not the singleton. // Bare icon/melody file-name char-rule: [A-Za-z0-9_-], length 0..32 (empty = clear, @@ -140,12 +140,12 @@ bool timerIsValidAction(const String &s); // TimerManager's private icon-slot validation. No globals. bool timerIsValidIconName(const String &name); -// String->enum over the buzzer / finished codec tables (ADR-0010), case-insensitive +// String->enum over the buzzer / finished codec tables, case-insensitive // wire spelling or alias. The enum parse behind the member validators. No globals. bool timerParseBuzzerMode(const String &s, BuzzerMode &out); bool timerParseFinishedMode(const String &s, FinishedMode &out); -// The one seconds->clock renderer (#153/#158). Three deliberate spellings the timer +// The one seconds->clock renderer. Three deliberate spellings the timer // surfaces speak, collapsed into one function: // Trimmed drop the hours group when zero; most-significant field unpadded, lower // fields zero-padded ("24:00:00" / "1:00:00" / "0:45"). HA/config-read form. @@ -162,13 +162,13 @@ String timerClock(uint32_t seconds, ClockStyle style); String timerFormatHMS(uint32_t seconds); // Raw seconds <-> H/M/S integer decomposition, relocated out of TimerManager's -// last pure statics (#206). Pure math, no clamp (the on-device config editor — +// last pure statics. Pure math, no clamp (the on-device config editor — // the one caller — owns its 99h display cap), no globals. void timerSecondsToHMS(uint32_t sec, uint32_t &h, uint32_t &m, uint32_t &s); uint32_t timerHmsToSeconds(uint32_t h, uint32_t m, uint32_t s); // --------------------------------------------------------------------------- -// HA attribute-group projection (PRD #57) -- the descriptor-table family's fifth +// HA attribute-group projection -- the descriptor-table family's fifth // member. One row per (carrier entity, settings key) projection: a persisted // settings value surfaced as a read-only JSON attribute on the HA entity it is // semantically about. A key may map to MULTIPLE carriers (one row each). The @@ -193,18 +193,18 @@ extern const size_t TIMER_ATTR_GROUP_DESC_COUNT; void timerBuildAttributeGroup(TimerHaEntity carrier, JsonDocument &doc); // --------------------------------------------------------------------------- -// The config block's SECOND table: the member-backed half (B1, ADR-0007/0009). +// The config block's SECOND table: the member-backed half (B1). // // One row per member-backed config key (buzzer / finished / the four icon_). // Unlike TIMER_SETTINGS_DESCS' DECLARATIVE rows, these carry function-pointer hooks // -- exactly like TIMER_MENU_SLOTS' enum slots -- because their values are owned by // TimerManager's publish-aware setters (equality-skip, _suspendPersist batching, MQTT -// publish), not by a typed storage pointer. This is NOT ADR-0007's rejected "B2 / fold -// into the declarative table"; it is a separate hook table, the fourth member of the +// publish), not by a typed storage pointer. This is deliberately NOT folded +// into the declarative table; it is a separate hook table, the fourth member of the // descriptor-table family (TIMER_SETTINGS_DESCS, TIMER_HA_DESCRIPTORS, TIMER_MENU_SLOTS). // // Together the two tables ARE the config block. `duration` is member-backed too but is -// deliberately EXCLUDED: it is run-state, not config (CONTEXT.md), and its validation +// deliberately EXCLUDED: it is run-state, not config, and its validation // depends on the cross-field effectiveMaxDuration staged from the table half. struct TimerMemberConfigDesc { @@ -213,7 +213,7 @@ struct TimerMemberConfigDesc void (*apply)(const TcValue &); // routes via TimerManager's deep setter void (*emit)(JsonDocument &doc); // writes the live value into the snapshot void (*publish)(); // optional: live value onto the MQTT wire - // seam (issue #33); nullptr = key not + // seam; nullptr = key not // individually published }; @@ -225,13 +225,13 @@ extern const size_t TIMER_MEMBER_CONFIG_DESC_COUNT; // pins it to the table extent. Bump if a member-config row is added. constexpr size_t TIMER_MEMBER_CONFIG_DESC_CAP = 6; -// The member-config half's PURE validation projection (#143). TIMER_MEMBER_CONFIG_DESCS +// The member-config half's PURE validation projection. TIMER_MEMBER_CONFIG_DESCS // carries impure apply/emit/publish hooks (the TimerManager singleton + the MQTT wire -// seam), so the whole table can't link in the dependency-light command-validator env -// (native_validate). This parallel table carries ONLY the pure {cmdKey, validate} +// seam), so the whole table can't link where only the dependency-light command +// validator is wanted. This parallel table carries ONLY the pure {cmdKey, validate} // columns -- the exact same validate function pointers -- so TimerCommand::classify -// validates the member half without dragging in the apply machinery. A drift guard -// (test) pins the two tables row-for-row (same cmdKey, same validate) so they cannot +// validates the member half without dragging in the apply machinery. The two +// tables stay row-for-row (same cmdKey, same validate) so they cannot // disagree about what a member key is or how it validates. struct TimerMemberValidatorDesc { @@ -252,7 +252,7 @@ bool timerMemValidateFinished(JsonVariantConst v, TcValue &out); bool timerMemValidateIcon(JsonVariantConst v, TcValue &out); // bar_color / bar_bg_color attribute formatters (external linkage, defined in the pure -// TU) shared by the attr-group table and the HTTP full-config dump (#143). +// TU) shared by the attr-group table and the HTTP full-config dump. void timerFormatBarColor(const TimerSettingDesc &d, JsonDocument &doc); void timerFormatBarBgColor(const TimerSettingDesc &d, JsonDocument &doc); @@ -261,11 +261,11 @@ void timerMemberConfigBuildSnapshot(JsonDocument &doc); // Run `cmdKey`'s declared publish hook (no-op if the key has none / is unknown). // TimerManager's per-key publish methods dispatch through this, so the row is the -// single place "how key X goes out on the wire" is defined (issue #33 / PRD #28). +// single place "how key X goes out on the wire" is defined. void timerMemberConfigPublish(const char *cmdKey); // --------------------------------------------------------------------------- -// HTTP GET /api/timer config mirror (PRD #73). Build the COMPLETE persisted +// HTTP GET /api/timer config mirror. Build the COMPLETE persisted // configuration into `doc`: the full two-table dump an HTTP-only client reads // back so it can confirm everything it can POST. Unlike timerSettingsBuildSnapshot // it does NOT honour the inSnapshot filter -- the sync-role keys @@ -273,9 +273,9 @@ void timerMemberConfigPublish(const char *cmdKey); // (buzzer/finished/the four icon_*) via timerMemberConfigBuildSnapshot. Pure: it // reads only the descriptor storage, no I/O, no globals beyond that. Because it // walks the same two tables that drive the control surface, the read surface -// cannot drift from what is writable (the drift-guard test pins this). Values are -// raw here; the two carrier-native renderings (friendly bar_color, -// max_duration_str) are layered on in issue #75. +// cannot drift from what is writable. Values are raw here; the two +// carrier-native renderings (friendly bar_color, max_duration_str) are layered +// on by the HA attribute builder. void timerBuildFullConfig(JsonDocument &doc); #endif diff --git a/src/TimerSettingsApply.cpp b/src/TimerSettingsApply.cpp index f9d95b5a..4176149d 100644 --- a/src/TimerSettingsApply.cpp +++ b/src/TimerSettingsApply.cpp @@ -7,10 +7,9 @@ #include "MQTTManager.h" // the (topic, payload) wire seam the publish hooks emit on #include "TimerHa.h" // TimerHaEntity slots for the hooks' canonical topics -// The IMPURE half of the descriptor-table family (#143). Everything here touches the -// TimerManager singleton, the MQTT wire seam, or Preferences -- so it is split out of -// the pure TimerSettings.cpp and linked only in device + full-suite builds, never in -// the dependency-light command-validator env (native_validate). The PURE half (the +// The IMPURE half of the descriptor-table family. Everything here touches the +// TimerManager singleton, the MQTT wire seam, or Preferences -- so it is split out +// of the pure TimerSettings.cpp, which stays dependency-light. The PURE half (the // descriptor tables, parsers, member VALIDATORS) lives in TimerSettings.cpp; this file // carries the member apply/emit/publish hooks, the full TIMER_MEMBER_CONFIG_DESCS // table, the NVS round-trip, and the HTTP full-config dump. @@ -60,7 +59,7 @@ namespace // -- buzzer -- void memApplyBuzzer(const TcValue &v) { TimerManager.setBuzzerMode((BuzzerMode)v.num); } void memEmitBuzzer (JsonDocument &doc) { doc["buzzer"] = TimerManager.buzzerModeString(); } - // Publish hook (issue #33): the live value onto the wire seam, payload the + // Publish hook: the live value onto the wire seam, payload the // per-enum codec's canonical `wire` string -- never the numeric index, and // deliberately not the HASelect `ha` label the retired setState path sent. void memPublishBuzzer() @@ -82,7 +81,7 @@ namespace // -- icon_ (shared validate + shared aggregate publish; the per-slot // apply/emit fold into these two TimerState-indexed templates over iconByState[]). // A fifth timer state wires its apply + emit for free from its row's tag - // instead of needing a hand-written pair that could be forgotten (#185). The + // instead of needing a hand-written pair that could be forgotten. The // snapshot/command key per state, ordered by TimerState (== each row's cmdKey). static const char *const kIconCmdKeys[kTimerStateCount] = { "icon_idle", "icon_running", "icon_paused", "icon_finished"}; @@ -90,7 +89,7 @@ namespace void memApplyIcon(const TcValue &v) { TimerManager.setIcon(S, v.str); } template void memEmitIcon (JsonDocument &doc) { doc[kIconCmdKeys[(size_t)S]] = TimerManager.getIcon(S); } - // Publish hook, shared by all four icon rows (issue #34): the icon keys have + // Publish hook, shared by all four icon rows: the icon keys have // ONE wire artifact — the aggregate four-state JSON on the plain // {MQTT_PREFIX}/timer/icons topic (not an HA entity data topic), payload // byte-identical to the retired MQTTManager::publishTimerIcons composer. @@ -137,7 +136,7 @@ void timerMemberConfigPublish(const char *cmdKey) } // =========================================================================== -// HTTP GET /api/timer config mirror (PRD #73). The complete persisted-config +// HTTP GET /api/timer config mirror. The complete persisted-config // projection: both tables, no snapshot filter, raw values. Lives beside the impure // member half it dumps. See the header for the full contract. // =========================================================================== @@ -146,7 +145,7 @@ void timerBuildFullConfig(JsonDocument &doc) // Table half: EVERY settings row, ignoring inSnapshot, so the sync-role keys // (sync_follow/sync_targets) are part of the read mirror even though they are // never propagated. Raw value per row via the shared single-row emitter, with - // the two deliberate carrier-native overrides (PRD #73 D2) reusing the family's + // the two deliberate carrier-native overrides reusing the family's // shared bar formatters -- diverging from the raw propagation snapshot by design, // yet never able to disagree in value (same storage). for (size_t i = 0; i < TIMER_SETTINGS_DESC_COUNT; ++i) diff --git a/src/TimerView.cpp b/src/TimerView.cpp index 26e8b612..a14dbf0b 100644 --- a/src/TimerView.cpp +++ b/src/TimerView.cpp @@ -54,8 +54,8 @@ TimerView TimerViewModel::compute(const TimerSnapshot &s) // The bar divides by the duration captured when the run began, not the live // configured duration, so editing the duration mid-run leaves the in-progress - // bar untouched (it re-arms on the next start/reset). See API-17 in - // TIMER_TEST_PLAN.md. Falls back to the configured duration if no snapshot. + // bar untouched (it re-arms on the next start/reset). Falls back to the + // configured duration if no snapshot. uint32_t barDuration = s.runDuration; if (barDuration == 0) barDuration = duration; if (ts != TimerState::Idle && barDuration > 0) @@ -68,7 +68,7 @@ TimerView TimerViewModel::compute(const TimerSnapshot &s) // The background track is the full bar trough and persists for the whole // Running/Paused window, independent of the foreground's len-rounds-to-0 - // gate below (ADR-0020). The painter AND-s in TIMER_BAR_ENABLED / a non-black + // gate below. The painter AND-s in TIMER_BAR_ENABLED / a non-black // bar_bg_color, exactly as it does for the foreground. v.showBarTrack = true; v.barTrackLen = (uint8_t)barMaxLen; diff --git a/src/TimerView.h b/src/TimerView.h index 86441d06..2c54a3e7 100644 --- a/src/TimerView.h +++ b/src/TimerView.h @@ -8,8 +8,7 @@ // The explicit, per-frame input to TimerViewModel::compute(): a plain-data // snapshot of everything the view reads, captured once from TimerManager by the // painter (src/Apps.cpp). It carries no methods and no TimerManager dependency, -// so compute() is a pure function of this value and the host tests can construct -// it directly. See CONTEXT.md ("Timer View"). +// so compute() is a pure function of this value. struct TimerSnapshot { TimerState state; // lifecycle state (Idle/Running/Paused/Finished) @@ -26,9 +25,8 @@ struct TimerSnapshot // gates the icon and, when off, reflows text+bar to the full panel). TimerApp // (src/Apps.cpp) is its painter — it maps this struct to DisplayManager / matrix // calls and owns the font-dependent text centering. Keeping the view display-free -// is what lets the host tests cover the bar geometry, blink cadence and -// display-string selection that the renderer previously hid. See CONTEXT.md -// ("Timer View"). +// keeps the bar geometry, blink cadence and display-string selection +// independently checkable instead of hidden in the renderer. struct TimerView { enum class Screen : uint8_t { Config, Finished, Time }; @@ -52,7 +50,7 @@ struct TimerView uint8_t barLen; // 0 .. (kBarMaxLen, or kScreenW when the icon is hidden) int16_t barStartX; // app-local start column - // Background track behind the bar (ADR-0020): the FULL bar extent, painted in + // Background track behind the bar: the FULL bar extent, painted in // bar_bg_color before the foreground segment. Unlike showBar it PERSISTS for the // whole Running/Paused window -- including the final stretch where barLen rounds // to 0 and showBar is false -- so the trough stays visible like the app's @@ -81,7 +79,7 @@ namespace TimerViewModel // 1..9h -> "H:MM" (seconds dropped to fit; 3661 -> "1:01") // >= 10h -> "HH:MM" (36000 -> "10:00") // Distinct from timerFormatHMS (the "H:MM:SS" wire string, which - // always carries seconds). See CONTEXT.md ("Timer Display String"). + // always carries seconds). void formatTimerDisplay(uint32_t seconds, char *out, size_t outLen); } diff --git a/src/main.cpp b/src/main.cpp index 7fc9ecb7..de9aa1d6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -124,11 +124,11 @@ void loop() ServerManager.tick(); DisplayManager.tick(); TimerManager.tick(); - TimerManager.tickPresence(millis()); // peer presence beacon + registry aging (#111) + TimerManager.tickPresence(millis()); // peer presence beacon + registry aging PeripheryManager.tick(); if (ServerManager.isConnected) { MQTTManager.tick(); - TimerHaHost.refreshTargets(millis()); // dynamic HA Targets select republish (#112) + TimerHaHost.refreshTargets(millis()); // dynamic HA Targets select republish } } From dd9caff7eb3a4e34133ec08c4c5d0def1bc9e012 Mon Sep 17 00:00:00 2001 From: ltloopy Date: Tue, 7 Jul 2026 09:20:33 -0500 Subject: [PATCH 3/4] docs: Timer app documentation New docs/timer.md (quick start, Home Assistant entities + read-only config attributes, multi-device sync, persistence); Timer sections in apps.md and api.md (full command/key reference); dev.json keys in dev.md; TIMER menu row + label scrolling in onscreen.md; sidebar links. Co-Authored-By: Claude Fable 5 --- docs/api.md | 32 ++++----- docs/dev.md | 22 +++---- docs/onscreen.md | 2 +- docs/timer.md | 166 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 28 deletions(-) diff --git a/docs/api.md b/docs/api.md index 25ee56ad..d858a370 100644 --- a/docs/api.md +++ b/docs/api.md @@ -386,21 +386,21 @@ All JSON properties are optional. When multiple are sent together, property sett | `finished_hold` | integer | 1–300 (s) | Auto-clear delay (only meaningful when `finished = "auto-clear"`). Persists. | | `realert_interval` | integer | 5–300 (s) | Re-alert cadence (only meaningful when `finished = "re-alert"`). Persists. | | `countdown_seconds` | integer | 0–30 (s) | Pre-expiry beep window (only meaningful when `buzzer = "countdown"`). Persists. | -| `max_duration` | integer | 1–604800 (s, 1 s .. 7 days) | Upper bound on accepted `duration` (ADR-0004). Persists. | -| `remaining_publish_interval` | integer | 1–60 (s) | How often `timer_rem` republishes while Running (ADR-0004). Persists. | -| `melody_tick` | string | **Either** a bare name resolved against `/MELODIES/.txt` (≤32 chars) **or** an inline RTTTL tune | RTTTL countdown-beep melody (ADR-0004). A bare name persists and obeys `save`; an **inline tune is always one-shot** (never saved, ADR-0017). | -| `melody_end` | string | Same. A bare empty string resets to default `"timer_end"`. | RTTTL end melody. Bare name persists & obeys `save`; inline tune always one-shot (ADR-0004 / ADR-0017). | -| `bar_enabled` | bool | `true` / `false` | Show/hide the Running/Paused progress bar (ADR-0004). Persists. | -| `icon_enabled` | bool | `true` / `false` | Show/hide the timer icon; when hidden the time text and bar reflow to span the full panel (ADR-0005). Persists. | -| `bar_color` | int or hex string | `0..0xFFFFFF` or `"#RRGGBB"` / `"RRGGBB"` | Progress-bar (foreground) color; `0` follows `TEXTCOLOR_888` (ADR-0004). Persists. | -| `bar_bg_color` | int or hex string | `0..0xFFFFFF` or `"#RRGGBB"` / `"RRGGBB"` | Progress-bar **background track** color (drawn behind the bar); `0` = black = no track (the default — bar looks unchanged). Unlike `bar_color`'s `0`, this is a literal black, not a sentinel (ADR-0020). Persists. | -| `sync_follow` | bool | `true` / `false` | Obey inbound multi-device timer-sync this clock is targeted by (follow consent gate, ADR-0006). Local identity — not propagated. Persists. | -| `sync_targets` | string | `""` / `all` / comma list of peer `uniqueID`s | Whom this clock commands on a local timer action (ADR-0006). Local identity — not propagated. Persists. | -| `save` | bool | `true` (default) / `false` | `save:false` makes the **whole command one-shot**: its config applies to the current run only and reverts to the saved settings when the timer next returns to Idle (a `reset` or auto-clear), writing nothing to flash. A non-boolean is rejected (atomic-reject). See the one-shot note below and ADR-0017. | +| `max_duration` | integer | 1–604800 (s, 1 s .. 7 days) | Upper bound on accepted `duration`. Persists. | +| `remaining_publish_interval` | integer | 1–60 (s) | How often `timer_rem` republishes while Running. Persists. | +| `melody_tick` | string | **Either** a bare name resolved against `/MELODIES/.txt` (≤32 chars) **or** an inline RTTTL tune | RTTTL countdown-beep melody. A bare name persists and obeys `save`; an **inline tune is always one-shot** (never saved). | +| `melody_end` | string | Same. A bare empty string resets to default `"timer_end"`. | RTTTL end melody. Bare name persists & obeys `save`; inline tune always one-shot. | +| `bar_enabled` | bool | `true` / `false` | Show/hide the Running/Paused progress bar. Persists. | +| `icon_enabled` | bool | `true` / `false` | Show/hide the timer icon; when hidden the time text and bar reflow to span the full panel. Persists. | +| `bar_color` | int or hex string | `0..0xFFFFFF` or `"#RRGGBB"` / `"RRGGBB"` | Progress-bar (foreground) color; `0` follows `TEXTCOLOR_888`. Persists. | +| `bar_bg_color` | int or hex string | `0..0xFFFFFF` or `"#RRGGBB"` / `"RRGGBB"` | Progress-bar **background track** color (drawn behind the bar); `0` = black = no track (the default — bar looks unchanged). Unlike `bar_color`'s `0`, this is a literal black, not a sentinel. Persists. | +| `sync_follow` | bool | `true` / `false` | Obey inbound multi-device timer-sync this clock is targeted by (follow consent gate). Local identity — not propagated. Persists. | +| `sync_targets` | string | `""` / `all` / comma list of peer `uniqueID`s | Whom this clock commands on a local timer action. Local identity — not propagated. Persists. | +| `save` | bool | `true` (default) / `false` | `save:false` makes the **whole command one-shot**: its config applies to the current run only and reverts to the saved settings when the timer next returns to Idle (a `reset` or auto-clear), writing nothing to flash. A non-boolean is rejected (atomic-reject). See the one-shot note below. | `action` / `buzzer` / `finished` values are case-insensitive; `auto-clear`/`autoclear` and `re-alert`/`realert` are both accepted. Icon and **bare** melody values are **case-sensitive** (they map to filenames on LittleFS) and **capped at 32 characters** (alphanumeric, `_`, `-`). The icon loader checks `/ICONS/.jpg` then `/ICONS/.gif`; the melody loader reads `/MELODIES/.txt`. -**One-shot commands (`save:false`) and inline melodies** (ADR-0017). By default every config key persists to flash and becomes your new default. Send `save:false` to run a timer **once** with custom parameters: its config applies to the current run only and reverts when the timer next returns to Idle, writing nothing to flash. While a one-shot run is active the **observation surface stays honest** — the `GET /api/timer` `config` mirror, the Home Assistant attribute entities, and device-to-device sync all keep reporting your **saved** configuration, never the one-off values (the top-level `duration`/`buzzer`/`finished` still show the live values). `melody_end`/`melody_tick` additionally accept an **inline RTTTL tune** (a literal melody string such as `"alarm:d=4,o=5,b=120:c,e,g"`, detected by its `:`-separated `d=`/`o=`/`b=` structure; a malformed tune is rejected `400`); an inline tune is **always one-shot** regardless of `save`, never persists, and **never appears** in the `config` mirror (which shows the saved bare name throughout). The on-device TIMER menu always persists. +**One-shot commands (`save:false`) and inline melodies**. By default every config key persists to flash and becomes your new default. Send `save:false` to run a timer **once** with custom parameters: its config applies to the current run only and reverts when the timer next returns to Idle, writing nothing to flash. While a one-shot run is active the **observation surface stays honest** — the `GET /api/timer` `config` mirror, the Home Assistant attribute entities, and device-to-device sync all keep reporting your **saved** configuration, never the one-off values (the top-level `duration`/`buzzer`/`finished` still show the live values). `melody_end`/`melody_tick` additionally accept an **inline RTTTL tune** (a literal melody string such as `"alarm:d=4,o=5,b=120:c,e,g"`, detected by its `:`-separated `d=`/`o=`/`b=` structure; a malformed tune is rejected `400`); an inline tune is **always one-shot** regardless of `save`, never persists, and **never appears** in the `config` mirror (which shows the saved bare name throughout). The on-device TIMER menu always persists. #### Responses @@ -413,7 +413,7 @@ All JSON properties are optional. When multiple are sent together, property sett | `400 Bad Request` | `InvalidValue` | A field has an unusable value: malformed/out-of-range `duration`, or an unknown `buzzer`/`finished`/`action`/icon. | | `409 Conflict` | `TimerDisabled` | The Timer feature is off (`SHOW_TIMER=false`); the command is understood but can't apply. | -Out-of-range durations are **rejected**, not clamped (e.g. `"30:00:00"` when the max is 24 h → `400`). The `{prefix}/timer` MQTT topic applies the same validation but, being fire-and-forget, ignores invalid commands silently (no error is published); the retained `timer_dur` state reflects the unchanged value. See [ADR 0001](adr/0001-timer-command-validation-parity.md). +Out-of-range durations are **rejected**, not clamped (e.g. `"30:00:00"` when the max is 24 h → `400`). The `{prefix}/timer` MQTT topic applies the same validation but, being fire-and-forget, ignores invalid commands silently (no error is published); the retained `timer_dur` state reflects the unchanged value. #### Examples @@ -503,12 +503,12 @@ The `buzzer` / `finished` strings are the **canonical output spellings** (hyphen The `config` object reports **every persisted Timer configuration key** — exactly the keys you can write via `POST /api/timer` — so an HTTP-only integrator can read the device's live configuration (and confirm a write applied) without Home Assistant or an MQTT subscription. It is projected from the same descriptor tables that drive the control surface and the HA attribute groups, so the read surface cannot drift from what is writable: adding a new persisted config key automatically makes it readable here. -While a **one-shot** run (`save:false`, see above / ADR-0017) is active, `config` reports your **saved** configuration, never the one-off values — so a read-after-write check sees the saved truth, not the transient run-only override. The top-level `duration`/`buzzer`/`finished` continue to show the live one-shot values. An inline RTTTL tune never appears here; `config.melody_*` shows the saved bare name throughout. +While a **one-shot** run (`save:false`, see above) is active, `config` reports your **saved** configuration, never the one-off values — so a read-after-write check sees the saved truth, not the transient run-only override. The top-level `duration`/`buzzer`/`finished` continue to show the live one-shot values. An inline RTTTL tune never appears here; `config.melody_*` shows the saved bare name throughout. Values are **raw** by default (interval seconds as integers, melodies and `sync_targets` as strings, toggles as booleans), with two deliberate carrier-native renderings that follow this endpoint's own raw+`_str` duration precedent: - **`bar_color`** is rendered as `"#RRGGBB"` (uppercase) or `"default"` when it follows the text color, rather than a raw 24-bit integer. -- **`bar_bg_color`** is rendered as `"#RRGGBB"` (uppercase) or `"none"` when it is black (no track), rather than a raw integer — the `"none"` vs `"default"` wording reflects the deliberate sentinel asymmetry between the two colors (ADR-0020). +- **`bar_bg_color`** is rendered as `"#RRGGBB"` (uppercase) or `"none"` when it is black (no track), rather than a raw integer — the `"none"` vs `"default"` wording reflects the deliberate sentinel asymmetry between the two colors. - **`max_duration`** is reported **both** raw (`86400`) **and** as `max_duration_str` (`"24:00:00"`, trimmed clock form). | `config` key | Type | Notes | @@ -531,7 +531,7 @@ Values are **raw** by default (interval seconds as integers, melodies and `sync_ | `finished` | string | Finished mode — mirrored here for completeness (also top-level). | | `icon_idle` / `icon_running` / `icon_paused` / `icon_finished` | string | The four per-state icon names (empty string = falls back to the Idle icon). | -`config` never contains `duration` (run-state, reported top-level only) or `action` (a transient command verb, not persisted). The endpoint stays a pure observation read regardless: always `200 OK`, never mutating, no `409`. See [ADR 0015](adr/0015-http-observation-mirrors-full-config.md). +`config` never contains `duration` (run-state, reported top-level only) or `action` (a transient command verb, not persisted). The endpoint stays a pure observation read regardless: always `200 OK`, never mutating, no `409`. > **Note on freshness vs. Home Assistant:** `remaining` here is computed at the moment of the request, so during `running` it can read a second or two lower than the HA `{id}_timer_rem` sensor, which is push-throttled to `remaining_publish_interval` (default 1 s). This is expected — the polled HTTP read is simply fresher than the throttled push sensor; the two are not in disagreement. diff --git a/docs/dev.md b/docs/dev.md index 9c608f1f..2d1ecd8d 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -40,9 +40,9 @@ The JSON object has the following properties: | `new_year` | boolean | Displays fireworks and plays a jingle at newyear. | false | | `swap_buttons` | boolean | Swaps the left and right hardware button. | false | | `ldr_on_ground` | boolean | Sets the LDR configuration to LDR-on-ground. | false | -| `show_timer` | boolean | Master enable for the [Timer app](https://blueforcer.github.io/awtrix3/#/apps?id=timer). When `false`: app removed from rotation, 8 HA entities not published, `POST /api/timer` and the `{prefix}/timer` MQTT topic ignored, any running timer reset. On the `true → false` transition the firmware publishes empty retained discovery payloads so HA prunes the stale entities. Also exposed via `/api/settings` (`TIMER` key) and the on-device **APPS** menu. | `true` | -| `timer_max_duration` | integer | Upper bound on accepted `duration` values (range 1–604800). Out-of-range duration commands are rejected, not clamped (ADR-0001). Also caps the HH/MM/SS wheels in the Timer-app config mode. Promoted to `{prefix}/timer` / `/api/timer` per ADR-0004. | `86400` | -| `timer_remaining_publish_interval` | integer | Seconds between `timer_rem` republishes while Running (range 1–60). Drives the HA `{id}_timer_rem` sensor cadence. Promoted per ADR-0004. **Renamed from `timer_publish_interval` on this branch.** | `1` | +| `show_timer` | boolean | Master enable for the [Timer app](https://blueforcer.github.io/awtrix3/#/apps?id=timer). When `false`: app removed from rotation, the 10 Timer HA entities not published, `POST /api/timer` and the `{prefix}/timer` MQTT topic ignored, any running timer reset. On the `true → false` transition the firmware publishes empty retained discovery payloads so HA prunes the stale entities. Also exposed via `/api/settings` (`TIMER` key) and the on-device **APPS** menu. | `true` | +| `timer_max_duration` | integer | Upper bound on accepted `duration` values (range 1–604800). Out-of-range duration commands are rejected, not clamped. Also caps the HH/MM/SS wheels in the Timer-app config mode. Promoted to `{prefix}/timer` / `/api/timer`. | `86400` | +| `timer_remaining_publish_interval` | integer | Seconds between `timer_rem` republishes while Running (range 1–60). Drives the HA `{id}_timer_rem` sensor cadence. | `1` | | `timer_finished_hold` | integer | Seconds the `00:00` Finished screen stays visible in Auto-clear finished mode (range 1–300). | `10` | | `timer_realert_interval` | integer | Seconds between buzzer re-fires in Re-alert finished mode (range 5–300). | `15` | | `timer_countdown_seconds` | integer | Tick window (seconds before zero) in Countdown buzzer mode (range 0–30). | `3` | @@ -50,14 +50,14 @@ The JSON object has the following properties: | `timer_icon_running` | string | Same, for the Running state. Empty falls back to `timer_icon_idle`. | `""` | | `timer_icon_paused` | string | Same, for the Paused state. Empty falls back to `timer_icon_idle`. | `""` | | `timer_icon_finished` | string | Same, for the Finished (blinking `0:00`) state. Empty falls back to `timer_icon_idle`. | `""` | -| `timer_melody_tick` | string | Bare melody name (resolved against `/MELODIES/.txt`) for countdown beeps. Empty resets to default `"timer_tick"`. Per ADR-0004. | `"timer_tick"` | -| `timer_melody_end` | string | Bare melody name for the end melody. Empty resets to default `"timer_end"`. Per ADR-0004. | `"timer_end"` | -| `timer_bar_enabled` | boolean | Show/hide the Running/Paused progress bar. Per ADR-0004. | `true` | -| `timer_icon_enabled` | boolean | Show/hide the timer icon; when hidden the time text and bar reflow to the full panel. Per ADR-0005. | `true` | -| `timer_bar_color` | integer | Hex color (0..0xFFFFFF) for the progress bar (foreground). `0` follows `TEXTCOLOR_888`. Per ADR-0004. | `0` | -| `timer_bar_bg_color` | integer | Hex color (0..0xFFFFFF) for the progress-bar **background track** drawn behind the bar. `0` = black = no track (literal, not a sentinel). Per ADR-0020. | `0` | -| `timer_sync_follow` | boolean | When `true`, this clock obeys inbound timer-sync packets it is targeted by (multi-device sync follow consent gate). Local identity — never propagated. Per ADR-0006. | `true` | -| `timer_sync_targets` | string | Whom this clock commands on a local timer action: `""` (sync off), `all`, or a comma list of peer device IDs (e.g. `awtrix_ab12,awtrix_cd34`). Local identity — never propagated. Per ADR-0006. | `""` | +| `timer_melody_tick` | string | Bare melody name (resolved against `/MELODIES/.txt`) for countdown beeps. Empty resets to default `"timer_tick"`. | `"timer_tick"` | +| `timer_melody_end` | string | Bare melody name for the end melody. Empty resets to default `"timer_end"`. | `"timer_end"` | +| `timer_bar_enabled` | boolean | Show/hide the Running/Paused progress bar. | `true` | +| `timer_icon_enabled` | boolean | Show/hide the timer icon; when hidden the time text and bar reflow to the full panel. | `true` | +| `timer_bar_color` | integer | Hex color (0..0xFFFFFF) for the progress bar (foreground). `0` follows `TEXTCOLOR_888`. | `0` | +| `timer_bar_bg_color` | integer | Hex color (0..0xFFFFFF) for the progress-bar **background track** drawn behind the bar. `0` = black = no track (literal, not a sentinel). | `0` | +| `timer_sync_follow` | boolean | When `true`, this clock obeys inbound timer-sync packets it is targeted by (multi-device sync follow consent gate). Local identity — never propagated. | `true` | +| `timer_sync_targets` | string | Whom this clock commands on a local timer action: `""` (sync off), `all`, or a comma list of peer device IDs (e.g. `awtrix_ab12,awtrix_cd34`). Local identity — never propagated. | `""` | #### Example: diff --git a/docs/onscreen.md b/docs/onscreen.md index c6d8ac50..187b7821 100644 --- a/docs/onscreen.md +++ b/docs/onscreen.md @@ -20,7 +20,7 @@ Any menu label too wide for the 32px panel **scrolls**: it holds briefly at the | `DATE` | Allows selection of date format. | | `WEEKDAY` | Allows selection of start of week. | | `TEMP` | Allows selection of temperature system (°C or °F). | -| `TIMER` | Configure the Timer app. A **drill-in list** consistent with the rest of the menu: left/right walks the named items (and **wraps**), a short press drills into the highlighted item's editor, and a long press steps back up one level. You can open it from the main menu or by long-pressing the middle button from the Timer app while it is idle; a long press out of the list returns you to wherever you came from (the Timer app, or the main menu), while `MAIN` always returns to the main menu. The list, in order: `DURATION` (the HH:MM:SS timer length), `BUZZER` (Off/End/Countdown), `COUNTDOWN` (countdown beep window, 0–30 s), `FINISH` (`CLEAR`/`HOLD`/`RE-ALERT`), `CLEAR DELAY` (auto-clear delay, 1–300 s), `RE-ALERT INTERVAL` (re-alert cadence, 5–300 s), `ICON` (show/hide the timer icon, ADR-0005), `PROGRESS BAR` (show/hide the progress bar), `MAIN` (back to the main menu). `DURATION` is the same HH:MM:SS wheel as before: a short press cycles the hour/minute/second field, left/right adjusts the active field (hold to repeat), and a long press saves the duration and returns to the list. While a timer is **running or paused** the `DURATION` leaf is read-only (it shows the current value, no field underline, and any press returns to the list). For the other items only the **bare value** is shown (e.g. `END`, `30`, `ON`): left/right cycles the enum, adjusts the number (step 5 for `CLEAR DELAY`/`RE-ALERT INTERVAL`, step 1 for `COUNTDOWN` — see [ADR 0003](adr/0003-timer-tuning-knobs-on-device.md) for these magnitudes and why), or toggles the boolean (`ICON`/`PROGRESS BAR` — both buttons flip it); a short press confirms and returns to the list, and a long press saves and returns to the list. Mode changes (`BUZZER`/`FINISH`) apply and publish to MQTT/HA immediately as you scroll; the duration commits on its own when you leave the `DURATION` leaf; all other edits persist together when you leave the list (long-press out of the list, or select `MAIN`). See [ADR 0016](adr/0016-timer-menu-drillin-navigation.md). | +| `TIMER` | Configure the [Timer app](apps.md#timer) via a drill-in list: left/right walks the items (wrapping), a short press opens the highlighted item's editor, a long press steps back up. Also reachable by long-pressing the middle button from an idle Timer app (a long press out of the list then returns you there). Items: `DURATION` (HH:MM:SS wheel — short press cycles the field, left/right adjusts with hold-to-repeat, long press saves; read-only while the timer runs), `BUZZER` (Off/End/Countdown), `COUNTDOWN` (beep window, 0–30 s), `FINISH` (`CLEAR`/`HOLD`/`RE-ALERT`), `CLEAR DELAY` (1–300 s), `RE-ALERT INTERVAL` (5–300 s), `ICON` and `PROGRESS BAR` (show/hide toggles), `MAIN` (back to the main menu). Mode changes apply immediately as you scroll; everything persists when you leave the list. | | `APPS` | Allows to enable or disable internal apps | | `SOUND` | Allows to enable or disable sound output | | `UPDATE` | Check and download new firmware if available. | diff --git a/docs/timer.md b/docs/timer.md index e69de29b..32f4e40e 100644 --- a/docs/timer.md +++ b/docs/timer.md @@ -0,0 +1,166 @@ +# Timer + +AWTRIX 3 has a built-in countdown **Timer app**: set a duration, start it, and the +clock counts down with a progress bar, finishing with a melody and a blinking +`0:00` screen. It is controllable four ways, all driving the same validated +command surface: + +- **On device** — buttons inside the Timer app, plus the `TIMER` item in the + [onscreen menu](onscreen.md). +- **HTTP** — `POST /api/timer` (see the [API reference](api.md#timer-control)). +- **MQTT** — the `{prefix}/timer` command topic (same JSON as HTTP). +- **Home Assistant** — auto-discovered entities (below). + +The app's display, states, physical buttons and buzzer/finished modes are +documented in [Apps](apps.md#timer). The full command/key reference with +validation rules lives in the [API docs](api.md#timer-control); the `dev.json` +boot overrides in [dev.md](dev.md). This page covers the quick start, the Home +Assistant surface, and multi-device sync. + +The Timer ships enabled; disable it entirely with `show_timer` in `dev.json` or +the `TIMER` key on `/api/settings` (this removes the app, unpublishes the HA +entities and ignores timer commands). + +## Quick start + +Start a 10-minute timer: + +```bash +curl -X POST http://[IP]/api/timer -H "Content-Type: application/json" \ + -d '{"duration":"10:00","action":"start"}' +``` + +or publish the same JSON to `{prefix}/timer`. Pause, resume and reset: + +```json +{"action":"pause"} +{"action":"start"} +{"action":"reset"} +``` + +Durations accept `"HH:MM:SS"`, `"MM:SS"` or bare seconds (`600`). Colon count +decides the units (`"3:00"` is 3 minutes), out-of-range fields carry +(`"3:90"` = 270 s). Malformed or out-of-range input is **rejected** +(HTTP `400`; MQTT silently ignores it) — never clamped, and an invalid command +changes nothing at all (atomic reject). + +Retained state topics `{prefix}/stats/timer_state`, `…/timer_dur` and +`…/timer_rem` report the lifecycle state (`idle`/`running`/`paused`/`finished`), +the configured duration as a clock string, and the seconds remaining (published +every `remaining_publish_interval` seconds while running). + +### One-shot runs + +Add `save:false` to run a timer once with custom settings — the config applies +to that run only, writes nothing to flash, and reverts to your saved settings +when the timer returns to Idle: + +```json +{"duration":"0:30","buzzer":"end","melody_end":"beep:d=16,o=6,b=180:c,c,c","action":"start","save":false} +``` + +`melody_end`/`melody_tick` accept either a bare melody name (a file under +`/MELODIES/`) or an inline RTTTL tune as above; an inline tune is always +one-shot. While a one-shot run is active, every observation surface (the +`GET /api/timer` config mirror, the HA attributes, peer sync) keeps reporting +your **saved** configuration. Details in the [API docs](api.md#timer-control). + +## Home Assistant entities + +With `HA_DISCOVERY=true` the firmware advertises ten Timer entities: + +| Entity | Type | Purpose | +| --- | --- | --- | +| `{id}_timer_dur` | `text` | Duration as a clock string (writable; accepts `MM:SS`/bare seconds too). Invalid input reverts to the previous value. | +| `{id}_timer_rem` | `sensor` | Seconds remaining (read-only; updates every `remaining_publish_interval` s while running). | +| `{id}_timer_state` | `sensor` | `idle` / `running` / `paused` / `finished`. | +| `{id}_timer_buz` | `select` | Buzzer mode (`Off` / `End` / `Countdown`). | +| `{id}_timer_fin` | `select` | Finished mode (`Clear` / `Hold` / `Re-alert`). | +| `{id}_timer_start` | `button` | Equivalent to `{"action":"start"}`. | +| `{id}_timer_pause` | `button` | Equivalent to `{"action":"pause"}`. | +| `{id}_timer_reset` | `button` | Equivalent to `{"action":"reset"}`. | +| `{id}_timer_sync_follow` | `switch` | Multi-device sync receive consent (`sync_follow`), writable. | +| `{id}_timer_sync_targets` | `select` | Multi-device sync send targeting (`sync_targets`), writable: `Off`, `All`, plus one option per **discovered peer clock** (the list updates as peers appear/disappear). A multi-ID CSV set over HTTP/MQTT shows as unknown; the state sensor's `sync_targets` attribute stays authoritative. | + +When a timer is started from Idle the display auto-switches to the Timer app +(unless a game or a navigation-blocking app is active). + +### Read-only configuration attributes + +Each carrier entity also exposes the persisted settings it is semantically +about as a **read-only JSON attribute object**, so the device's configuration +is readable from inside HA without an MQTT/HTTP query: + +| Carrier | Attributes | +| --- | --- | +| `{id}_timer_dur` | `max_duration` (clock form, e.g. `"24:00:00"`) | +| `{id}_timer_rem` | `remaining_publish_interval` | +| `{id}_timer_buz` | `countdown_seconds`, `melody_tick`, `melody_end` | +| `{id}_timer_fin` | `realert_interval`, `finished_hold` | +| `{id}_timer_state` | the full config: `max_duration` (raw seconds), `remaining_publish_interval`, `icon_enabled`, `bar_enabled`, `bar_color` (`"default"` or `"#RRGGBB"`), `bar_bg_color` (`"none"` or `"#RRGGBB"`), `sync_follow`, `sync_targets` | + +The attribute objects are retained and republished after every config change +and MQTT (re)connect. During a one-shot run they keep reporting the saved +configuration, never the transient one-off values. + +## Multi-device sync + +Two or more AWTRIX clocks on the same LAN can mirror each other's timer: when +one starts, pauses or resets, the action propagates to a chosen set of peers. +Sync is **broker-free** — it rides UDP broadcast on port **4212**, so it works +without MQTT. + +### Roles: two independent settings + +| Setting | Axis | Meaning | +| --- | --- | --- | +| `sync_targets` | send | Whom this clock commands on a *local* action: `""` (send nothing, the default), `all`, or a comma list of peer `uniqueID`s. | +| `sync_follow` | receive | Whether this clock obeys inbound sync it is targeted by (default **on**; turn off to make a clock ignore peers). | + +Compose them freely: a **leader** (targets set, follow off) commands but never +obeys; a **follower** (follow on, no targets) obeys but never commands; +set every clock to `sync_targets=all` + `sync_follow=true` for "everyone +mirrors everyone". Out of the box nothing propagates — sync only activates +once you set `sync_targets` on at least one clock. + +### What propagates + +- **Run-state:** `start` / `pause` / `reset` propagate the action. A `start` + also carries the duration (the only duration-bearing packet) — so a bare + duration edit on one clock never moves a peer's countdown. +- **Config:** travels **only** bundled inside a `start`, as the leader's + effective settings for that run. The follower applies it **one-shot** and + reverts to its own saved settings when its timer returns to Idle — a peer's + `start` can never overwrite a clock's saved configuration. +- **Never propagated:** `sync_follow` / `sync_targets` themselves (each + clock's own identity) and the live remaining seconds. + +### Behavior notes + +- Targeting is by the stable `uniqueID` (shown in the web UI); `all` spares + you from listing ids. +- One-hop: an applied inbound command is never re-broadcast, so partial + target lists do not chain. +- Delivery is best-effort: each packet is sent 3× and receivers de-duplicate, + so a single dropped frame rarely desyncs a clock. +- Clocks announce themselves with a presence beacon every 30 s; that is what + populates the HA Targets select's dynamic peer list. +- A clock with `show_timer=false` ignores inbound sync. + +Set the two keys via `POST /api/timer` / `{prefix}/timer` +(`sync_follow`, `sync_targets`), via `dev.json` +(`timer_sync_follow`, `timer_sync_targets`), or via the two HA entities. + +## Settings and persistence + +All timer settings persist across reboots. The behaviour-tuning knobs +(`max_duration`, `remaining_publish_interval`, `finished_hold`, +`realert_interval`, `countdown_seconds`, `melody_tick`, `melody_end`, +`bar_enabled`, `icon_enabled`, `bar_color`, `bar_bg_color`, `sync_follow`, +`sync_targets`) are settable over HTTP/MQTT ([reference](api.md#timer-control)) +and readable back via `GET /api/timer` and the HA attributes; a subset is also +editable in the on-device `TIMER` menu. `dev.json` can override any of them at +boot ([reference](dev.md)). Per-state icons (`icon_idle` / `icon_running` / +`icon_paused` / `icon_finished`) resolve against `/ICONS/.{jpg,gif}`; +the two melodies against `/MELODIES/.txt`, with built-in default tunes +when the files are absent. From 35aa0a0a38624fb51482955a2954b3509899e594 Mon Sep 17 00:00:00 2001 From: ltloopy Date: Wed, 8 Jul 2026 23:05:52 -0500 Subject: [PATCH 4/4] feat: AWTRIX_DISABLE_TIMER compile-time opt-out for the Timer feature Building with -DAWTRIX_DISABLE_TIMER excludes the entire Timer stack (app, on-device menu, MQTT/HTTP API, HA entities, multi-device sync): ~29.6 kB flash and ~3.2 kB RAM reclaimed (ulanzi: 99.2% -> 96.9% flash; awtrix2_upgrade: 99.0% -> 96.7%). Default builds are unchanged. Guards follow the existing #ifndef awtrix2_upgrade convention. Co-Authored-By: Claude Fable 5 --- docs/timer.md | 17 ++++++++++++++++ src/Apps.cpp | 4 ++++ src/Apps.h | 2 ++ src/DisplayManager.cpp | 10 ++++++++++ src/Globals.cpp | 8 ++++++++ src/MQTTManager.cpp | 23 ++++++++++++++++++++- src/MQTTManager.h | 6 ++++++ src/MenuManager.cpp | 40 +++++++++++++++++++++++++++++++++++++ src/MenuManager.h | 2 ++ src/PeerRegistry.cpp | 2 ++ src/PeripheryManager.cpp | 6 ++++++ src/ServerManager.cpp | 12 +++++++++++ src/ServerManager.h | 2 ++ src/SyncEnvelope.cpp | 2 ++ src/SyncSeenCache.cpp | 2 ++ src/SyncTargetsDebounce.cpp | 2 ++ src/TimerCommand.cpp | 2 ++ src/TimerConfigEditor.cpp | 2 ++ src/TimerEnums.cpp | 2 ++ src/TimerHa.cpp | 2 ++ src/TimerHaHost.cpp | 2 ++ src/TimerManager.cpp | 2 ++ src/TimerMenu.cpp | 2 ++ src/TimerMenuNav.cpp | 2 ++ src/TimerRuntime.cpp | 2 ++ src/TimerSettings.cpp | 2 ++ src/TimerSettingsApply.cpp | 2 ++ src/TimerView.cpp | 2 ++ src/main.cpp | 12 +++++++++++ 29 files changed, 175 insertions(+), 1 deletion(-) diff --git a/docs/timer.md b/docs/timer.md index 32f4e40e..5a1b7015 100644 --- a/docs/timer.md +++ b/docs/timer.md @@ -164,3 +164,20 @@ boot ([reference](dev.md)). Per-state icons (`icon_idle` / `icon_running` / `icon_paused` / `icon_finished`) resolve against `/ICONS/.{jpg,gif}`; the two melodies against `/MELODIES/.txt`, with built-in default tunes when the files are absent. + +## Build-time opt-out + +The Timer feature is included by default. To build a firmware without it — +reclaiming roughly 29 kB of flash and 3 kB of RAM on space-constrained +devices — add `-DAWTRIX_DISABLE_TIMER` to `build_flags` in `platformio.ini`, +or set `PLATFORMIO_BUILD_FLAGS=-DAWTRIX_DISABLE_TIMER` when invoking +`pio run`. A disabled build has no Timer app, no `TIMER` on-device menu +entry, no `/api/timer` HTTP endpoint, no `{prefix}/timer` MQTT topic, no +Home Assistant timer entities, and no multi-device sync. The runtime +`SHOW_TIMER` setting is only meaningful in default builds. + +Note when switching an already-provisioned device from a timer-enabled build +to a disabled one: the disabled firmware also lacks the HA discovery cleanup +code, so timer entities registered by the previous build linger as +"unavailable" in Home Assistant until you remove them there (or clear the +retained `homeassistant/…` discovery topics on the broker). diff --git a/src/Apps.cpp b/src/Apps.cpp index f2b8fc06..8cfb4aac 100644 --- a/src/Apps.cpp +++ b/src/Apps.cpp @@ -11,8 +11,10 @@ #include "MQTTManager.h" #include "Overlays.h" #include "timer.h" +#ifndef AWTRIX_DISABLE_TIMER #include "TimerManager.h" #include "TimerView.h" +#endif #include "Globals.h" #include "DisplayManager.h" @@ -417,6 +419,7 @@ void BatApp(FastLED_NeoMatrix *matrix, MatrixDisplayUiState *state, int16_t x, i } #endif +#ifndef AWTRIX_DISABLE_TIMER namespace { // Painter-side layout (font-/draw-dependent). The view-model owns the rest // of the timer geometry (text region, progress bar) — see src/TimerView.cpp. @@ -547,6 +550,7 @@ void TimerApp(FastLED_NeoMatrix *matrix, MatrixDisplayUiState *state, int16_t x, matrix->drawFastHLine(view.barStartX + x, (kTimerScreenH - 1) + y, view.barLen, TIMER_BAR_COLOR ? TIMER_BAR_COLOR : TEXTCOLOR_888); } +#endif // AWTRIX_DISABLE_TIMER String replacePlaceholders(String text) { diff --git a/src/Apps.h b/src/Apps.h index 2e9c33d8..5b370a87 100644 --- a/src/Apps.h +++ b/src/Apps.h @@ -85,7 +85,9 @@ void HumApp(FastLED_NeoMatrix *matrix, MatrixDisplayUiState *state, int16_t x, i void BatApp(FastLED_NeoMatrix *matrix, MatrixDisplayUiState *state, int16_t x, int16_t y, GifPlayer *gifPlayer); #endif +#ifndef AWTRIX_DISABLE_TIMER void TimerApp(FastLED_NeoMatrix *matrix, MatrixDisplayUiState *state, int16_t x, int16_t y, GifPlayer *gifPlayer); +#endif void ShowCustomApp(String name, FastLED_NeoMatrix *matrix, MatrixDisplayUiState *state, int16_t x, int16_t y, GifPlayer *gifPlayer); diff --git a/src/DisplayManager.cpp b/src/DisplayManager.cpp index 55682958..f6b51bd1 100644 --- a/src/DisplayManager.cpp +++ b/src/DisplayManager.cpp @@ -5,7 +5,9 @@ #include "Globals.h" #include "PeripheryManager.h" #include "MQTTManager.h" +#ifndef AWTRIX_DISABLE_TIMER #include "TimerHaHost.h" +#endif #include "GifPlayer.h" #include #include "timer.h" @@ -23,7 +25,9 @@ #include #include "base64.hpp" #include "Games/GameManager.h" +#ifndef AWTRIX_DISABLE_TIMER #include "TimerManager.h" +#endif unsigned long lastArtnetStatusTime = 0; const int numberOfChannels = 256 * 3; @@ -1115,7 +1119,9 @@ void DisplayManager_::loadNativeApps() #ifdef ULANZI updateApp("Battery", BatApp, SHOW_BAT, 4); #endif +#ifndef AWTRIX_DISABLE_TIMER updateApp("Timer", TimerApp, SHOW_TIMER, Apps.size()); +#endif ui->setApps(Apps); setAutoTransition(true); @@ -2132,7 +2138,9 @@ void DisplayManager_::setNewSettings(const char *json) UPPERCASE_LETTERS = doc.containsKey("UPPERCASE") ? doc["UPPERCASE"].as() : UPPERCASE_LETTERS; SHOW_WEEKDAY = doc.containsKey("WD") ? doc["WD"].as() : SHOW_WEEKDAY; BLOCK_NAVIGATION = doc.containsKey("BLOCKN") ? doc["BLOCKN"].as() : BLOCK_NAVIGATION; +#ifndef AWTRIX_DISABLE_TIMER bool prevShowTimer = SHOW_TIMER; +#endif SHOW_TIME = doc.containsKey("TIM") ? doc["TIM"].as() : SHOW_TIME; SHOW_DATE = doc.containsKey("DAT") ? doc["DAT"].as() : SHOW_DATE; SHOW_HUM = doc.containsKey("HUM") ? doc["HUM"].as() : SHOW_HUM; @@ -2257,6 +2265,7 @@ void DisplayManager_::setNewSettings(const char *json) } doc.clear(); applyAllSettings(); +#ifndef AWTRIX_DISABLE_TIMER TimerManager.onShowTimerChange(prevShowTimer, SHOW_TIMER); if (prevShowTimer && !SHOW_TIMER) { @@ -2270,6 +2279,7 @@ void DisplayManager_::setNewSettings(const char *json) { SHOW_TIMER_HA_PREV = SHOW_TIMER; } +#endif loadNativeApps(); saveSettings(); if (DEBUG_MODE) diff --git a/src/Globals.cpp b/src/Globals.cpp index f3581934..9de60b82 100644 --- a/src/Globals.cpp +++ b/src/Globals.cpp @@ -1,6 +1,8 @@ #include "Globals.h" #include "Preferences.h" +#ifndef AWTRIX_DISABLE_TIMER #include "TimerSettings.h" +#endif #include #include #include @@ -212,10 +214,12 @@ void loadDevSettings() SHOW_TIMER = doc["show_timer"].as(); } +#ifndef AWTRIX_DISABLE_TIMER // Timer value-config keys: validated + applied per-key best-effort from the // single TIMER_SETTINGS_DESCS table (ranges live there, once). dev.json is a // boot override layer, so an invalid key is skipped, not atomic-rejected. timerSettingsLoadDevJson(doc.as()); +#endif // Timer state icons stay member-backed (B1); their dev.json shadows // seed the TimerManager members at setup(). @@ -304,7 +308,9 @@ void loadSettings() SHOW_TIMER = Settings.getBool("TIMER", true); SHOW_TIMER_HA_PREV = Settings.getBool("TIMERPREV", true); Settings.remove("TSTEP"); // removed timer_step feature; clean orphaned NVS key +#ifndef AWTRIX_DISABLE_TIMER timerSettingsLoadNvs(Settings); // value-config table keys (TFHOLD..TSYNT); defaults live in TIMER_SETTINGS_DESCS +#endif MATRIX_LAYOUT = Settings.getUInt("MAT", 0); SCROLL_SPEED = Settings.getUInt("SSPEED", 100); #ifdef ULANZI @@ -357,7 +363,9 @@ void saveSettings() Settings.putBool("HUM", SHOW_HUM); Settings.putBool("TIMER", SHOW_TIMER); Settings.putBool("TIMERPREV", SHOW_TIMER_HA_PREV); +#ifndef AWTRIX_DISABLE_TIMER timerSettingsSaveNvs(Settings); // value-config table keys (TFHOLD..TSYNT) +#endif Settings.putUInt("SSPEED", SCROLL_SPEED); #ifdef ULANZI Settings.putBool("BAT", SHOW_BAT); diff --git a/src/MQTTManager.cpp b/src/MQTTManager.cpp index 2cd85a51..3e887c76 100644 --- a/src/MQTTManager.cpp +++ b/src/MQTTManager.cpp @@ -9,9 +9,11 @@ #include "PeripheryManager.h" #include "UpdateManager.h" #include "PowerManager.h" +#ifndef AWTRIX_DISABLE_TIMER #include "TimerManager.h" #include "TimerHa.h" #include "TimerHaHost.h" +#endif const uint16_t PORT = 1883; @@ -104,6 +106,7 @@ void processMqttMessage(const String &strTopic, const String &payloadCopy) return; } + #ifndef AWTRIX_DISABLE_TIMER { size_t plen = MQTT_PREFIX.length(); const char *t = strTopic.c_str(); @@ -115,6 +118,7 @@ void processMqttMessage(const String &strTopic, const String &payloadCopy) return; } } + #endif if (strTopic.equals(MQTT_PREFIX + "/sendscreen")) { @@ -254,7 +258,9 @@ void processMqttMessage(const String &strTopic, const String &payloadCopy) void onButtonCommand(HAButton *sender) { + #ifndef AWTRIX_DISABLE_TIMER if (TimerHaHost.tryHandleButton(sender)) return; // Timer carrier: routed by the host + #endif if (sender == dismiss) { DisplayManager.dismissNotify(); @@ -278,7 +284,9 @@ void onButtonCommand(HAButton *sender) void onSwitchCommand(bool state, HASwitch *sender) { + #ifndef AWTRIX_DISABLE_TIMER if (TimerHaHost.tryHandleSwitch(state, sender)) return; // Timer carrier: routed by the host + #endif AUTO_TRANSITION = state; DisplayManager.setAutoTransition(state); saveSettings(); @@ -287,7 +295,9 @@ void onSwitchCommand(bool state, HASwitch *sender) void onSelectCommand(int8_t index, HASelect *sender) { + #ifndef AWTRIX_DISABLE_TIMER if (TimerHaHost.tryHandleSelect(sender, index)) return; // Timer carrier: routed by the host + #endif if (sender == BriMode) { switch (index) @@ -416,6 +426,7 @@ void onMqttConnected() if (DEBUG_MODE) DEBUG_PRINTLN(F("MQTT Connected")); + #ifndef AWTRIX_DISABLE_TIMER // Command topics must never carry a retained payload. A retained // {prefix}/timer command (e.g. {"action":"start"}) is re-delivered by the // broker on every (re)connect and would auto-start the timer on boot, which @@ -423,6 +434,7 @@ void onMqttConnected() // PubSubClient doesn't surface the retain flag to the receive callback, so we // purge the retained command at the source: clear it before subscribing. mqtt.publish((MQTT_PREFIX + "/timer").c_str(), "", true); + #endif const char *topics[] PROGMEM = { "/brightness", @@ -449,7 +461,10 @@ void onMqttConnected() "/rtttl", "/sendscreen", "/r2d2", - "/timer"}; + #ifndef AWTRIX_DISABLE_TIMER + "/timer", + #endif + }; for (const char *topic : topics) { if (DEBUG_MODE) @@ -471,9 +486,11 @@ void onMqttConnected() myOwnID->setValue(MQTT_PREFIX.c_str()); version->setValue(VERSION); + #ifndef AWTRIX_DISABLE_TIMER // onConnected() also flushes the pending discovery cleanup reconcile() latched // when SHOW_TIMER went off across a reboot. TimerHaHost.onConnected(); // Timer wire + attribute groups when SHOW_TIMER + #endif } MQTTManager.publish("stats/effects", DisplayManager.getEffectNames().c_str()); @@ -780,11 +797,13 @@ void MQTTManager_::setup() ipAddr->setName(HAipAddrName); ipAddr->setIcon(HAipAddrIcon); + #ifndef AWTRIX_DISABLE_TIMER // Resolve the Timer carrier ids and (when SHOW_TIMER) construct/register the // carriers — at the same sequence point as before (after the device's own // entities register), so the ArduinoHA registration/entity-cap drop order is // unchanged. The carrier lifecycle now lives in TimerHaHost. TimerHaHost.setup(); + #endif } else { @@ -809,6 +828,7 @@ void MQTTManager_::tick() } } +#ifndef AWTRIX_DISABLE_TIMER // The Timer wire seam: publishes the exact (topic, payload) the // caller hands over — retained, like the HASensor::setValue path it replaces. // Gated on the Timer HA carriers existing (TimerHaHost.carriersReady() mirrors the @@ -855,6 +875,7 @@ String MQTTManager_::timerIconsTopic() { return MQTT_PREFIX + "/timer/icons"; } +#endif // AWTRIX_DISABLE_TIMER void MQTTManager_::publish(const char *topic, const char *payload) { diff --git a/src/MQTTManager.h b/src/MQTTManager.h index e96b837d..4c0cc15a 100644 --- a/src/MQTTManager.h +++ b/src/MQTTManager.h @@ -4,7 +4,9 @@ #include #include +#ifndef AWTRIX_DISABLE_TIMER #include "TimerHa.h" +#endif // HA entity registration cap. ArduinoHA's HAMqtt::addDeviceType has an off-by-one // (`_devicesTypesNb + 1 >= _maxDevicesTypesNb`), so the EFFECTIVE capacity is @@ -40,6 +42,7 @@ class MQTTManager_ bool isConnected(); String getValueForTopic(const String &topic); + #ifndef AWTRIX_DISABLE_TIMER // The Timer wire seam: the single chokepoint through // which Timer MQTT output flows as (topic, payload) strings, published // retained (like the HA setValue path it replaces). timerWireTopic() sources @@ -48,9 +51,12 @@ class MQTTManager_ String timerWireTopic(TimerHaEntity slot); String timerWireAttrTopic(TimerHaEntity slot); String timerIconsTopic(); + #endif }; +#ifndef AWTRIX_DISABLE_TIMER void reconcileTimerHAState(); +#endif extern MQTTManager_ &MQTTManager; diff --git a/src/MenuManager.cpp b/src/MenuManager.cpp index cf7be066..23910182 100644 --- a/src/MenuManager.cpp +++ b/src/MenuManager.cpp @@ -5,12 +5,16 @@ #include #include #include "timer.h" +#ifndef AWTRIX_DISABLE_TIMER #include "TimerManager.h" #include "TimerMenu.h" #include "TimerMenuNav.h" #include "TimerConfigEditor.h" +#endif #include "MQTTManager.h" +#ifndef AWTRIX_DISABLE_TIMER #include "TimerHaHost.h" +#endif #include #include #include "Functions.h" // getTextWidth (centering the duration leaf + its underline) @@ -27,7 +31,9 @@ enum MenuState DateFormatMenu, WeekdayMenu, TempMenu, +#ifndef AWTRIX_DISABLE_TIMER TimerConfigMenu, +#endif Appmenu, SoundMenu, VolumeMenu, @@ -45,7 +51,9 @@ const char *menuItems[] PROGMEM = { "DATE", "WEEKDAY", "TEMP", +#ifndef AWTRIX_DISABLE_TIMER "TIMER", +#endif "APPS", "SOUND", "VOLUME", @@ -82,12 +90,21 @@ int8_t dateFormatIndex; uint8_t dateFormatCount = 9; int8_t appsIndex; +#ifndef AWTRIX_DISABLE_TIMER #ifndef awtrix2_upgrade uint8_t appsCount = 6; #else uint8_t appsCount = 5; #endif +#else +#ifndef awtrix2_upgrade +uint8_t appsCount = 5; +#else +uint8_t appsCount = 4; +#endif +#endif +#ifndef AWTRIX_DISABLE_TIMER uint8_t timerConfigCount = TIMER_MENU_SLOT_COUNT; // The TIMER menu's drill-in navigation state machine. MAIN // is the last slot (a Navigation row); the device keeps only drawing + the commit. @@ -119,6 +136,7 @@ static void commitTimerMenu() } TimerManager.publishAllAttributeGroups(); } +#endif MenuState currentState = MainMenu; @@ -237,12 +255,16 @@ String MenuManager_::menutext() case 4: DisplayManager.drawBMP(0, 0, icon_1486, 8, 8); return SHOW_BAT ? "ON" : "OFF"; +#endif +#ifndef AWTRIX_DISABLE_TIMER +#ifndef awtrix2_upgrade case 5: #else case 4: #endif DisplayManager.drawBMP(0, 0, icon_timer, 8, 8); return SHOW_TIMER ? "ON" : "OFF"; +#endif default: break; } @@ -256,6 +278,7 @@ String MenuManager_::menutext() { return String(SOUND_VOLUME); } +#ifndef AWTRIX_DISABLE_TIMER case TimerConfigMenu: // List focus: walk the named items (indicator over the list). Leaf focus: // show the bare value only, no indicator. @@ -293,6 +316,7 @@ String MenuManager_::menutext() return timerMenuValue(timerNav.index()); // read-only: current value } return timerMenuValue(timerNav.index()); +#endif default: break; } @@ -354,6 +378,7 @@ void MenuManager_::rightButton() else SOUND_VOLUME++; break; +#ifndef AWTRIX_DISABLE_TIMER case TimerConfigMenu: { // List focus walks the list; a value leaf steps its value live; the @@ -365,6 +390,7 @@ void MenuManager_::rightButton() timerDurationEditor.adjust(+1); break; } +#endif default: break; } @@ -427,6 +453,7 @@ void MenuManager_::leftButton() else SOUND_VOLUME--; break; +#ifndef AWTRIX_DISABLE_TIMER case TimerConfigMenu: { TimerNavOutcome o = timerNav.navigate(-1); @@ -436,6 +463,7 @@ void MenuManager_::leftButton() timerDurationEditor.adjust(-1); break; } +#endif default: break; } @@ -470,11 +498,13 @@ void MenuManager_::selectButton() UpdateManager.updateFirmware(); } break; +#ifndef AWTRIX_DISABLE_TIMER case TimerConfigMenu: // Open the TIMER menu at the top of the list (origin = main menu). timerNav.enter(TIMER_MENU_SLOT_COUNT, TIMER_MENU_SLOT_COUNT - 1, TimerNavOrigin::Menu); break; +#endif } break; case BrightnessMenu: @@ -485,6 +515,7 @@ void MenuManager_::selectButton() DisplayManager.setBrightness(BRIGHTNESS); } break; +#ifndef AWTRIX_DISABLE_TIMER case TimerConfigMenu: // Short press. if (timerNav.focus() == TimerNavFocus::List) @@ -508,6 +539,7 @@ void MenuManager_::selectButton() timerDurationEditor.cycleField(); } break; +#endif case Appmenu: switch (appsIndex) { @@ -527,6 +559,9 @@ void MenuManager_::selectButton() case 4: SHOW_BAT = !SHOW_BAT; break; +#endif +#ifndef AWTRIX_DISABLE_TIMER +#ifndef awtrix2_upgrade case 5: #else case 4: @@ -541,6 +576,7 @@ void MenuManager_::selectButton() TimerHaHost.enable(); break; } +#endif default: break; } @@ -604,6 +640,7 @@ void MenuManager_::selectButtonLong() PeripheryManager.setVolume(SOUND_VOLUME); saveSettings(); break; +#ifndef AWTRIX_DISABLE_TIMER case TimerConfigMenu: { // Long press: in a value/read-only leaf it just steps back up to the @@ -633,6 +670,7 @@ void MenuManager_::selectButtonLong() } break; // GoToMainMenu: falls through to MainMenu } +#endif default: break; } @@ -644,6 +682,7 @@ void MenuManager_::selectButtonLong() } } +#ifndef AWTRIX_DISABLE_TIMER void MenuManager_::openTimerMenuFromApp() { // Open the TIMER menu directly at the top of the list, origin = App so a @@ -652,3 +691,4 @@ void MenuManager_::openTimerMenuFromApp() currentState = TimerConfigMenu; timerNav.enter(TIMER_MENU_SLOT_COUNT, TIMER_MENU_SLOT_COUNT - 1, TimerNavOrigin::App); } +#endif diff --git a/src/MenuManager.h b/src/MenuManager.h index f95fd4da..1c3b04a0 100644 --- a/src/MenuManager.h +++ b/src/MenuManager.h @@ -18,10 +18,12 @@ class MenuManager_ void selectButton(); void selectButtonLong(); +#ifndef AWTRIX_DISABLE_TIMER // Open the TIMER menu directly from the Timer app's idle long-press: // list focus, first item, origin = App so a long-press out of the // list returns to the Timer app rather than the main menu. void openTimerMenuFromApp(); +#endif }; extern MenuManager_ &MenuManager; diff --git a/src/PeerRegistry.cpp b/src/PeerRegistry.cpp index 7ce761d3..ea364c13 100644 --- a/src/PeerRegistry.cpp +++ b/src/PeerRegistry.cpp @@ -1,3 +1,4 @@ +#ifndef AWTRIX_DISABLE_TIMER #include "PeerRegistry.h" // A bounded set of {uniqueID, lastSeen} learned from inbound presence beacons; @@ -83,3 +84,4 @@ void PeerRegistry::clear() for (uint8_t i = 0; i < _count; ++i) _peers[i].uniqueID = String(); _count = 0; } +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/PeripheryManager.cpp b/src/PeripheryManager.cpp index f813de0c..697d223b 100644 --- a/src/PeripheryManager.cpp +++ b/src/PeripheryManager.cpp @@ -18,8 +18,10 @@ #include #include #include +#ifndef AWTRIX_DISABLE_TIMER #include "TimerManager.h" #include "TimerCommand.h" // TimerCommand::Action for the runStateAction seam +#endif const int buzzerPin = 2; // Buzzer an GPIO2 const int baudRate = 50; // Nachrichtenübertragungsrate const char *message = "HELLO"; // Die Nachricht, die gesendet werden soll @@ -176,6 +178,7 @@ void select_button_pressed() if (DFPLAYER_ACTIVE) PeripheryManager.playFromFile(DFMINI_MP3_CLICK); +#ifndef AWTRIX_DISABLE_TIMER if (!MenuManager.inMenu) { TimerState ts = TimerManager.getState(); @@ -191,6 +194,7 @@ void select_button_pressed() return; } } +#endif DisplayManager.selectButton(); MenuManager.selectButton(); @@ -224,6 +228,7 @@ void select_button_pressed_long() } else if (!BLOCK_NAVIGATION) { +#ifndef AWTRIX_DISABLE_TIMER if (!MenuManager.inMenu) { TimerState ts = TimerManager.getState(); @@ -245,6 +250,7 @@ void select_button_pressed_long() return; } } +#endif MenuManager.selectButtonLong(); DisplayManager.selectButtonLong(); if (DEBUG_MODE) diff --git a/src/ServerManager.cpp b/src/ServerManager.cpp index ea9f84fa..24d45af3 100644 --- a/src/ServerManager.cpp +++ b/src/ServerManager.cpp @@ -14,7 +14,9 @@ #include #include #include "Games/GameManager.h" +#ifndef AWTRIX_DISABLE_TIMER #include "TimerManager.h" +#endif #include WiFiUDP udp; @@ -25,9 +27,11 @@ char incomingPacket[255]; // Propagation surface: dedicated UDP socket for device-to-device timer sync. A // separate port (and buffer) from discovery so a full config snapshot fits and the // FIND_AWTRIX traffic is never parsed as JSON. +#ifndef AWTRIX_DISABLE_TIMER WiFiUDP syncUdp; const uint16_t kTimerSyncPort = 4212; char syncBuffer[1024]; +#endif // Pufferdefinition #define BUFFER_SIZE 64 @@ -124,6 +128,7 @@ void addHandler() }else{ mws.webserver->send(500, F("text/plain"), F("ErrorParsingJson")); } }); +#ifndef AWTRIX_DISABLE_TIMER mws.addHandler("/api/timer", HTTP_POST, []() { switch (TimerManager.parseCommand(mws.webserver->arg("plain").c_str())) @@ -137,6 +142,7 @@ void addHandler() // Observation surface (read-only): always 200; `enabled` carries SHOW_TIMER. mws.addHandler("/api/timer", HTTP_GET, []() { mws.webserver->send(200, F("application/json"), TimerManager.getStateJson().c_str()); }); +#endif mws.addHandler("/api/nextapp", HTTP_ANY, []() { DisplayManager.nextApp(); mws.webserver->send(200,F("text/plain"),F("OK")); }); mws.addHandler("/fullscreen", HTTP_GET, []() @@ -272,7 +278,9 @@ void ServerManager_::setup() mws.addHandler("/save", HTTP_POST, saveHandler); addHandler(); udp.begin(localUdpPort); +#ifndef AWTRIX_DISABLE_TIMER syncUdp.begin(kTimerSyncPort); +#endif if (DEBUG_MODE) DEBUG_PRINTLN(F("Webserver loaded")); } @@ -332,6 +340,7 @@ void ServerManager_::tick() } } +#ifndef AWTRIX_DISABLE_TIMER // Propagation surface: inbound timer-sync packets (echo/follow/target/dedup // gating happens inside applySyncCommand). int syncSize = syncUdp.parsePacket(); @@ -344,6 +353,7 @@ void ServerManager_::tick() TimerManager.applySyncCommand(syncBuffer); } } +#endif } if (!currentClient || !currentClient.connected()) { @@ -384,6 +394,7 @@ void ServerManager_::sendTCP(String message) } } +#ifndef AWTRIX_DISABLE_TIMER void ServerManager_::sendTimerSync(const String &payload) { if (AP_MODE) return; @@ -399,6 +410,7 @@ void ServerManager_::sendTimerSync(const String &payload) if (i < 2) delay(15); } } +#endif void ServerManager_::loadSettings() { diff --git a/src/ServerManager.h b/src/ServerManager.h index 8cac304d..ee448d6a 100644 --- a/src/ServerManager.h +++ b/src/ServerManager.h @@ -16,9 +16,11 @@ class ServerManager_ void sendButton(byte btn, bool state); void erase(); void sendTCP(String message); +#ifndef AWTRIX_DISABLE_TIMER // Broadcast a timer-sync packet on the LAN (propagation surface). Sent 3x for // best-effort delivery; receivers dedup by (src,seq). void sendTimerSync(const String &payload); +#endif bool isConnected; IPAddress myIP; }; diff --git a/src/SyncEnvelope.cpp b/src/SyncEnvelope.cpp index 6e4dbfe0..13d2be4f 100644 --- a/src/SyncEnvelope.cpp +++ b/src/SyncEnvelope.cpp @@ -1,3 +1,4 @@ +#ifndef AWTRIX_DISABLE_TIMER #include "SyncEnvelope.h" // The Timer-sync wire envelope + pure inbound receive gate. See SyncEnvelope.h. @@ -77,3 +78,4 @@ namespace SyncEnvelope } } } +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/SyncSeenCache.cpp b/src/SyncSeenCache.cpp index 91dd453c..9e770a69 100644 --- a/src/SyncSeenCache.cpp +++ b/src/SyncSeenCache.cpp @@ -1,3 +1,4 @@ +#ifndef AWTRIX_DISABLE_TIMER #include "SyncSeenCache.h" // A bounded (src,seq,atMs) ring learned from inbound sync commands; the backend @@ -35,3 +36,4 @@ void SyncSeenCache::clear() } _writeIdx = 0; } +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/SyncTargetsDebounce.cpp b/src/SyncTargetsDebounce.cpp index 17bbc3d2..1bf1bda5 100644 --- a/src/SyncTargetsDebounce.cpp +++ b/src/SyncTargetsDebounce.cpp @@ -1,3 +1,4 @@ +#ifndef AWTRIX_DISABLE_TIMER #include "SyncTargetsDebounce.h" // The settle-window state machine for the dynamic Targets select's republish @@ -40,3 +41,4 @@ void SyncTargetsDebounce::seed(const char *opts) _sig = opts; _dirty = false; } +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/TimerCommand.cpp b/src/TimerCommand.cpp index 3d958788..56e1be50 100644 --- a/src/TimerCommand.cpp +++ b/src/TimerCommand.cpp @@ -1,3 +1,4 @@ +#ifndef AWTRIX_DISABLE_TIMER #include "TimerCommand.h" #include // strcmp @@ -124,3 +125,4 @@ namespace TimerCommand return p; } } +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/TimerConfigEditor.cpp b/src/TimerConfigEditor.cpp index 1fde656d..4ec92417 100644 --- a/src/TimerConfigEditor.cpp +++ b/src/TimerConfigEditor.cpp @@ -1,3 +1,4 @@ +#ifndef AWTRIX_DISABLE_TIMER #include "TimerConfigEditor.h" #include "Globals.h" // TIMER_MAX_DURATION (cap math) @@ -94,3 +95,4 @@ void TimerConfigEditor::repeatHeld(bool pressed, unsigned long nowMs, repeatMs = nowMs; } } +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/TimerEnums.cpp b/src/TimerEnums.cpp index 90764ecd..185d9be5 100644 --- a/src/TimerEnums.cpp +++ b/src/TimerEnums.cpp @@ -1,3 +1,4 @@ +#ifndef AWTRIX_DISABLE_TIMER #include "TimerEnums.h" // One row per enum value, in enum-value order so the array index IS the enum @@ -56,3 +57,4 @@ bool timerEnumParse(const TimerEnumCodec *table, size_t count, } return false; } +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/TimerHa.cpp b/src/TimerHa.cpp index 1272a4ee..de9819b3 100644 --- a/src/TimerHa.cpp +++ b/src/TimerHa.cpp @@ -1,3 +1,4 @@ +#ifndef AWTRIX_DISABLE_TIMER #include "TimerHa.h" #include @@ -159,3 +160,4 @@ bool haRegistrationAtCap(uint8_t registered, uint8_t maxEntities) // maxEntities of 255 from wrapping. See the contract in TimerHa.h. return (uint16_t)registered + 1 >= maxEntities; } +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/TimerHaHost.cpp b/src/TimerHaHost.cpp index 09bbb2c5..249e94f9 100644 --- a/src/TimerHaHost.cpp +++ b/src/TimerHaHost.cpp @@ -1,3 +1,4 @@ +#ifndef AWTRIX_DISABLE_TIMER #include "TimerHaHost.h" #include #include @@ -434,3 +435,4 @@ bool TimerHaHost_::carriersReady() const { return timerDuration != nullptr; } const char *TimerHaHost_::entityId(TimerHaEntity slot) const { return timerHaId(slot); } TimerHaHost_ TimerHaHost; +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/TimerManager.cpp b/src/TimerManager.cpp index ef2f3e3e..523f2c28 100644 --- a/src/TimerManager.cpp +++ b/src/TimerManager.cpp @@ -1,3 +1,4 @@ +#ifndef AWTRIX_DISABLE_TIMER #include "TimerManager.h" #include "TimerSettings.h" #include "TimerCommand.h" // the pure atomic-reject command plan (classify) @@ -939,3 +940,4 @@ void TimerManager_::tickPresence(unsigned long nowMs) _presenceEverSent = true; } } +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/TimerMenu.cpp b/src/TimerMenu.cpp index c55f7ba2..f5d63c50 100644 --- a/src/TimerMenu.cpp +++ b/src/TimerMenu.cpp @@ -1,3 +1,4 @@ +#ifndef AWTRIX_DISABLE_TIMER #include "TimerMenu.h" #include "TimerManager.h" @@ -123,3 +124,4 @@ TimerNavLeaf timerMenuLeafKind(uint8_t slot, TimerState st) ? TimerNavLeaf::DurationEditable : TimerNavLeaf::DurationReadOnly; } +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/TimerMenuNav.cpp b/src/TimerMenuNav.cpp index ce0e7d2c..b4a92913 100644 --- a/src/TimerMenuNav.cpp +++ b/src/TimerMenuNav.cpp @@ -1,3 +1,4 @@ +#ifndef AWTRIX_DISABLE_TIMER #include "TimerMenuNav.h" // Display-free TIMER-menu navigation state machine. @@ -97,3 +98,4 @@ TimerNavOutcome TimerMenuNav::back() ? TimerNavOutcome::ExitMenu : TimerNavOutcome::GoToMainMenu; } +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/TimerRuntime.cpp b/src/TimerRuntime.cpp index 25c28519..4ea22e59 100644 --- a/src/TimerRuntime.cpp +++ b/src/TimerRuntime.cpp @@ -1,3 +1,4 @@ +#ifndef AWTRIX_DISABLE_TIMER #include "TimerRuntime.h" namespace TimerRuntime @@ -179,3 +180,4 @@ namespace TimerRuntime return r; } } +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/TimerSettings.cpp b/src/TimerSettings.cpp index 0e66ffa1..0eacb1a6 100644 --- a/src/TimerSettings.cpp +++ b/src/TimerSettings.cpp @@ -1,3 +1,4 @@ +#ifndef AWTRIX_DISABLE_TIMER #include "TimerSettings.h" #include @@ -588,3 +589,4 @@ const size_t TIMER_MEMBER_VALIDATOR_COUNT = static_assert(sizeof(TIMER_MEMBER_VALIDATORS) / sizeof(TIMER_MEMBER_VALIDATORS[0]) == TIMER_MEMBER_CONFIG_DESC_CAP, "TIMER_MEMBER_CONFIG_DESC_CAP must equal the member-config row count"); +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/TimerSettingsApply.cpp b/src/TimerSettingsApply.cpp index 4176149d..ae9fe729 100644 --- a/src/TimerSettingsApply.cpp +++ b/src/TimerSettingsApply.cpp @@ -1,3 +1,4 @@ +#ifndef AWTRIX_DISABLE_TIMER #include "TimerSettings.h" #include @@ -175,3 +176,4 @@ void timerBuildFullConfig(JsonDocument &doc) // Member-backed half: buzzer/finished + the four icon_* live values. timerMemberConfigBuildSnapshot(doc); } +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/TimerView.cpp b/src/TimerView.cpp index a14dbf0b..d6f387a5 100644 --- a/src/TimerView.cpp +++ b/src/TimerView.cpp @@ -1,3 +1,4 @@ +#ifndef AWTRIX_DISABLE_TIMER #include "TimerView.h" #include "TimerSettings.h" // timerClock / ClockStyle @@ -87,3 +88,4 @@ TimerView TimerViewModel::compute(const TimerSnapshot &s) return v; } +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/main.cpp b/src/main.cpp index de9aa1d6..181170fa 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -35,12 +35,16 @@ #include "DisplayManager.h" #include "PeripheryManager.h" #include "MQTTManager.h" +#ifndef AWTRIX_DISABLE_TIMER #include "TimerHaHost.h" +#endif #include "ServerManager.h" #include "Globals.h" #include "UpdateManager.h" #include "timer.h" +#ifndef AWTRIX_DISABLE_TIMER #include "TimerManager.h" +#endif TaskHandle_t taskHandle; volatile bool StopTask = false; @@ -71,7 +75,9 @@ void setup() PeripheryManager.setup(); ServerManager.loadSettings(); DisplayManager.setup(); +#ifndef AWTRIX_DISABLE_TIMER TimerManager.setup(); +#endif DisplayManager.HSVtext(9, 6, VERSION, true, 0); delay(500); xTaskCreatePinnedToCore(BootAnimation, "Task", 10000, NULL, 1, &taskHandle, 0); @@ -103,7 +109,9 @@ void setup() if (MQTT_HOST != "") { DisplayManager.HSVtext(4, 6, "MQTT...", true, 0); +#ifndef AWTRIX_DISABLE_TIMER TimerHaHost.reconcile(); +#endif MQTTManager.setup(); MQTTManager.tick(); } @@ -123,12 +131,16 @@ void loop() timer_tick(); ServerManager.tick(); DisplayManager.tick(); +#ifndef AWTRIX_DISABLE_TIMER TimerManager.tick(); TimerManager.tickPresence(millis()); // peer presence beacon + registry aging +#endif PeripheryManager.tick(); if (ServerManager.isConnected) { MQTTManager.tick(); +#ifndef AWTRIX_DISABLE_TIMER TimerHaHost.refreshTargets(millis()); // dynamic HA Targets select republish +#endif } }