From ce9cf8496fbad9596df048b73d5703fd7f76c68e Mon Sep 17 00:00:00 2001 From: Neuroquila Date: Sat, 18 Jul 2026 21:08:02 +0200 Subject: [PATCH 01/10] Redesign Home Assistant MQTT discovery --- Changelog.md | 16 + Home Assistant/input_values.yaml | 62 ---- Home Assistant/mqtt.yaml | 124 ------- Home Assistant/scripts.yaml | 44 --- Home Assistant/sensors.yaml | 340 ------------------- README.md | 21 +- assets/Configuration.md | 36 ++ data/frontend/mqtt.html | 98 +++++- data/ha_binarysensors.json | 84 ----- data/ha_numbers.json | 45 --- data/ha_sensors.json | 88 ----- gzip-data.py | 56 +-- include/configuration.h | 8 +- include/ha_autodiscovery.h | 22 +- include/webconfig.h | 4 +- platformio.ini | 1 + src/configuration.cpp | 30 +- src/ha_autodiscovery.cpp | 566 +++++++++++++++++++++++-------- src/mqtt.cpp | 116 +++---- src/webconfig.cpp | 112 +++++- 20 files changed, 820 insertions(+), 1053 deletions(-) delete mode 100644 Home Assistant/input_values.yaml delete mode 100644 Home Assistant/mqtt.yaml delete mode 100644 Home Assistant/scripts.yaml delete mode 100644 Home Assistant/sensors.yaml delete mode 100644 data/ha_binarysensors.json delete mode 100644 data/ha_numbers.json delete mode 100644 data/ha_sensors.json diff --git a/Changelog.md b/Changelog.md index eacb641..ee1a47c 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,21 @@ # Changelog +## Unreleased + +- Replaced the incomplete legacy Home Assistant integration with current MQTT device discovery using one retained device payload. +- Removed manual Home Assistant YAML and filesystem discovery-template files. +- Added discovery for heating, hot-water, controller-status, and dynamic auxiliary-temperature entities under one device. +- Added MQTT number controls for requested feed temperature, boost duration, and room-reference temperature. +- Added MQTT switches for heating enablement, boost, and fast heatup. +- Added retained online/offline availability through MQTT Last Will and automatic rediscovery after Home Assistant restarts. +- Added Home Assistant configuration to the web interface and automatic MQTT reconnection after saving it. +- Added cleanup of retained discovery records when Home Assistant discovery is disabled or its device identity changes. +- Added meaningful Home Assistant icons for every discovered entity and changed the burner flame entity to explicit on/off semantics with a flame icon. +- Changed the Home Assistant device manufacturer and discovery origin branding from JunkersControl to Cerasmarter. +- Added Home Assistant diagnostic entities for heap memory, filesystem and flash storage, chip model and revision, CPU cores, CPU frequency, and auxiliary-sensor connectivity. +- Fixed MQTT command handling, including the previously unreachable hot-water parameter handler and unsafe callback payload termination. +- Prevented stale generated filesystem files from leaking into release images and made preprocessing failures stop the build. + ## v0.93.4 - Pinned release and development builds to the tested pioarduino ESP32 platform, and upgraded all GitHub Actions workflows to Node.js 24-compatible action versions. diff --git a/Home Assistant/input_values.yaml b/Home Assistant/input_values.yaml deleted file mode 100644 index d648266..0000000 --- a/Home Assistant/input_values.yaml +++ /dev/null @@ -1,62 +0,0 @@ -input_number: - heizung_fusspunkt: - name: "Fußpunkt Heizung" - min: -30 - max: 30 - step: 0.5 - mode: box - unit_of_measurement: "°C" - icon: mdi:ray-start - heizung_endpunkt: - name: "Endpunkt Heizung" - min: 0 - max: 75 - step: 0.5 - mode: box - unit_of_measurement: "°C" - icon: mdi:ray-end - heizung_minimum: - name: "Minimale Vorlauftemperatur Heizung" - min: 0 - max: 75 - step: 0.5 - mode: box - unit_of_measurement: "°C" - icon: mdi:thermometer-low - heizung_boostdauer: - name: "Dauer der Boost-Funktion" - min: 0 - max: 3600 - mode: box - unit_of_measurement: "s" - icon: mdi:timer-sand - heizung_zieltemperatur: - name: "Ziel Raumtemperatur" - min: 0 - max: 35 - step: 0.5 - mode: box - unit_of_measurement: "°C" - icon: mdi:thermometer-high - heizung_adaption: - name: "Vorlauf Adaption" - min: -30 - max: 30 - step: 0.1 - mode: box - unit_of_measurement: "°C" - icon: mdi:thermometer-lines - heizung_ventilskalierung_max: - name: "Ventilöffnung Max" - min: 0 - max: 100 - mode: box - unit_of_measurement: "%" - icon: mdi:arrow-collapse-right - heizung_gewichtung_ventile: - name: "Gewichtung Ventilöffnung EG-OG" - min: 0 - max: 100 - mode: slider - unit_of_measurement: "%" - icon: mdi:scale \ No newline at end of file diff --git a/Home Assistant/mqtt.yaml b/Home Assistant/mqtt.yaml deleted file mode 100644 index 312f53e..0000000 --- a/Home Assistant/mqtt.yaml +++ /dev/null @@ -1,124 +0,0 @@ -mqtt: - broker: 1.2.3.4 - port: 1883 - username: mqtt - password: mqttpass - sensor: - - name: "Cerasmarter Errorlog Message" - unique_id: "CerasmarterErrorLog" - state_topic: "cerasmarter/log" - value_template: >- - {% set value = value_json.lvl | int %} - {% if value == 0 %} - {% set errLvl = "Error" %} - {% elif value == 1 %} - {% set errLvl = "Warning" %} - {% elif value == 2 %} - {% set errLvl = "Info" %} - {% elif value == 3 %} - {% set errLvl = "Debug" %} - {% elif value == 4 %} - {% set errLvl = "Verbose" %} - {% endif %} - [{{ errLvl }}][{{value_json.fnc}}]:{{value_json.msg}} - ### Heating Temperatures 'cerasmarter/heating/parameters' - - name: "Aktuelle Vorlauftemperatur Heizung" - unique_id: "HeatingInformation_Temperatures_FeedCurrent" - state_topic: "cerasmarter/heating/values" - unit_of_measurement: "°C" - value_template: "{{ value_json.FeedCurrent }}" - - name: "Maximale Vorlauftemperatur Heizung" - unique_id: "HeatingInformation_Temperatures_FeedMaximum" - state_topic: "cerasmarter/heating/values" - unit_of_measurement: "°C" - value_template: "{{ value_json.FeedMaximum }}" - - name: "Soll-Vorlauftemperatur Heizung" - unique_id: "HeatingInformation_Temperatures_FeedSetpoint" - state_topic: "cerasmarter/heating/values" - unit_of_measurement: "°C" - value_template: "{{ value_json.FeedSetpoint }}" - - name: "Außentemperaturfühler Heizung" - unique_id: "HeatingInformation_Temperatures_Outside" - state_topic: "cerasmarter/heating/values" - unit_of_measurement: "°C" - value_template: "{{ value_json.Outside }}" - - name: "Heizung Boost" - unique_id: "HeatingInformation_Status_Boost" - state_topic: "cerasmarter/heating/values" - value_template: "{{ value_json.Boost }}" - force_update: true - - name: "Heizung Boost Restdauer" - unique_id: "HeatingInformation_Status_BoostRemaining" - state_topic: "cerasmarter/heating/values" - value_template: "{{ value_json.BoostTimeLeft }}" - unit_of_measurement: "Sekunden" - force_update: true - - name: "Heizung Heizbetrieb" - unique_id: "HeatingInformation_Status_Working" - state_topic: "cerasmarter/heating/values" - value_template: "{{ value_json.Working }}" - force_update: true - - name: "Heizung Schnellaufheizung" - unique_id: "HeatingInformation_Status_FastHeatup" - state_topic: "cerasmarter/heating/values" - value_template: "{{ value_json.FastHeatup }}" - force_update: true - - name: "Heizung Pumpe" - unique_id: HeatingInformation_Status_Pump - state_topic: "cerasmarter/heating/values" - value_template: "{{ value_json.Pump }}" - force_update: true - - name: "Heizung Saisonaler Betriebsmodus" - unique_id: "HeatingInformation_Status_Season" - state_topic: "cerasmarter/heating/values" - value_template: "{{ value_json.Season }}" - force_update: true - ### Auxiliary Sensors 'cerasmarter/auxiliary/parameters' - - name: "Externe Vorlauftemperatur" - unique_id: "HeatingInformation_AuxiliaryTemperatures_Feed" - state_topic: "cerasmarter/auxiliary/values" - unit_of_measurement: "°C" - value_template: "{{ value_json.Feed.Temperature }}" - - name: "Externe Rücklauftemperatur" - unique_id: "HeatingInformation_AuxiliaryTemperatures_Return" - state_topic: "cerasmarter/auxiliary/values" - unit_of_measurement: "°C" - value_template: "{{ value_json.Return.Temperature }}" - - name: "Externe Abgastemperatur" - unique_id: "HeatingInformation_AuxiliaryTemperatures_Exhaust" - state_topic: "cerasmarter/auxiliary/values" - unit_of_measurement: "°C" - value_template: "{{ value_json.Exhaust.Temperature }}" - - name: "Externe Umgebungstemperatur" - unique_id: "HeatingInformation_AuxiliaryTemperatures_Ambient" - state_topic: "cerasmarter/auxiliary/values" - unit_of_measurement: "°C" - value_template: "{{ value_json.Ambient.Temperature }}" - ### Auxiliary Sensors 'cerasmarter/auxiliary/parameters' - - name: "Externe Vorlauftemperatur Status" - unique_id: "HeatingInformation_AuxiliaryTemperatures_FeedReachable" - state_topic: "cerasmarter/auxiliary/values" - value_template: "{{ iif(value_json.Feed.Reachable == 'true', 'yes', 'no', 'unknown') }}" - - name: "Externe Rücklauftemperatur Status" - unique_id: "HeatingInformation_AuxiliaryTemperatures_ReturnReachable" - state_topic: "cerasmarter/auxiliary/values" - value_template: "{{ iif(value_json.Return.Reachable == 'true', 'yes', 'no', 'unknown') }}" - - name: "Externe Abgastemperatur Status" - unique_id: "HeatingInformation_AuxiliaryTemperatures_ExhaustReachable" - state_topic: "cerasmarter/auxiliary/values" - value_template: "{{ iif(value_json.Exhaust.Reachable == 'true', 'yes', 'no', 'unknown') }}" - - name: "Externe Umgebungstemperatur Status" - unique_id: "HeatingInformation_AuxiliaryTemperatures_AmbientReachable" - state_topic: "cerasmarter/auxiliary/values" - value_template: "{{ iif(value_json.Ambient.Reachable == 'true', 'yes', 'no', 'unknown') }}" - ### Status 'cerasmarter/status' - - name: "Heizung Brenner" - unique_id: "HeatingInformation_Status_GasBurner" - state_topic: "cerasmarter/status" - value_template: "{{ value_json.GasBurner }}" - force_update: true - - name: "Heizung Fehler" - unique_id: "HeatingInformation_Status_Error" - state_topic: "cerasmarter/status" - value_template: "{{ value_json.Error }}" - force_update: true \ No newline at end of file diff --git a/Home Assistant/scripts.yaml b/Home Assistant/scripts.yaml deleted file mode 100644 index a297a5c..0000000 --- a/Home Assistant/scripts.yaml +++ /dev/null @@ -1,44 +0,0 @@ -mqtt_set_heatingparameters: - alias: Set Parameters for Heating - icon: mdi:send - sequence: - - service: mqtt.publish - data: - topic: cerasmarter/heating/parameters - payload_template: '{ - "Enabled": {{ iif(is_state(''input_boolean.heizung_betrieb'', ''on''), 1, 0) }}, - "FeedSetpoint": {{ states(''input_number.heizung_sollvorlauf_manuell'') | int(default=0) }}, - "FeedBaseSetpoint": {{ states(''input_number.heizung_fusspunkt'') | int(default=0) }}, - "FeedCutOff": {{ states(''input_number.heizung_endpunkt'') | int(default=0) }}, - "FeedMinimum": {{ states(''input_number.heizung_minimum'') | int(default=0) }}, - "AuxiliaryTemperature": {{ states(''sensor.temperatur_und_luftfeuchte_aussen_actual_temperature'') | float(default=0) }}, - "AmbientTemperature": {{ state_attr(''climate.thermostat_og_kochen_und_essen'',''current_temperature'') | float(default=0) }}, - "TargetAmbientTemperature": {{ states(''input_number.heizung_zieltemperatur'') | float(default=0) }}, - "OnDemandBoostDuration": {{ states(''input_number.heizung_boostdauer'') | int(default=0) }}, - "Adaption": {{ states(''input_number.heizung_adaption'') | int(default=0) }}, - "ValveScaling": {{ iif(is_state(''input_boolean.heizung_ventilskalierung'', ''on''), 1, 0) }}, - "ValveScalingMaxOpening": {{ states(''input_number.heizung_ventilskalierung_max'') | int(default=0) }}, - "ValveScalingOpening": {{ states(''sensor.average_weighted_valve'') | int(default=0) }}, - "DynamicAdaption": {{ iif(is_state(''input_boolean.heizung_dynamicadaption'', ''on''), 1, 0) }}, - "OverrideSetpoint": {{ iif(is_state(''input_boolean.heizung_override'', ''on''), 1, 0) }} - }' -# This Script will trigger the boost function. -boost_heizung: - alias: Boost Heizung - sequence: - - service: mqtt.publish - data: - topic: cerasmarter/boost/set - payload: '1' - mode: single - icon: mdi:fire-circle -# This Script will trigger the fast heatup function. -fastheatup_heizung: - alias: Schnellaufheizung - sequence: - - service: mqtt.publish - data: - topic: cerasmarter/fastheatup/set - payload: '1' - mode: single - icon: mdi:fire-circle \ No newline at end of file diff --git a/Home Assistant/sensors.yaml b/Home Assistant/sensors.yaml deleted file mode 100644 index a088015..0000000 --- a/Home Assistant/sensors.yaml +++ /dev/null @@ -1,340 +0,0 @@ -- platform: template - sensors: - heizung_brenner_zustand: - friendly_name: "Brenner" - value_template: >- - {% set value = is_state('sensor.heizung_brenner','true') %} - {% if value %} - An - {% else %} - Aus - {% endif %} - icon_template: >- - {% set value = is_state('sensor.heizung_brenner','true') %} - {% if value %} - mdi:fire - {% else %} - mdi:fire-off - {% endif %} - heizung_pumpe_zustand: - friendly_name: "Pumpe" - value_template: >- - {% set value = is_state('sensor.heizung_pumpe','true') %} - {% if value %} - An - {% else %} - Aus - {% endif %} - icon_template: >- - {% set value = is_state('sensor.heizung_pumpe','true') %} - {% if value %} - mdi:autorenew - {% else %} - mdi:sync-off - {% endif %} - heizung_heizbetrieb_zustand: - friendly_name: "Heizbetrieb" - value_template: >- - {% set value = is_state('sensor.heizung_heizbetrieb','true') %} - {% if value %} - An - {% else %} - Aus - {% endif %} - icon_template: >- - {% set value = is_state('sensor.heizung_heizbetrieb','true') %} - {% if value %} - mdi:radiator - {% else %} - mdi:radiator-disabled - {% endif %} - heizung_saisonmodus_zustand: - friendly_name: "Saisonaler Heizbetrieb" - value_template: >- - {% set value = is_state('sensor.heizung_saisonaler_betriebsmodus','false') %} - {% if value %} - Sommer - {% else %} - Winter - {% endif %} - icon_template: >- - {% set value = is_state('sensor.heizung_saisonaler_betriebsmodus','false') %} - {% if value %} - mdi:weather-sunny - {% else %} - mdi:weather-snowy - {% endif %} - heizung_fehler_zustand: - friendly_name: "Fehler" - unique_id: "Heizung_Fehlerbeschreibung" - value_template: >- - {% set value = states('sensor.heizung_fehler') | int %} - {% if value == 0 %} - Betrieb - {% elif value == 161 %} - A1: Kennfeldpumpe trockengelaufen - {% elif value == 162 %} - A2: Abgasaustritt: Brennkammer - {% elif value == 163 %} - A3: Abgas-NTC defekt: Strömungssicherung - {% elif value == 164 %} - A4: Abgasaustritt: Strömungssicherung - {% elif value == 166 %} - A6: Abgas-NTC defekt: Brennkammer - {% elif value == 167 %} - A7: Warmwasser-NTC defekt - {% elif value == 168 %} - A8: CAN-Kommunikation unterbrochen - {% elif value == 170 %} - AA: Sekundär-Wärmetauscher verkalkt - {% elif value == 172 %} - AC: Modul nicht erkannt. - {% elif value == 173 %} - AD: Speicher-NTC 1 nicht erkannt - {% elif value == 177 %} - B1: Kodierstecker nicht erkannt - {% elif value == 202 %} - CA: Turbinendrehzahl zu hoch - {% elif value == 204 %} - CC: Außentemperatur-NTC nicht erkannt - {% elif value == 209 %} - D1: LSM Verriegelt - {% elif value == 211 %} - D3: Brücke 8-9 nicht erkannt - {% elif value == 226 %} - E2: Vorlauftemperatur-NTC defekt - {% elif value == 229 %} - E5: Brenner-NTC Temperatur überschritten - {% elif value == 231 %} - E7: Brenner-NTC defekt - {% elif value == 233 %} - E9: STB im Vorlauf hat ausgelöst. Druck auf 1-2 Bar prüfen. - {% elif value == 234 %} - EA: Im Betrieb: Flamme wird nicht erkannt - {% elif value == 240 %} - F0: Interner Fehler - {% elif value == 247 %} - F7: Obwohl Gerät ausgeschaltet: Flamme wird erkannt - {% elif value == 250 %} - FA: Nach Gasabschaltung: Flamme wird erkannt - {% elif value == 252 %} - FC: Textdisplay nicht erkannt - {% elif value == 253 %} - FD: Entstörtaste irrtümlich gedrückt - {% endif %} - icon_template: >- - {% set value = states('sensor.heizung_fehler') | int %} - {% if value == 0 %} - mdi:check - {% else %} - mdi:alert-circle - {% endif %} -- platform: template - sensors: - heating_feed_return_diff: - friendly_name: "Spreizung VL-NL" - value_template: >- - {% set feed = states('sensor.externe_vorlauftemperatur')| float(default=0) %} - {% set return = states('sensor.externe_rucklauftemperatur')| float(default=0) %} - {{ (feed - return) | round(2) }} - unit_of_measurement: "°C" - heating_setpoint_return_diff: - friendly_name: "Spreizung Soll-NL" - value_template: >- - {% set feed = states('sensor.soll_vorlauftemperatur_heizung')| float(default=0) %} - {% set return = states('sensor.externe_rucklauftemperatur')| float(default=0) %} - {{ (feed - return) | round(2) }} - unit_of_measurement: "°C" - heating_adaption_return_ambient: - friendly_name: "Adaption Zieltemperatur und Rücklauf" - value_template: >- - {% set ambient = states('input_number.heizung_zieltemperatur')| float(default=0) %} - {% set return = states('sensor.externe_rucklauftemperatur')| float(default=0) %} - {{ (ambient - return) | round(2) }} - unit_of_measurement: "°C" -- platform: template - sensors: - heating_parameters_preview: - friendly_name: "Vorschau aktuelle Vorlauftemperatur" - icon_template: mdi:thermometer - unit_of_measurement: "°C" - value_template: >- - {% set from_min = states('input_number.heizung_endpunkt')|float(default=0) %} - {% set from_max = states('input_number.heizung_fusspunkt')|float(default=0) %} - {% set to_min = states('input_number.heizung_minimum')|float(default=0) %} - {% set to_max = states('sensor.maximale_vorlauftemperatur_heizung')|float(default=0) %} - {% set input_value = states('sensor.aussentemperaturfuhler_heizung')|float(default=0) %} - {% set adaption = states('input_number.heizung_adaption')|float(default=0) %} - {{ (((input_value - from_min) * (to_max - to_min) / (from_max - from_min) + to_min) + adaption) | round(2) }} -- platform: template - sensors: - heating_boost_active: - friendly_name: "Boostfunktion aktiv" - icon_template: >- - {% set value = is_state('sensor.heizung_boost','true') %} - {% if value %} - mdi:fire - {% else %} - mdi:cancel - {% endif %} - value_template: >- - {% set value = is_state('sensor.heizung_boost','true') %} - {% if value %} - An - {% else %} - Aus - {% endif %} -- platform: template - sensors: - heating_fastheatup_active: - friendly_name: "Schnellaufheizung aktiv" - icon_template: >- - {% set value = is_state('sensor.heizung_schnellaufheizung','true') %} - {% if value %} - mdi:fast-forward - {% else %} - mdi:cancel - {% endif %} - value_template: >- - {% set value = is_state('sensor.heizung_schnellaufheizung','true') %} - {% if value %} - An - {% else %} - Aus - {% endif %} -- platform: template - sensors: - heating_dynamic_adaption_int: - friendly_name: "INT Dynamische Adaption" - value_template: >- - {% set value = is_state('input_boolean.heizung_dynamicadaption','on') %} - {% if value %} - 1 - {% else %} - 0 - {% endif %} -- platform: template - sensors: - heating_valve_scaling_int: - friendly_name: "INT Ventilskalierung" - value_template: >- - {% set value = is_state('input_boolean.heizung_ventilskalierung','on') %} - {% if value %} - 1 - {% else %} - 0 - {% endif %} -### Min/Max Thermostats -- platform: min_max - name: "Ventilöffnung MinMax" - entity_ids: - # - Valve Entities you want to monitor -### Average Values Heating -- platform: average - end: "{{ now().replace(hour=0).replace(minute=0).replace(second=0) }}" - duration: - hours: 1 - name: "Durchschnitt Vorlauftemperatur (1 Stunde)" - entities: - - sensor.externe_vorlauftemperatur -- platform: average - end: "{{ now().replace(hour=0).replace(minute=0).replace(second=0) }}" - duration: - hours: 1 - name: "Durchschnitt Nachlauftemperatur (1 Stunde)" - entities: - - sensor.externe_rucklauftemperatur -- platform: average - end: "{{ now().replace(hour=0).replace(minute=0).replace(second=0) }}" - duration: - hours: 1 - name: "Durchschnitt Abgastemperatur (1 Stunde)" - entities: - - sensor.externe_abgastemperatur -- platform: average - end: "{{ now().replace(hour=0).replace(minute=0).replace(second=0) }}" - duration: - minutes: 15 - name: "Durchschnitt Helligkeit außen 15 Minuten" - entities: - - sensor.lichtsensor_aussen_current_illumination -- platform: average - end: "{{ now().replace(hour=0).replace(minute=0).replace(second=0) }}" - duration: - minutes: 15 - precision: 0 - name: "Durchschnittliche Ventilöffnung 15 Minuten" - unique_id: "average_valve_period" - entities: - # - Valve Entities you want to monitor -- platform: average - name: "Durchschnittliche Ventilöffnung Aktuell" - unique_id: "average_valve_act" - precision: 0 - entities: - # - Valve Entities you want to monitor -- platform: average - name: "Durchschnittliche Ventilöffnung EG" - unique_id: "average_valve_act_eg" - precision: 0 - entities: - # - Valve Entities you want to monitor -- platform: average - name: "Durchschnittliche Ventilöffnung OG" - unique_id: "average_valve_act_og" - precision: 0 - entities: - # - Valve Entities you want to monitor -- platform: template - sensors: - average_weighted_valve: - unique_id: "average_weighted_valve" - friendly_name: "Ventilöffnung gewichtet" - icon_template: "mdi:scale-balance" - value_template: >- - {% set eg = states('sensor.durchschnittliche_ventiloffnung_eg') | float(default=100) %} - {% set og = states('sensor.durchschnittliche_ventiloffnung_og') | float(default=30) %} - {% set weight = states('input_number.heizung_gewichtung_ventile') | int %} - {% set lesserWeight = 100 - weight %} - {% set value = ((eg * weight/100) + (og * lesserWeight/100)) | round() %} - {{ value }} - -- platform: history_stats - name: "Brennerstatistik Tag" - entity_id: sensor.heizung_brenner - state: "true" - type: count - end: "{{ now() }}" - duration: - hours: 24 - -- platform: template - sensors: - brennerstarts_intervall_tag: - friendly_name: "Brennerstarts Intervall Tag" - unit_of_measurement: "minuten" - value_template: >- - {% set perDay = states('sensor.brennerstatistik_tag') | int %} - {% set hour = 60*24 | int %} - {% set value = hour / perDay | float %} - {{ value | round(2) }} - -- platform: history_stats - name: "Brennerstatistik Stunde" - entity_id: sensor.heizung_brenner - state: "true" - type: count - end: "{{ now() }}" - duration: - hours: 1 - -- platform: template - sensors: - brennerstarts_intervall_stunde: - friendly_name: "Brennerstarts Intervall Stunde" - unit_of_measurement: "minuten" - value_template: >- - {% set perHour = states('sensor.brennerstatistik_stunde') | int %} - {% set hour = 60 | int %} - {% set value = hour / perHour | float %} - {{ value | round(2) }} diff --git a/README.md b/README.md index d7a3ba6..ec9661b 100644 --- a/README.md +++ b/README.md @@ -397,7 +397,26 @@ You can also use the web UI (See: Firmware Update on the menu bar) to upload the Debug info can be retrieved using a very basic telnet implementation. Simply connect to the ESP32 using telnet and watch as the messages flow. You can reboot the ESP by typing `reboot` and press enter. Be aware you have to type very quickly because this is truly a very minimalistic and barebone implementation of a client-server console communication which is primarily designed to see debug output without having to stand near the esp. ## Home Assistant Integration -This project was originally specifically designed to be run alongside Home Assistant and efforts have been taken to make the setup as hassle-free as possible but the so-called autodiscovery is still in the works. In the meantime you may find the necessary scripts, values and other stuff inside the [Home Assistant Folder](Home%20Assistant) + +Cerasmarter uses current MQTT **device discovery**. No Home Assistant YAML files or separate discovery templates are required. + +Set `HomeAssistant.Enabled` to `true`, configure the same discovery prefix used by Home Assistant (normally `homeassistant`), and give every controller a unique `DeviceId`. After connecting to MQTT, the controller publishes one retained device-discovery document to: + +```text +/device//config +``` + +Home Assistant groups the heating, hot-water, status, and auxiliary-temperature entities under one device. The integration provides temperature and status sensors, binary operating-state sensors, dynamically generated auxiliary-sensor entities, number controls for requested feed temperature, boost duration, and room-reference temperature, plus switches for heating enablement, boost, and fast heatup. + +Memory, filesystem and flash capacity, chip model and revision, CPU core count, CPU frequency, and auxiliary-sensor connectivity are exposed as diagnostic entities. Home Assistant keeps these on the device's diagnostic card instead of treating them as normal heating controls or measurements. + +Discovery assigns purpose-specific Material Design icons to every entity. The **Flame Lit** binary sensor intentionally has no Home Assistant device class: it therefore reports plain **On/Off** while using a flame icon whose active color follows the burner state. MQTT discovery supports one static icon per entity, so using different custom glyphs for its on and off states would require a separate Home Assistant template entity. + +Runtime state and command topics use `junkerscontrol//...`, independently of the discovery prefix. Discovery and state messages are retained, and MQTT Last Will availability marks the device offline if its connection is lost. The controller also listens for Home Assistant's `/status` birth message and republishes discovery when Home Assistant restarts. + +Keep `DeviceId` stable after discovery. If it or the discovery prefix is changed through the web interface, the controller removes its previous retained discovery record and reconnects with the new identity. An old record only needs manual removal if the broker was unavailable during the change. + +See the [configuration guide](assets/Configuration.md#home-assistant) for all settings. ## Hints - If you just wanna read then usually you have nothing to modify. The program will see other controllers on the bus and will go into "read-only" mode by itself. If you're not wanting to take any risks, you have to set the variable `OverrideControl` in [main.cpp](src/main.cpp) to `false`. This way nothing will be sent on the bus udner any circumstances but you can read everything. diff --git a/assets/Configuration.md b/assets/Configuration.md index b3de4a3..3e4b484 100644 --- a/assets/Configuration.md +++ b/assets/Configuration.md @@ -252,6 +252,42 @@ Example Output: Following the timestamp when the message has been received, you will find the ID of the message, i.e.: `CAN: [0x20D]` following the data bytes in hexadecimal representation and the decimal value in paranthesis `0x18 (24)`. Each Byte is separated by tab `\t` +### Home Assistant + +Cerasmarter supports native MQTT device discovery. No manual Home Assistant YAML or filesystem discovery-template files are needed. + +```json + "HomeAssistant": { + "AutoDiscoveryPrefix": "homeassistant", + "OffDelay": 30, + "Enabled": true, + "DeviceId": "cerasmarter_1", + "TempUnit": "°C" + } +``` + +- `Enabled` activates Home Assistant device discovery and Home Assistant-specific state and command topics. +- `AutoDiscoveryPrefix` must match the discovery prefix configured in Home Assistant's MQTT integration. Its default is `homeassistant`. +- `DeviceId` must be unique for every controller on the broker. It identifies the Home Assistant device and forms part of its MQTT topics. Spaces and `/` characters are normalized to `_`. +- `TempUnit` is used by all discovered temperature entities. Normally this is `°C` or `°F`. +- `OffDelay` is the number of seconds after which an active binary sensor returns to off without a newer active state. Set it to `0` to disable this behavior. + +The controller publishes one retained device-discovery payload to `/device//config`. Runtime data uses `junkerscontrol//...`; changing the discovery prefix does not change state or command topics. + +Treat `DeviceId` as stable after Home Assistant has discovered the controller. When the ID or discovery prefix is changed through the web interface, the controller removes its previous retained discovery record before reconnecting with the new identity. If the broker is unavailable during that change, remove the old device or retained discovery topic manually. + +The discovered device contains: + +- General error and gas-burner state. +- Heating temperatures, pump/season/operation/boost states, and fast-heatup state. +- Hot-water temperatures and operating states. +- A temperature entity and diagnostic connectivity entity for every configured auxiliary sensor. +- Diagnostic entities for heap memory, filesystem and flash storage, chip model and revision, and CPU core count and frequency. +- Number controls for requested feed temperature, boost duration, and room-reference temperature. +- Switch controls for heating enablement, boost, and fast heatup. + +Discovery and state payloads are retained. The controller publishes retained online/offline availability using MQTT Last Will and republishes discovery when Home Assistant sends its MQTT birth message. + ### CAN Configuration ```json diff --git a/data/frontend/mqtt.html b/data/frontend/mqtt.html index 2f9203e..b565fad 100644 --- a/data/frontend/mqtt.html +++ b/data/frontend/mqtt.html @@ -73,6 +73,65 @@

Server Configuration

+
+

Home Assistant Device Discovery

+
+
+
+
+
+ + +
+
+ +
+ + Must match the MQTT discovery prefix configured in Home Assistant. +
+
+
+ +
+ + Use a unique value for every controller on the MQTT broker. Changing it replaces the controller's discovered device. +
+
+
+ +
+ +
+
+
+ +
+ + Seconds before an active binary sensor resets; use 0 to disable. +
+
+
+ +
+ +
+
+
+
+
+ +
+
+
+
+

Topics Configuration

@@ -194,9 +253,11 @@

Topics Configuration

loadNavigation(); loadMqttConfig(); loadMqttTopicsConfig(); + loadHomeAssistantConfig(); _("mqtt-form").addEventListener('submit', sendMqttConfig); _("mqtt-topics-form").addEventListener('submit', sendMqttTopicsConfig); + _("home-assistant-form").addEventListener('submit', sendHomeAssistantConfig); async function loadMqttConfig() { const form = document.forms["mqtt-form"]; @@ -217,5 +278,40 @@

Topics Configuration

} form.disabled = false; } + + async function loadHomeAssistantConfig() { + const form = document.forms["home-assistant-form"]; + form.disabled = true; + const config = await getConfigJson("/api/config/homeassistant"); + _("ha-enabled").checked = config.enabled; + for (const field of ["discovery-prefix", "device-id", "temperature-unit", "off-delay", "state-topic"]) + form.elements[field].value = config[field]; + form.disabled = false; + } + + async function sendHomeAssistantConfig(event) { + event.preventDefault(); + const button = _("save-home-assistant"); + button.disabled = true; + + const payload = Object.fromEntries(new FormData(event.target).entries()); + payload.enabled = _("ha-enabled").checked; + delete payload["state-topic"]; + + const response = await fetch('/api/config/homeassistant', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const result = await response.json().catch(() => ({})); + const level = response.ok ? "success" : "danger"; + _("home-assistant-status").innerHTML = ``; + + if (response.ok) + await loadHomeAssistantConfig(); + button.disabled = false; + } - \ No newline at end of file + diff --git a/data/ha_binarysensors.json b/data/ha_binarysensors.json deleted file mode 100644 index edbc4ad..0000000 --- a/data/ha_binarysensors.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "binary_sensor": { - "General": [ - { - "GasBurner": { - "Label": "Flame Lit", - "frc_upd": true, - "pl_off": "false", - "pl_on": "true", - "val_tpl": "{{ value_json.General.GasBurner }}" - } - } - ], - "Heating": [ - { - "Pump": { - "Label": "Pump Active", - "dev_cla": "running", - "frc_upd": true, - "pl_off": "false", - "pl_on": "true", - "val_tpl": "{{ value_json.Heating.Pump }}" - } - }, - { - "Season": { - "Label": "Seasonal Mode", - "frc_upd": true, - "pl_off": "false", - "pl_on": "true", - "val_tpl": "{{ value_json.Heating.Season }}" - } - }, - { - "Working": { - "Label": "Operation", - "frc_upd": true, - "pl_off": "false", - "pl_on": "true", - "val_tpl": "{{ value_json.Heating.Working }}" - } - }, - { - "Boost": { - "Label": "Boost", - "frc_upd": true, - "pl_off": "false", - "pl_on": "true", - "val_tpl": "{{ value_json.Heating.Boost }}" - } - }, - { - "FastHeatup": { - "Label": "FastHeatup", - "frc_upd": true, - "pl_off": "false", - "pl_on": "true", - "val_tpl": "{{ value_json.Heating.FastHeatup }}" - } - } - ], - "Water": [ - { - "Now": { - "Label": "Hot Water NOW Switch", - "frc_upd": true, - "pl_off": "false", - "pl_on": "true", - "val_tpl": "{{ value_json.Water.Now }}" - } - }, - { - "Buffer": { - "Label": "Buffer Mode", - "frc_upd": true, - "pl_off": "false", - "pl_on": "true", - "val_tpl": "{{ value_json.Water.Buffer }}" - } - } - ] - - } -} \ No newline at end of file diff --git a/data/ha_numbers.json b/data/ha_numbers.json deleted file mode 100644 index c29797a..0000000 --- a/data/ha_numbers.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "number":{ - "Heating": [ - { - "Setpoint": { - "Label": "Feed Setpoint", - "unit_of_meas": "°C", - "min": 0, - "max": 100, - "stat_t": "~/istate", - "cmd_t": "~/set" - } - }, - { - "BoostDuration": { - "Label": "Boost Duration", - "unit_of_meas": "s", - "min": 0, - "stat_t": "~/istate", - "cmd_t": "~/set" - } - }, - { - "RoomReferenceT": { - "Label": "Room Reference Temperature", - "unit_of_meas": "°C", - "stat_t": "~/istate", - "cmd_t": "~/set" - } - } - ], - "Water": [ - { - "Setpoint": { - "Label": "Setpoint", - "unit_of_meas": "°C", - "min": 0, - "max": 100, - "stat_t": "~/istate", - "cmd_t": "~/set" - } - } - ] - } -} \ No newline at end of file diff --git a/data/ha_sensors.json b/data/ha_sensors.json deleted file mode 100644 index 8335b21..0000000 --- a/data/ha_sensors.json +++ /dev/null @@ -1,88 +0,0 @@ -{ "sensor": { - "General": [ - { - "Error": { - "Label": "Error", - "frc_upd": true, - "val_tpl": "{{ value_json.General.Error }}" - } - } - ], - "Heating": [ - { - "FeedCurrent": { - "Label": "Current Feed Temperature", - "unit_of_meas": "°C", - "dev_cla": "temperature", - "frc_upd": true, - "val_tpl": "{{ value_json.Heating.FeedCurrent | float(default=0) }}" - } - }, - { - "FeedMaximum": { - "Label": "Maximum Feed Temperature", - "unit_of_meas": "°C", - "dev_cla": "temperature", - "frc_upd": true, - "val_tpl": "{{ value_json.Heating.FeedMaximum | float(default=0) }}" - } - }, - { - "FeedSetpoint": { - "Label": "Setpoint Feed Temperature", - "unit_of_meas": "°C", - "dev_cla": "temperature", - "frc_upd": true, - "val_tpl": "{{ value_json.Heating.FeedSetpoint | float(default=0) }}" - } - }, - { - "Outside": { - "Label": "Outside Temperature Reading", - "unit_of_meas": "°C", - "dev_cla": "temperature", - "frc_upd": true, - "val_tpl": "{{ value_json.Heating.Outside | float(default=0) }}" - } - } - ], - "Water": [ - { - "Maximum": { - "Label": "Maximum Temperature", - "unit_of_meas": "°C", - "dev_cla": "temperature", - "frc_upd": true, - "val_tpl": "{{ value_json.Water.Maximum | float(default=0) }}" - } - }, - { - "Current": { - "Label": "Current Temperature", - "unit_of_meas": "°C", - "dev_cla": "temperature", - "frc_upd": true, - "val_tpl": "{{ value_json.Water.Current | float(default=0) }}" - } - }, - { - "Setpoint": { - "Label": "Setpoint Temperature", - "unit_of_meas": "°C", - "dev_cla": "temperature", - "frc_upd": true, - "val_tpl": "{{ value_json.Water.Setpoint | float(default=0) }}" - } - }, - { - "CFSetpoint": { - "Label": "Continous Flow Setpoint Temperature", - "unit_of_meas": "°C", - "dev_cla": "temperature", - "frc_upd": true, - "val_tpl": "{{ value_json.Water.CFSetpoint | float(default=0) }}" - } - } - ] - } -} diff --git a/gzip-data.py b/gzip-data.py index 85244b9..bd49ce9 100644 --- a/gzip-data.py +++ b/gzip-data.py @@ -53,27 +53,16 @@ def gzip_webfiles( source, target, env ): print('GZIP: FAILURE / ABORTED') raise RuntimeError('Configuration source is missing') - # CHECK GZIP DIR - if not os.path.exists( target_data_path ): - print( 'GZIP: GZIP DIRECTORY MISSING AT PATH: ' + target_data_path ) - print( 'GZIP: TRYING TO CREATE IT...' ) - try: - os.mkdir( target_data_path ) - except Exception as e: - print( 'GZIP: FAILED TO CREATE DIRECTORY: ' + target_data_path ) - # print( 'GZIP: EXCEPTION... ' + str( e ) ) - print( 'GZIP: PLEASE CREATE THE DIRECTORY FIRST (ABORTING)' ) - print( 'GZIP: FAILURE / ABORTED' ) - raise - - # A release filesystem must be directly provisionable, but must never copy - # a developer's device-specific configuration. Always source this file from - # the versioned, credential-free template outside the data directory. - if configuration_source_path == configuration_template_path: - print(' Copying credential-free configuration template') - else: - print(' Copying explicitly selected local device configuration') - shutil.copyfile(configuration_source_path, target_configuration_path) + # Keep the generated tree in place because cloud-synced directories can be + # locked on Windows. Stale files are removed individually after the source + # file list has been assembled below. + try: + os.makedirs( target_data_path, exist_ok=True ) + except Exception as e: + print( 'GZIP: FAILED TO CREATE DIRECTORY: ' + target_data_path ) + print( 'GZIP: EXCEPTION... ' + str( e ) ) + print( 'GZIP: FAILURE / ABORTED' ) + raise files_to_gzip = [] files_to_copy = [] @@ -93,6 +82,30 @@ def gzip_webfiles( source, target, env ): # Just Copy files_to_copy.append( fileFullPath ) + expected_target_files = { target_configuration_path } + for file in files_to_copy: + expected_target_files.add( file.replace(source_data_path, target_data_path) ) + for file in files_to_gzip: + expected_target_files.add( file.replace(source_data_path, target_data_path) + '.gz' ) + + # Remove generated files that no longer have a source. This prevents old + # templates or a previously staged configuration from entering the image. + for dirpath, dirs, files in os.walk(target_data_path): + for filename in files: + target_file = os.path.join(dirpath, filename) + if target_file not in expected_target_files: + print( ' Removing stale file: ' + target_file ) + os.remove( target_file ) + + # A release filesystem must be directly provisionable, but must never copy + # a developer's device-specific configuration. Always use the credential-free + # template unless local provisioning explicitly selected an external file. + if configuration_source_path == configuration_template_path: + print(' Copying credential-free configuration template') + else: + print(' Copying explicitly selected local device configuration') + shutil.copyfile(configuration_source_path, target_configuration_path) + # Copy files not included in gzip extension list for file in files_to_copy: # Just replace the path portion of our file while keeping the rest of the structure as-is @@ -124,6 +137,7 @@ def gzip_webfiles( source, target, env ): print( 'GZIP: EXCEPTION... {}'.format( e ) ) if was_error: print( 'GZIP: FAILURE/INCOMPLETE.\n' ) + raise RuntimeError( 'Filesystem preprocessing failed' ) else: print( 'GZIP: SUCCESS/COMPRESSED.\n' ) diff --git a/include/configuration.h b/include/configuration.h index 8a4272b..a14c628 100644 --- a/include/configuration.h +++ b/include/configuration.h @@ -99,12 +99,12 @@ struct Configuration struct HomeAssistant_ { - String AutoDiscoveryPrefix; + String AutoDiscoveryPrefix = "homeassistant"; bool Enabled = false; - int OffDelay; - String DeviceId; + int OffDelay = 0; + String DeviceId = "cerasmarter"; String StateTopic; - String TempUnit; + String TempUnit = "°C"; } HomeAssistant; struct LEDs_ diff --git a/include/ha_autodiscovery.h b/include/ha_autodiscovery.h index a049d7d..74549ec 100644 --- a/include/ha_autodiscovery.h +++ b/include/ha_autodiscovery.h @@ -1,24 +1,10 @@ -#if !defined(HA_AUTODISCOVERY_H) +#ifndef HA_AUTODISCOVERY_H #define HA_AUTODISCOVERY_H #include -#include -#include -extern const char *HaSensorsFileName; -extern const char *HaBinarySensorsFileName; -extern const char *HaNumbersFileName; - -extern void SetupAutodiscovery(const char* fileName); -extern void sendMQTTTemperatureDiscoveryMsg(); -extern void CreateAndPublishAutoDiscoverySensorJson( - String name, - String value_template, - String unit_of_measurement, - String state_topic, - char *device_class, - bool force_update, - char *sensorShortName -); +void SetupHomeAssistantDiscovery(); +void PublishHomeAssistantAvailability(bool online); +bool HandleHomeAssistantMessage(const char *topic, const String &payload); #endif // HA_AUTODISCOVERY_H diff --git a/include/webconfig.h b/include/webconfig.h index ed37aa4..dada09e 100644 --- a/include/webconfig.h +++ b/include/webconfig.h @@ -47,6 +47,8 @@ extern void onMqttConfigReceive(AsyncWebServerRequest *request, JsonVariant &jso extern void getMqttTopicConfig(AsyncWebServerRequest *request); extern void onMqttTopicConfigReceive(AsyncWebServerRequest *request, JsonVariant &json); +extern void getHomeAssistantConfig(AsyncWebServerRequest *request); +extern void onHomeAssistantConfigReceive(AsyncWebServerRequest *request, JsonVariant &json); extern void getSystemInfo(AsyncWebServerRequest *request); @@ -88,4 +90,4 @@ extern void getLedConfig(AsyncWebServerRequest *request); extern void configureLedConfigEndpoints(); -#endif \ No newline at end of file +#endif diff --git a/platformio.ini b/platformio.ini index 499a58a..45dbc59 100644 --- a/platformio.ini +++ b/platformio.ini @@ -12,6 +12,7 @@ description = "Project for controlling Junkers heating systems equipped with BM1 and BM2 bus modules" default_envs = development, production data_dir = data_build +build_cache_dir = .pio/build_cache [env] # Pin the exact pioarduino platform used for development and release builds. diff --git a/src/configuration.cpp b/src/configuration.cpp index efdc4df..c5b001f 100644 --- a/src/configuration.cpp +++ b/src/configuration.cpp @@ -141,12 +141,30 @@ bool ReadConfiguration() configuration.FailSafe.MaximumFeedTemperature = 55; JsonObject HomeAssistantSettings = doc["HomeAssistant"]; - configuration.HomeAssistant.Enabled = HomeAssistantSettings["Enabled"]; - configuration.HomeAssistant.DeviceId = HomeAssistantSettings["DeviceId"].as(); - configuration.HomeAssistant.OffDelay = HomeAssistantSettings["OffDelay"]; - configuration.HomeAssistant.AutoDiscoveryPrefix = HomeAssistantSettings["AutoDiscoveryPrefix"].as(); - configuration.HomeAssistant.StateTopic = configuration.HomeAssistant.AutoDiscoveryPrefix + "/" + configuration.HomeAssistant.DeviceId + "/"; - configuration.HomeAssistant.TempUnit = HomeAssistantSettings["TempUnit"].as(); + configuration.HomeAssistant.Enabled = HomeAssistantSettings["Enabled"] | false; + configuration.HomeAssistant.OffDelay = HomeAssistantSettings["OffDelay"] | 0; + + String deviceId = HomeAssistantSettings["DeviceId"].as(); + if (deviceId.isEmpty()) + deviceId = configuration.Wifi.Hostname; + if (deviceId.isEmpty()) + deviceId = "cerasmarter"; + deviceId.trim(); + deviceId.replace(" ", "_"); + deviceId.replace("/", "_"); + configuration.HomeAssistant.DeviceId = deviceId; + + String discoveryPrefix = HomeAssistantSettings["AutoDiscoveryPrefix"].as(); + discoveryPrefix.trim(); + while (discoveryPrefix.endsWith("/")) + discoveryPrefix.remove(discoveryPrefix.length() - 1); + if (discoveryPrefix.isEmpty()) + discoveryPrefix = "homeassistant"; + configuration.HomeAssistant.AutoDiscoveryPrefix = discoveryPrefix; + + String temperatureUnit = HomeAssistantSettings["TempUnit"].as(); + configuration.HomeAssistant.TempUnit = temperatureUnit.isEmpty() ? "°C" : temperatureUnit; + configuration.HomeAssistant.StateTopic = "junkerscontrol/" + configuration.HomeAssistant.DeviceId + "/"; JsonObject Leds = doc["LEDs"]; if (Leds["Wifi"].is()) diff --git a/src/ha_autodiscovery.cpp b/src/ha_autodiscovery.cpp index 5267a77..152f4fb 100644 --- a/src/ha_autodiscovery.cpp +++ b/src/ha_autodiscovery.cpp @@ -1,176 +1,448 @@ #include -#include - -const char *HaSensorsFileName = (char *)"/ha_sensors.json"; -const char *HaBinarySensorsFileName = (char *)"/ha_binarysensors.json"; -const char *HaNumbersFileName = (char *)"/ha_numbers.json"; - -/// @brief Create a JSON definition of a HA sensor. -/// @param name Name of the Sensor. Example: "Heating Feed Setpoint Temperature" -/// @param unit_of_measurement °C, KW, ... -/// @param device_class See https://www.home-assistant.io/integrations/sensor/#device-class -/// @param force_update Sends update events even if the value hasn’t changed. Useful if you want to have meaningful value graphs in history. See https://www.home-assistant.io/integrations/sensor.mqtt/#force_update -/// @param sensorShortName A short name for the sensor wich is used inside the discovery topic. -/// @param value_template The template to be used inside HA to correctly parse this value. Example:"{{ value_json.temperature | float(default=0) }}" -void CreateAndPublishAutoDiscoverySensorJson( - String name, - String value_template, - String unit_of_measurement = (char *)"°C", - String state_topic = "homeassistant/device/state", - char *device_class = (char *)"temperature", - bool force_update = true, - char *sensorShortName = (char *)"temperature") -{ - // This is the discovery topic for this specific sensor - String discoveryTopic = configuration.HomeAssistant.AutoDiscoveryPrefix + "/sensor/" + configuration.HomeAssistant.DeviceId + "/temperature/config"; - JsonDocument doc; - char buffer[256]; +#include +#include +#include +#include +#include +#include - doc["name"] = name; - doc["uniq_id"] = configuration.HomeAssistant.DeviceId; - doc["stat_t"] = state_topic; - doc["unit_of_meas"] = unit_of_measurement; - doc["dev_cla"] = device_class; - doc["frc_upd"] = force_update; - // I'm sending a JSON object as the state of this MQTT device - // so we'll need to unpack this JSON object to get a single value - // for this specific sensor. - doc["val_tpl"] = value_template; +namespace +{ +String availabilityTopic() +{ + return configuration.HomeAssistant.StateTopic + "availability"; +} - size_t n = serializeJson(doc, buffer); - client.publish(configuration.HomeAssistant.StateTopic.c_str(), buffer, n); +String discoveryTopic() +{ + return configuration.HomeAssistant.AutoDiscoveryPrefix + "/device/" + + configuration.HomeAssistant.DeviceId + "/config"; } -void SetupAutodiscoveryForAuxSensors() +String homeAssistantStatusTopic() { - for (size_t i = 0; i < configuration.TemperatureSensors.SensorCount; i++) + return configuration.HomeAssistant.AutoDiscoveryPrefix + "/status"; +} + +String sanitizeId(String value) +{ + value.toLowerCase(); + for (size_t i = 0; i < value.length(); i++) { - String label = configuration.TemperatureSensors.Sensors[i].Label; - label.replace(" ", "-"); - char *valTempl; - sprintf(valTempl, "{{ value_json.Auxiliary.%s }}", label.c_str()); - String topic = configuration.HomeAssistant.StateTopic + "Auxiliary/state"; - CreateAndPublishAutoDiscoverySensorJson( - label.c_str(), - configuration.HomeAssistant.TempUnit.c_str(), - valTempl, - topic); + const char c = value.charAt(i); + if (!isAlphaNumeric(c) && c != '_' && c != '-') + value.setCharAt(i, '_'); } + return value; +} + +String softwareVersion() +{ + String version = JC_STRINGIFY(VERSION); + version.replace("\"", ""); + return version; +} + +String stateTopic(const String &category) +{ + return configuration.HomeAssistant.StateTopic + category + "/state"; +} + +String commandTopic(const String &category, const String &key) +{ + return configuration.HomeAssistant.StateTopic + category + "/" + key + "/set"; +} + +String uniqueId(const String &componentId) +{ + return sanitizeId(configuration.HomeAssistant.DeviceId + "_" + componentId); +} + +JsonObject addComponent(JsonObject components, const String &componentId, const char *platform, const String &name) +{ + JsonObject component = components[componentId].to(); + component["p"] = platform; + component["name"] = name; + component["unique_id"] = uniqueId(componentId); + return component; +} + +void addSensor(JsonObject components, + const String &componentId, + const String &name, + const String &category, + const String &valueTemplate, + const char *deviceClass = nullptr, + const String &unit = "", + const char *icon = nullptr) +{ + JsonObject sensor = addComponent(components, componentId, "sensor", name); + sensor["state_topic"] = stateTopic(category); + sensor["value_template"] = valueTemplate; + if (deviceClass != nullptr) + sensor["device_class"] = deviceClass; + if (!unit.isEmpty()) + sensor["unit_of_measurement"] = unit; + if (icon != nullptr) + sensor["icon"] = icon; + if (deviceClass != nullptr && strcmp(deviceClass, "temperature") == 0) + sensor["state_class"] = "measurement"; +} + +void addDiagnosticSensor(JsonObject components, + const String &componentId, + const String &name, + const String &valueTemplate, + const char *deviceClass, + const String &unit, + const char *icon, + const char *stateClass = nullptr) +{ + addSensor(components, componentId, name, "General", valueTemplate, deviceClass, unit, icon); + JsonObject sensor = components[componentId].as(); + sensor["entity_category"] = "diagnostic"; + if (stateClass != nullptr) + sensor["state_class"] = stateClass; +} + +void addBinarySensor(JsonObject components, + const String &componentId, + const String &name, + const String &category, + const String &valueTemplate, + const char *deviceClass = nullptr, + const char *icon = nullptr, + const char *entityCategory = nullptr) +{ + JsonObject sensor = addComponent(components, componentId, "binary_sensor", name); + sensor["state_topic"] = stateTopic(category); + sensor["value_template"] = valueTemplate; + sensor["payload_on"] = "true"; + sensor["payload_off"] = "false"; + if (deviceClass != nullptr) + sensor["device_class"] = deviceClass; + if (icon != nullptr) + sensor["icon"] = icon; + if (entityCategory != nullptr) + sensor["entity_category"] = entityCategory; + if (configuration.HomeAssistant.OffDelay > 0) + sensor["off_delay"] = configuration.HomeAssistant.OffDelay; +} + +void addNumber(JsonObject components, + const String &componentId, + const String &name, + const String &category, + const String &key, + const String &valueTemplate, + double minimum, + double maximum, + double step, + const String &unit = "", + const char *mode = "slider", + const char *icon = nullptr) +{ + JsonObject number = addComponent(components, componentId, "number", name); + number["state_topic"] = stateTopic(category); + number["command_topic"] = commandTopic(category, key); + number["value_template"] = valueTemplate; + number["min"] = minimum; + number["max"] = maximum; + number["step"] = step; + number["mode"] = mode; + if (!unit.isEmpty()) + number["unit_of_measurement"] = unit; + if (icon != nullptr) + number["icon"] = icon; + + client.subscribe(number["command_topic"].as()); +} + +void addSwitch(JsonObject components, + const String &componentId, + const String &name, + const String &category, + const String &key, + const String &valueTemplate, + const char *icon = nullptr) +{ + JsonObject control = addComponent(components, componentId, "switch", name); + control["state_topic"] = stateTopic(category); + control["command_topic"] = commandTopic(category, key); + control["value_template"] = valueTemplate; + control["payload_on"] = "true"; + control["payload_off"] = "false"; + control["state_on"] = "true"; + control["state_off"] = "false"; + control["optimistic"] = false; + if (icon != nullptr) + control["icon"] = icon; + + client.subscribe(control["command_topic"].as()); +} + +String escapeTemplateKey(String value) +{ + value.replace("\\", "\\\\"); + value.replace("'", "\\'"); + return value; +} + +void addCoreComponents(JsonObject components) +{ + const String temperatureUnit = configuration.HomeAssistant.TempUnit; + + addSensor(components, "general_error", "Error", "General", + "{{ value_json.General.Error | int(default=0) }}", nullptr, "", "mdi:alert-circle-outline"); + + addDiagnosticSensor(components, "diagnostic_free_heap", "Free Heap", + "{{ value_json.General.FreeHeap | int(default=0) }}", "data_size", "B", + "mdi:memory", "measurement"); + addDiagnosticSensor(components, "diagnostic_heap_size", "Heap Size", + "{{ value_json.General.HeapSize | int(default=0) }}", "data_size", "B", + "mdi:memory", "measurement"); + addDiagnosticSensor(components, "diagnostic_filesystem_used", "Filesystem Used", + "{{ value_json.General.FilesystemUsed | int(default=0) }}", "data_size", "B", + "mdi:database", "measurement"); + addDiagnosticSensor(components, "diagnostic_filesystem_size", "Filesystem Size", + "{{ value_json.General.FilesystemSize | int(default=0) }}", "data_size", "B", + "mdi:database-outline", "measurement"); + addDiagnosticSensor(components, "diagnostic_flash_size", "Flash Size", + "{{ value_json.General.FlashSize | int(default=0) }}", "data_size", "B", + "mdi:chip", "measurement"); + addDiagnosticSensor(components, "diagnostic_chip_model", "Chip Model", + "{{ value_json.General.ChipModel | default('unknown') }}", nullptr, "", + "mdi:chip"); + addDiagnosticSensor(components, "diagnostic_chip_revision", "Chip Revision", + "{{ value_json.General.ChipRevision | default('unknown') }}", nullptr, "", + "mdi:counter"); + addDiagnosticSensor(components, "diagnostic_cpu_cores", "CPU Cores", + "{{ value_json.General.CpuCores | int(default=0) }}", nullptr, "", + "mdi:cpu-32-bit"); + addDiagnosticSensor(components, "diagnostic_cpu_frequency", "CPU Frequency", + "{{ value_json.General.CpuFrequency | int(default=0) }}", "frequency", "MHz", + "mdi:speedometer", "measurement"); + + addSensor(components, "heating_feed_current", "Current Feed Temperature", "Heating", + "{{ value_json.Heating.FeedCurrent | float(default=0) }}", "temperature", temperatureUnit, + "mdi:thermometer-water"); + addSensor(components, "heating_feed_maximum", "Maximum Feed Temperature", "Heating", + "{{ value_json.Heating.FeedMaximum | float(default=0) }}", "temperature", temperatureUnit, + "mdi:thermometer-high"); + addSensor(components, "heating_feed_setpoint", "Feed Setpoint Temperature", "Heating", + "{{ value_json.Heating.FeedSetpoint | float(default=0) }}", "temperature", temperatureUnit, + "mdi:thermometer-check"); + addSensor(components, "heating_outside", "Outside Temperature", "Heating", + "{{ value_json.Heating.Outside | float(default=0) }}", "temperature", temperatureUnit, + "mdi:sun-thermometer-outline"); + + addSensor(components, "water_maximum", "Maximum Water Temperature", "Water", + "{{ value_json.Water.Maximum | float(default=0) }}", "temperature", temperatureUnit, + "mdi:thermometer-high"); + addSensor(components, "water_current", "Current Water Temperature", "Water", + "{{ value_json.Water.Current | float(default=0) }}", "temperature", temperatureUnit, + "mdi:water-thermometer"); + addSensor(components, "water_setpoint", "Water Setpoint Temperature", "Water", + "{{ value_json.Water.Setpoint | float(default=0) }}", "temperature", temperatureUnit, + "mdi:thermometer-check"); + addSensor(components, "water_continuous_flow_setpoint", "Continuous Flow Setpoint", "Water", + "{{ value_json.Water.CFSetpoint | float(default=0) }}", "temperature", temperatureUnit, + "mdi:water-sync"); + + addBinarySensor(components, "general_gas_burner", "Flame Lit", "General", + "{{ value_json.General.GasBurner }}", nullptr, "mdi:fire"); + addBinarySensor(components, "heating_pump", "Heating Pump", "Heating", + "{{ value_json.Heating.Pump }}", "running", "mdi:pump"); + addBinarySensor(components, "heating_season", "Heating Season", "Heating", + "{{ value_json.Heating.Season }}", nullptr, "mdi:radiator"); + addBinarySensor(components, "heating_working", "Heating Operation", "Heating", + "{{ value_json.Heating.Working }}", "running", "mdi:heating-coil"); + addBinarySensor(components, "water_now", "Hot Water Now", "Water", + "{{ value_json.Water.Now }}", "running", "mdi:water-boiler"); + addBinarySensor(components, "water_buffer", "Hot Water Buffer Mode", "Water", + "{{ value_json.Water.Buffer }}", "running", "mdi:water-boiler-auto"); + + addNumber(components, "heating_requested_setpoint", "Requested Feed Setpoint", "Heating", "Setpoint", + "{{ value_json.Heating.RequestedFeedSetpoint | float(default=0) }}", 0, 100, 0.5, temperatureUnit, + "slider", "mdi:thermometer-chevron-up"); + addNumber(components, "heating_boost_duration", "Boost Duration", "Heating", "BoostDuration", + "{{ value_json.Heating.BoostDuration | int(default=0) }}", 0, 86400, 1, "s", "box", + "mdi:timer-outline"); + addNumber(components, "heating_room_reference", "Room Reference Temperature", "Heating", "RoomReferenceT", + "{{ value_json.Heating.RoomReferenceT | float(default=0) }}", -50, 100, 0.1, temperatureUnit, + "slider", "mdi:home-thermometer"); + + addSwitch(components, "heating_enabled", "Heating Enabled", "Heating", "Enabled", + "{{ value_json.Heating.Enabled }}", "mdi:radiator"); + addSwitch(components, "heating_boost", "Heating Boost", "Heating", "Boost", + "{{ value_json.Heating.Boost }}", "mdi:fire-circle"); + addSwitch(components, "heating_fast_heatup", "Fast Heatup", "Heating", "FastHeatup", + "{{ value_json.Heating.FastHeatup }}", "mdi:heat-wave"); } -void SetupAutodiscovery(const char *fileName) +void addAuxiliaryComponents(JsonObject components) { - if (!LittleFS.exists(fileName)) + for (size_t i = 0; i < configuration.TemperatureSensors.SensorCount; i++) { - Log.println("HA Autodiscovery file could not be found. Please upload it first."); - return; + const String label = configuration.TemperatureSensors.Sensors[i].Label; + const String id = sanitizeId(label); + const String templateKey = escapeTemplateKey(label); + + addSensor(components, "auxiliary_" + id + "_temperature", label + " Temperature", "Auxiliary", + "{{ value_json.Auxiliary['" + templateKey + "'].Temperature | float(default=0) }}", + "temperature", configuration.HomeAssistant.TempUnit, "mdi:thermometer-probe"); + addBinarySensor(components, "auxiliary_" + id + "_reachable", label + " Reachable", "Auxiliary", + "{{ value_json.Auxiliary['" + templateKey + "'].Reachable }}", "connectivity", + "mdi:lan-connect", "diagnostic"); } +} - File file = LittleFS.open(fileName); +bool parseNumber(const String &payload, double &value) +{ + char *end = nullptr; + value = strtod(payload.c_str(), &end); + return end != payload.c_str() && *end == '\0' && isfinite(value); +} - if (!file) +bool handleHomeAssistantCommand(const String &relativeTopic, const String &payload) +{ + if (relativeTopic == "Heating/Enabled/set" || + relativeTopic == "Heating/Boost/set" || + relativeTopic == "Heating/FastHeatup/set") { - Log.println("HA Autodiscovery file could not be loaded. Consider checking and reuploading it."); - return; - } + String normalizedPayload = payload; + normalizedPayload.toLowerCase(); + const bool enabled = normalizedPayload == "true" || normalizedPayload == "on" || normalizedPayload == "1"; + const bool disabled = normalizedPayload == "false" || normalizedPayload == "off" || normalizedPayload == "0"; + if (!enabled && !disabled) + { + Log.printf("Ignoring invalid HA switch payload on %s\r\n", relativeTopic.c_str()); + return true; + } - JsonDocument doc; + if (relativeTopic == "Heating/Enabled/set") + commandedValues.Heating.Active = enabled; + else if (relativeTopic == "Heating/Boost/set") + { + commandedValues.Heating.Boost = enabled; + commandedValues.Heating.BoostTimeCountdown = commandedValues.Heating.BoostDuration; + } + else + { + commandedValues.Heating.FastHeatup = enabled; + commandedValues.Heating.ReferenceAmbientTemperature = commandedValues.Heating.AmbientTemperature; + } - DeserializationError error = deserializeJson(doc, file); - file.close(); + SetFeedTemperature(); + NotifyValidHeatingCommand(); + Log.printf("Applied HA command: %s\r\n", relativeTopic.c_str()); + PublishHeatingTemperaturesAndStatus(); + return true; + } - JsonObject sensors = doc.as(); + double value = 0; + if (!parseNumber(payload, value)) + { + Log.printf("Ignoring invalid HA number payload on %s\r\n", relativeTopic.c_str()); + return true; + } - if (error) + if (relativeTopic == "Heating/Setpoint/set") { - Log.print("deserializeJson() failed: "); - Log.println(error.c_str()); - return; + commandedValues.Heating.FeedSetpoint = constrain(value, 0.0, 100.0); + commandedValues.Heating.OverrideSetpoint = true; + SetFeedTemperature(); + } + else if (relativeTopic == "Heating/BoostDuration/set") + { + commandedValues.Heating.BoostDuration = constrain(static_cast(value), 0, 86400); + } + else if (relativeTopic == "Heating/RoomReferenceT/set") + { + commandedValues.Heating.AmbientTemperature = constrain(value, -50.0, 100.0); + SetFeedTemperature(); } - if (configuration.General.Debug) - Log.println("///----- Reading HA AD Config -----"); - // Sensor Type Category Block: Sensor, Binary Sensor, ... - for (JsonPair SensorCategory : sensors) + else { - if (configuration.General.Debug) - Log.println(SensorCategory.key().c_str()); - // Sensor Device Specific Category like Heating, Water, ... - JsonObject CurCategoryObj = doc[SensorCategory.key().c_str()].as(); + return false; + } - for (JsonPair InternalDevCategory : CurCategoryObj) - { - if (configuration.General.Debug) - Log.printf("\t%s\r\n", InternalDevCategory.key().c_str()); - // Specific Internal Device Category Config - JsonArray CurSensorObject = CurCategoryObj[InternalDevCategory.key().c_str()].as(); - - for (JsonObject SensorConfig : CurSensorObject) - { - String curKey; - for (JsonPair curPair : SensorConfig) - { - curKey = curPair.key().c_str(); - break; - } - if (configuration.General.Debug) - Log.printf("\t\t%s\r\n", curKey.c_str()); - - JsonObject CurrentSensor = SensorConfig[curKey]; - String discoveryTopic = configuration.HomeAssistant.AutoDiscoveryPrefix + "/" + SensorCategory.key().c_str() + "/" + configuration.HomeAssistant.DeviceId + "/" + curKey + "/config"; - // Replace any whitespaces by dashes - String label = CurrentSensor["Label"].as(); - label.replace(" ", "-"); - if (label.length() == 0) - { - label = curKey; - } - // Topic Abbreviation - String baseTopic = configuration.HomeAssistant.StateTopic; - baseTopic += InternalDevCategory.key().c_str(); - CurrentSensor["~"] = baseTopic; - // Set the default state topic only if it hasn't been defined (special cases for numbers, switches) - const char *stat_t = CurrentSensor["stat_t"]; - if (!stat_t) - CurrentSensor["stat_t"] = "~/state"; - CurrentSensor["name"] = configuration.HomeAssistant.DeviceId + "_" + label; - CurrentSensor["uniq_id"] = configuration.HomeAssistant.DeviceId + "_" + curKey; - CurrentSensor["off_delay"] = configuration.HomeAssistant.OffDelay; - // Remove "Label" Value because it isn't specified for HA AD - CurrentSensor.remove("Label"); - - // Subscribe to cmd topic, if set. - const char *cmd_t = CurrentSensor["cmd_t"]; - if (cmd_t) - { - // //set - String cmdTopic = baseTopic; - cmdTopic += "/"; - cmdTopic += curKey; - cmdTopic += CurrentSensor["cmd_t"].as(); - cmdTopic.replace("~", ""); - client.subscribe(cmdTopic.c_str()); - - // Assemble a specific command topic from the key: ~//set - cmdTopic = CurrentSensor["cmd_t"].as(); - cmdTopic.replace("~", ""); - CurrentSensor["cmd_t"] = "~/" + curKey + cmdTopic; - } - - // Sensor is assembled. We have to transmit this config to HA now in order to get it working - char buffer[768]; - size_t n = serializeJson(CurrentSensor, buffer); - if (configuration.General.Debug) - { - Log.println(discoveryTopic); - serializeJsonPretty(CurrentSensor, Serial); - } - client.publish(discoveryTopic.c_str(), buffer, n); - } - } + NotifyValidHeatingCommand(); + Log.printf("Applied HA command: %s\r\n", relativeTopic.c_str()); + PublishHeatingTemperaturesAndStatus(); + return true; +} +} // namespace + +void PublishHomeAssistantAvailability(bool online) +{ + if (MUTE_MQTT == 1 || !configuration.HomeAssistant.Enabled || !client.connected()) + return; + + const String topic = availabilityTopic(); + client.publish(topic.c_str(), online ? "online" : "offline", true); +} + +void SetupHomeAssistantDiscovery() +{ + if (MUTE_MQTT == 1 || !configuration.HomeAssistant.Enabled || !client.connected()) + return; + + client.subscribe(homeAssistantStatusTopic().c_str()); + + JsonDocument doc; + JsonObject root = doc.to(); + + JsonObject device = root["dev"].to(); + device["ids"] = configuration.HomeAssistant.DeviceId; + device["name"] = configuration.HomeAssistant.DeviceId; + device["mf"] = "Cerasmarter"; + device["mdl"] = "Cerasmart-er"; + device["sw"] = softwareVersion(); + + JsonObject origin = root["o"].to(); + origin["name"] = "Cerasmarter"; + origin["sw"] = softwareVersion(); + origin["url"] = "https://github.com/Neuroquila-n8fall/JunkersControl"; + + root["availability_topic"] = availabilityTopic(); + root["payload_available"] = "online"; + root["payload_not_available"] = "offline"; + root["qos"] = 0; + + JsonObject components = root["cmps"].to(); + addCoreComponents(components); + addAuxiliaryComponents(components); + + String payload; + serializeJson(doc, payload); + const String topic = discoveryTopic(); + const bool published = client.publish(topic.c_str(), payload.c_str(), true); + PublishHomeAssistantAvailability(true); + + Log.printf("Home Assistant device discovery %s (%u components, %u bytes).\r\n", + published ? "published" : "failed", + static_cast(components.size()), + static_cast(payload.length())); +} + +bool HandleHomeAssistantMessage(const char *topic, const String &payload) +{ + if (!configuration.HomeAssistant.Enabled) + return false; + + if (homeAssistantStatusTopic() == topic) + { + if (payload == "online") + SetupHomeAssistantDiscovery(); + return true; } - if (configuration.General.Debug) - Log.println("----- HA AD Config END -----///"); + const String statePrefix = configuration.HomeAssistant.StateTopic; + const String messageTopic = topic; + if (!messageTopic.startsWith(statePrefix) || !messageTopic.endsWith("/set")) + return false; + return handleHomeAssistantCommand(messageTopic.substring(statePrefix.length()), payload); } diff --git a/src/mqtt.cpp b/src/mqtt.cpp index 73f70bc..6f1b1b7 100644 --- a/src/mqtt.cpp +++ b/src/mqtt.cpp @@ -32,27 +32,40 @@ void reconnectMqtt() Log.print("Attempting MQTT connection..."); - String clientId = generateClientId(); - if (client.connect(clientId.c_str(), configuration.Mqtt.User, configuration.Mqtt.Password)) + const String clientId = generateClientId(); + bool connected = false; + if (configuration.HomeAssistant.Enabled) { - Log.println("connected"); - - client.subscribe(configuration.Mqtt.Topics.HeatingParameters); - client.subscribe(configuration.Mqtt.Topics.WaterParameters); - client.subscribe(configuration.Mqtt.Topics.StatusRequest); - client.subscribe(configuration.Mqtt.Topics.Boost); - client.subscribe(configuration.Mqtt.Topics.FastHeatup); - if (configuration.HomeAssistant.Enabled) - { - SetupAutodiscovery(HaSensorsFileName); - SetupAutodiscovery(HaBinarySensorsFileName); - SetupAutodiscovery(HaNumbersFileName); - } + const String availability = configuration.HomeAssistant.StateTopic + "availability"; + connected = client.connect(clientId.c_str(), + configuration.Mqtt.User, + configuration.Mqtt.Password, + availability.c_str(), + 0, + true, + "offline"); } else + { + connected = client.connect(clientId.c_str(), configuration.Mqtt.User, configuration.Mqtt.Password); + } + + if (!connected) { Log.printf("failed, rc=%d. Retrying in %lu seconds while CAN control continues.\r\n", client.state(), mqttRetryInterval / 1000); + return; + } + + Log.println("connected"); + client.subscribe(configuration.Mqtt.Topics.HeatingParameters); + client.subscribe(configuration.Mqtt.Topics.WaterParameters); + client.subscribe(configuration.Mqtt.Topics.StatusRequest); + client.subscribe(configuration.Mqtt.Topics.Boost); + client.subscribe(configuration.Mqtt.Topics.FastHeatup); + if (configuration.HomeAssistant.Enabled) + { + SetupHomeAssistantDiscovery(); } } @@ -78,6 +91,8 @@ void setupMqttClient() // The ESP TCP connect and MQTT CONNACK waits must not stall boiler control. espClient.setConnectionTimeout(250); client.setSocketTimeout(1); + if (!client.setBufferSize(16384)) + Log.println("Unable to allocate the MQTT buffer required for Home Assistant discovery."); } String boolToString(bool src) @@ -93,49 +108,8 @@ void callback(char *topic, byte *payload, unsigned int length) payloadBuf.reserve(length); for (unsigned int i = 0; i < length; i++) payloadBuf += static_cast(payload[i]); - - /* - NOTE: This is supposed to be in the HA branch. - */ - //TopicBuf = topic; -// - //// Command Topics for HA auto discovery. - //if (TopicBuf.endsWith(F("/set"))) - //{ - // WriteToConsoles("Received SET Topic: "); - // WriteToConsoles(TopicBuf); - // WriteToConsoles("\r\n"); - // // Remove prefixes - // TopicBuf.replace(configuration.HomeAssistant.AutoDiscoveryPrefix + "/",""); - // TopicBuf.replace(configuration.HomeAssistant.DeviceId + "/",""); - // // Try to get the category - // String category = TopicBuf.substring(0,TopicBuf.indexOf('/')); - // category.replace("/",""); - // // Remove Category from string - // TopicBuf.replace(category,""); - // TopicBuf.replace(F("/set"),""); - // String parameterName = TopicBuf.substring(TopicBuf.lastIndexOf('/'),TopicBuf.length()); - // parameterName.replace(F("/"),""); -// - // WriteToConsoles("Received Values for Category: "); - // WriteToConsoles(category); - // WriteToConsoles(" Parameter Name: "); - // WriteToConsoles(parameterName); - // WriteToConsoles(" Payload: "); - // WriteToConsoles(PayloadBuf); - // WriteToConsoles("\r\n"); - // - // // Setting Values coming from HA. - // // NOTE: This is all hardcoded on purpose as we have no means of determining which variable is targeted - // if(category == "Heating") - // { - // if(parameterName == "BoostDuration") - // { -// - // } - // } - //} - + if (HandleHomeAssistantMessage(topic, payloadBuf)) + return; // Status Requested if (strcmp(topic, configuration.Mqtt.Topics.StatusRequest) == 0) @@ -259,7 +233,6 @@ void callback(char *topic, byte *payload, unsigned int length) commandedValues.Heating.OverrideSetpoint = doc["OverrideSetpoint"]; if (!doc["OnDemandBoostDuration"].isNull()) commandedValues.Heating.BoostDuration = doc["OnDemandBoostDuration"]; - const bool containsHeatingCommand = !doc["Enabled"].isNull() || !doc["FeedSetpoint"].isNull() || !doc["FeedBaseSetpoint"].isNull() || !doc["FeedCutOff"].isNull() || @@ -273,7 +246,6 @@ void callback(char *topic, byte *payload, unsigned int length) NotifyValidHeatingCommand(); } - // Receiving Water Parameters if (strcmp(topic, configuration.Mqtt.Topics.WaterParameters) == 0) { @@ -343,6 +315,18 @@ void PublishStatus() jsonObj["GasBurner"] = boolToString(ceraValues.General.FlameLit); jsonObj["Error"] = ceraValues.General.Error; + jsonObj["FreeHeap"] = ESP.getFreeHeap(); + jsonObj["HeapSize"] = ESP.getHeapSize(); + jsonObj["FilesystemUsed"] = LittleFS.usedBytes(); + jsonObj["FilesystemSize"] = LittleFS.totalBytes(); + jsonObj["FlashSize"] = ESP.getFlashChipSize(); + jsonObj["ChipModel"] = ESP.getChipModel(); + const uint16_t chipRevision = ESP.getChipRevision(); + char chipRevisionText[8]; + snprintf(chipRevisionText, sizeof(chipRevisionText), "%u.%u", chipRevision / 100, chipRevision % 100); + jsonObj["ChipRevision"] = chipRevisionText; + jsonObj["CpuCores"] = ESP.getChipCores(); + jsonObj["CpuFrequency"] = ESP.getCpuFreqMHz(); // Mute Flag Set. Don't send message. if (MUTE_MQTT == 1) @@ -356,7 +340,7 @@ void PublishStatus() if (configuration.HomeAssistant.Enabled) { String topic = configuration.HomeAssistant.StateTopic + "General/state"; - client.publish(topic.c_str(), buffer, n); + client.publish(topic.c_str(), reinterpret_cast(buffer), n, true); } else { @@ -400,6 +384,10 @@ void PublishHeatingTemperaturesAndStatus() jsonObj["Boost"] = boolToString(commandedValues.Heating.Boost); jsonObj["BoostTimeLeft"] = commandedValues.Heating.BoostTimeCountdown; jsonObj["FastHeatup"] = boolToString(commandedValues.Heating.FastHeatup); + jsonObj["Enabled"] = boolToString(commandedValues.Heating.Active); + jsonObj["RequestedFeedSetpoint"] = commandedValues.Heating.FeedSetpoint; + jsonObj["BoostDuration"] = commandedValues.Heating.BoostDuration; + jsonObj["RoomReferenceT"] = commandedValues.Heating.AmbientTemperature; // Mute Flag Set. Don't send message. if (MUTE_MQTT == 1) @@ -413,7 +401,7 @@ void PublishHeatingTemperaturesAndStatus() if (configuration.HomeAssistant.Enabled) { String topic = configuration.HomeAssistant.StateTopic + "Heating/state"; - client.publish(topic.c_str(), buffer, n); + client.publish(topic.c_str(), reinterpret_cast(buffer), n, true); } else { @@ -464,7 +452,7 @@ void PublishWaterTemperatures() if (configuration.HomeAssistant.Enabled) { String topic = configuration.HomeAssistant.StateTopic + "Water/state"; - client.publish(topic.c_str(), buffer, n); + client.publish(topic.c_str(), reinterpret_cast(buffer), n, true); } else { @@ -514,7 +502,7 @@ void PublishAuxiliaryTemperatures() if (configuration.HomeAssistant.Enabled) { String topic = configuration.HomeAssistant.StateTopic + "Auxiliary/state"; - client.publish(topic.c_str(), buffer, n); + client.publish(topic.c_str(), reinterpret_cast(buffer), n, true); } else { diff --git a/src/webconfig.cpp b/src/webconfig.cpp index 5db4e25..3f1d80e 100644 --- a/src/webconfig.cpp +++ b/src/webconfig.cpp @@ -48,12 +48,16 @@ static bool validateConfigurationFile(const char *path, String &errorMessage) return true; } -static void sendConfigurationSaveResult(AsyncWebServerRequest *request, const char *successMessage) +static bool sendConfigurationSaveResult(AsyncWebServerRequest *request, const char *successMessage) { if (WriteConfiguration()) + { request->send(200, "application/json", successMessage); - else - request->send(500, "application/json", R"({"status":500,"msg":"Configuration could not be saved."})"); + return true; + } + + request->send(500, "application/json", R"({"status":500,"msg":"Configuration could not be saved."})"); + return false; } void StartApMode() @@ -445,6 +449,20 @@ void configureMqttEndpoints() mqttTopicsRcvHandler->setMethod(HTTP_POST); server->addHandler(mqttTopicsRcvHandler); + + server->on("/api/config/homeassistant", HTTP_GET, [](AsyncWebServerRequest *request) + { getHomeAssistantConfig(request); }); + + auto *homeAssistantRcvHandler = + new AsyncCallbackJsonWebHandler( + "/api/config/homeassistant", + [](AsyncWebServerRequest *request, JsonVariant &json) + { + onHomeAssistantConfigReceive(request, json); + }); + + homeAssistantRcvHandler->setMethod(HTTP_POST); + server->addHandler(homeAssistantRcvHandler); } void getMqttConfig(AsyncWebServerRequest *request) @@ -538,6 +556,94 @@ void onMqttTopicConfigReceive(AsyncWebServerRequest *request, JsonVariant &json) sendConfigurationSaveResult(request, R"({"status":200, "msg":"MQTT Topics have been saved."})"); } +void getHomeAssistantConfig(AsyncWebServerRequest *request) +{ + JsonDocument doc; + doc["enabled"] = configuration.HomeAssistant.Enabled; + doc["discovery-prefix"] = configuration.HomeAssistant.AutoDiscoveryPrefix; + doc["device-id"] = configuration.HomeAssistant.DeviceId; + doc["temperature-unit"] = configuration.HomeAssistant.TempUnit; + doc["off-delay"] = configuration.HomeAssistant.OffDelay; + doc["state-topic"] = configuration.HomeAssistant.StateTopic; + sendJson(doc, request); +} + +void onHomeAssistantConfigReceive(AsyncWebServerRequest *request, JsonVariant &json) +{ + if (!json.is()) + { + request->send(400, "application/json", R"({"status":400, "msg":"Expected a JSON object."})"); + return; + } + + JsonObject doc = json.as(); + if (doc["enabled"].isNull() || + doc["discovery-prefix"].isNull() || + doc["device-id"].isNull() || + doc["temperature-unit"].isNull() || + doc["off-delay"].isNull()) + { + request->send(400, "application/json", R"({"status":400, "msg":"Missing Home Assistant configuration fields."})"); + return; + } + + String discoveryPrefix = doc["discovery-prefix"].as(); + discoveryPrefix.trim(); + while (discoveryPrefix.endsWith("/")) + discoveryPrefix.remove(discoveryPrefix.length() - 1); + + String deviceId = doc["device-id"].as(); + deviceId.trim(); + deviceId.replace(" ", "_"); + deviceId.replace("/", "_"); + + String temperatureUnit = doc["temperature-unit"].as(); + temperatureUnit.trim(); + const int offDelay = doc["off-delay"].as(); + + if (discoveryPrefix.isEmpty() || discoveryPrefix.indexOf('#') >= 0 || discoveryPrefix.indexOf('+') >= 0 || + deviceId.isEmpty() || temperatureUnit.isEmpty() || offDelay < 0) + { + request->send(400, "application/json", R"({"status":400, "msg":"Invalid discovery prefix, device ID, temperature unit, or off delay."})"); + return; + } + + const bool previousEnabled = configuration.HomeAssistant.Enabled; + const String previousDiscoveryPrefix = configuration.HomeAssistant.AutoDiscoveryPrefix; + const String previousDeviceId = configuration.HomeAssistant.DeviceId; + const String previousStateTopic = configuration.HomeAssistant.StateTopic; + + bool enabled = false; + if (doc["enabled"].is()) + enabled = doc["enabled"].as(); + else + { + String enabledValue = doc["enabled"].as(); + enabledValue.toLowerCase(); + enabled = enabledValue == "true" || enabledValue == "1" || enabledValue == "on"; + } + + configuration.HomeAssistant.Enabled = enabled; + configuration.HomeAssistant.AutoDiscoveryPrefix = discoveryPrefix; + configuration.HomeAssistant.DeviceId = deviceId; + configuration.HomeAssistant.TempUnit = temperatureUnit; + configuration.HomeAssistant.OffDelay = offDelay; + configuration.HomeAssistant.StateTopic = "junkerscontrol/" + deviceId + "/"; + + if (sendConfigurationSaveResult(request, R"({"status":200, "msg":"Home Assistant configuration has been saved. MQTT will reconnect automatically."})")) + { + const bool identityChanged = previousDiscoveryPrefix != discoveryPrefix || previousDeviceId != deviceId; + if (previousEnabled && (!enabled || identityChanged) && client.connected()) + { + const String previousAvailabilityTopic = previousStateTopic + "availability"; + const String previousDiscoveryTopic = previousDiscoveryPrefix + "/device/" + previousDeviceId + "/config"; + client.publish(previousAvailabilityTopic.c_str(), "offline", true); + client.publish(previousDiscoveryTopic.c_str(), "", true); + } + client.disconnect(); + } +} + #pragma endregion #pragma region "Firmware Related" From 04c287830dbf5c730a99c7b102826337ee984b1c Mon Sep 17 00:00:00 2001 From: Neuroquila Date: Sat, 18 Jul 2026 23:57:34 +0200 Subject: [PATCH 02/10] feat: modernize Cerasmarter web UI and runtime controls Add categorized English and German translations, persistent theme support, dashboard diagnostics, CAN value labels, and browser-based fallback controls. Preserve configuration during filesystem updates and align documentation and release artifacts with the revised UI. --- .github/workflows/create-release.yml | 2 +- Changelog.md | 15 ++- README.md | 24 +++- assets/Configuration.md | 2 +- data/frontend/auxsensors.html | 2 + data/frontend/canalyzer.html | 33 +++-- data/frontend/canbus.html | 4 +- data/frontend/control.html | 32 +++++ data/frontend/css/theme.css | 135 +++++++++++++++++++++ data/frontend/filemanager.html | 14 ++- data/frontend/firmware.html | 4 +- data/frontend/general.html | 2 + data/frontend/i18n/de.json | 22 ++++ data/frontend/i18n/en.json | 22 ++++ data/frontend/index.html | 175 ++++++--------------------- data/frontend/js/control.js | 29 +++++ data/frontend/js/dashboard.js | 70 +++++++++++ data/frontend/js/filemanager.js | 8 +- data/frontend/js/firmware.js | 14 ++- data/frontend/js/framework.js | 7 +- data/frontend/js/i18n.js | 115 ++++++++++++++++++ data/frontend/leds.html | 2 + data/frontend/mqtt.html | 86 ++++++------- data/frontend/navigation.html | 46 ++++--- data/frontend/reboot.html | 6 +- data/frontend/wifi.html | 4 +- gzip-data.py | 4 +- include/configuration.h | 4 + include/mqtt.h | 7 +- src/configuration.cpp | 136 ++++++++++++++++++++- src/main.cpp | 4 + src/mqtt.cpp | 113 +++++++++-------- src/webconfig.cpp | 175 +++++++++++++++++++++++++-- 33 files changed, 1018 insertions(+), 300 deletions(-) create mode 100644 data/frontend/control.html create mode 100644 data/frontend/css/theme.css create mode 100644 data/frontend/i18n/de.json create mode 100644 data/frontend/i18n/en.json create mode 100644 data/frontend/js/control.js create mode 100644 data/frontend/js/dashboard.js create mode 100644 data/frontend/js/i18n.js diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index cce7c7c..af0ee3e 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -58,7 +58,7 @@ jobs: - name: Archive release binaries uses: actions/upload-artifact@v7 with: - name: JunkersControl-${{ env.RELEASE_TAG }} + name: Cerasmarter-${{ env.RELEASE_TAG }} path: | .pio/build/github/firmware.bin .pio/build/github/littlefs.bin diff --git a/Changelog.md b/Changelog.md index ee1a47c..c8b4458 100644 --- a/Changelog.md +++ b/Changelog.md @@ -2,6 +2,19 @@ ## Unreleased +- Added a live home dashboard for boiler, heating, hot-water, connectivity, and fail-safe state, including a dependency-free five-minute temperature chart. +- Restored percentage progress bars for RAM and application flash usage, and added a separate LittleFS usage indicator to the dashboard. +- Made the manual configuration reload action prominent in the file manager and clarified when it is still required. +- Added complete web fallback controls for every MQTT-controlled runtime value and consolidated both transports behind the same command handlers. +- Added `GET /api/runtime` and `POST /api/control` for current values and immediate heating control. +- Preserved valid device configuration automatically across LittleFS images uploaded through the web interface by using a one-shot NVS backup and boot-time restore. Raw serial filesystem flashing remains outside firmware control. +- Added English and German web-interface localization with automatic browser-language defaults and persisted preferences. +- Reworked localization into one JSON resource per language with maintainable, area-based keys and explicit `data-i18n` attribute support, following the structure documented by the Javascript i18n core project. +- Added a persistent light/dark appearance switch to every web-interface page, with the operating-system preference used as the initial default. +- Replaced the dark-mode text control with a compact sun/moon icon and normalized MQTT form columns so translated labels wrap without displacing inputs. +- Completed the Cerasmarter branding migration by renaming Home Assistant runtime topics to `cerasmarter//...` and the local provisioning override to `CERASMARTER_CONFIG_FILE`. +- Renamed generated GitHub release artifacts to `Cerasmarter-`; historical repository URLs remain unchanged because the GitHub repository itself has not been renamed. +- Enhanced the CAN message analyzer to show the configured controller value name beside every known CAN address and captured message. - Replaced the incomplete legacy Home Assistant integration with current MQTT device discovery using one retained device payload. - Removed manual Home Assistant YAML and filesystem discovery-template files. - Added discovery for heating, hot-water, controller-status, and dynamic auxiliary-temperature entities under one device. @@ -11,7 +24,7 @@ - Added Home Assistant configuration to the web interface and automatic MQTT reconnection after saving it. - Added cleanup of retained discovery records when Home Assistant discovery is disabled or its device identity changes. - Added meaningful Home Assistant icons for every discovered entity and changed the burner flame entity to explicit on/off semantics with a flame icon. -- Changed the Home Assistant device manufacturer and discovery origin branding from JunkersControl to Cerasmarter. +- Changed the Home Assistant device manufacturer and discovery origin branding to Cerasmarter. - Added Home Assistant diagnostic entities for heap memory, filesystem and flash storage, chip model and revision, CPU cores, CPU frequency, and auxiliary-sensor connectivity. - Fixed MQTT command handling, including the previously unreachable hot-water parameter handler and unsafe callback payload termination. - Prevented stale generated filesystem files from leaking into release images and made preprocessing failures stop the build. diff --git a/README.md b/README.md index ec9661b..98f52ac 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# JunkersControl +# Cerasmarter [![CI](https://github.com/Neuroquila-n8fall/JunkersControl/actions/workflows/build-master.yml/badge.svg)](https://github.com/Neuroquila-n8fall/JunkersControl/actions/workflows/build-master.yml) ## NOTE: Documentation is mostly accurate right now. **Feel free to open an issue if something is unclear.** ## Table of contents -- [JunkersControl](#junkerscontrol) +- [Cerasmarter](#cerasmarter) - [NOTE: Documentation is mostly accurate right now.](#note-documentation-is-mostly-accurate-right-now) - [Table of contents](#table-of-contents) - [Community](#community) @@ -137,6 +137,18 @@ Now you can connect to the AP ("CERASMARTER" network by default) and modify/impo If MDNS is working properly on your end, you will be able to open the web UI using http://cerasmarter/ +### Web interface preferences + +The navigation bar provides English and German language selection and a sun/moon light/dark appearance toggle. Both preferences are stored in the current browser and apply to every web-interface page. On first use, the browser language selects German when appropriate, and the operating-system color preference selects the initial appearance. + +Translations are maintained as one JSON resource per locale in `data/frontend/i18n/` (`en.json`, `de.json`). Keys are grouped by their UI area, for example `menu.home`, `dashboard.system_status`, `mqtt.prefix`, and `filemanager.reload`. Markup can opt into automatic translation with `data-i18n="area.key"`; translated attributes use `data-i18n-placeholder`, `data-i18n-title`, or `data-i18n-aria-label`. JavaScript uses `translate("area.key", values)`. English is the fallback locale, and new locales only require a new resource plus an entry in `UiLocales`. + +The CAN Message Analyzer under **Utilities** reads the active CAN address configuration and displays names such as `Heating · Feed Current` beside their hexadecimal IDs. This makes captures usable with customized address maps while unknown IDs remain clearly identified. + +The home page is a live heating dashboard. It shows burner, feed, outside, and hot-water values plus a lightweight five-minute temperature chart that runs entirely in the browser without an external chart service. **Utilities > Fallback Heating Control** exposes every runtime command accepted through MQTT, including heating-curve, room-reference, boost, fast-heatup, valve-scaling, and hot-water controls. These commands take effect immediately, refresh the fail-safe command lease, and are deliberately not written to `configuration.json`; MQTT or Home Assistant can replace them later. + +The dashboard data is also available as JSON from `GET /api/runtime`. Runtime commands can be sent as a partial JSON object to `POST /api/control`, using the same field names as the heating MQTT payload plus `Boost`, `FastHeatup`, and `HotWaterSetpoint`. Both transports use the same firmware command handlers. + ## Features ### MQTT @@ -388,8 +400,8 @@ The standard "Arduino OTA" procedure is included which means you can upload the You can also use the web UI (See: Firmware Update on the menu bar) to upload the `firmware.bin` and `littlefs.bin` files to update the firmware and filesystem image. - A firmware-only update preserves the existing LittleFS configuration. -- Before uploading `littlefs.bin`, download `/configuration.json` from the file manager as a backup. Replacing the filesystem erases the active configuration and installs the credential-free template. -- After a filesystem update, upload the backup as `/configuration.json` and click **Reload Configuration**. A successful reload activates WiFi, MQTT, CAN, auxiliary-sensor, and LED settings without requiring a power cycle. Without a backup, connect to the `CERASMARTER` setup access point and configure the template values through the web UI. +- A `littlefs.bin` update performed through the web interface validates and copies the active configuration to the separate NVS partition before replacing LittleFS. On the next boot it restores the configuration once, before normal configuration loading. The update is cancelled if a safe backup cannot be made. +- Keep a downloaded `/configuration.json` backup for recovery. Direct PlatformIO, esptool, or programmer-based filesystem flashing cannot be intercepted by the running firmware and therefore still installs the credential-free template. After such an update, upload the backup as `/configuration.json` and click **Reload Configuration**. - Configuration saves from the web UI are written atomically and verified before they replace the previous file. If validation or writing fails, the previous configuration remains available as a backup. ## Telnet Console @@ -412,7 +424,7 @@ Memory, filesystem and flash capacity, chip model and revision, CPU core count, Discovery assigns purpose-specific Material Design icons to every entity. The **Flame Lit** binary sensor intentionally has no Home Assistant device class: it therefore reports plain **On/Off** while using a flame icon whose active color follows the burner state. MQTT discovery supports one static icon per entity, so using different custom glyphs for its on and off states would require a separate Home Assistant template entity. -Runtime state and command topics use `junkerscontrol//...`, independently of the discovery prefix. Discovery and state messages are retained, and MQTT Last Will availability marks the device offline if its connection is lost. The controller also listens for Home Assistant's `/status` birth message and republishes discovery when Home Assistant restarts. +Runtime state and command topics use `cerasmarter//...`, independently of the discovery prefix. Discovery and state messages are retained, and MQTT Last Will availability marks the device offline if its connection is lost. The controller also listens for Home Assistant's `/status` birth message and republishes discovery when Home Assistant restarts. Keep `DeviceId` stable after discovery. If it or the discovery prefix is changed through the web interface, the controller removes its previous retained discovery record and reconnects with the new identity. An old record only needs manual removal if the broker was unavailable during the change. @@ -435,7 +447,7 @@ See the [configuration guide](assets/Configuration.md#home-assistant) for all se Do not place a real `configuration.json` in the repository's `data` directory when preparing releases. The filesystem build always copies the credential-free file from `assets/Templates/Configurations/configuration.json`; a device-specific file could contain WiFi and MQTT credentials. -For local USB provisioning only, set `JUNKERSCONTROL_CONFIG_FILE` to an external configuration file while running the `buildfs`/`uploadfs` targets. This stages that file in the generated image without adding it to the repository. The GitHub release workflow never sets this override and verifies that release images use the credential-free template. +For local USB provisioning only, set `CERASMARTER_CONFIG_FILE` to an external configuration file while running the `buildfs`/`uploadfs` targets. This stages that file in the generated image without adding it to the repository. The GitHub release workflow never sets this override and verifies that release images use the credential-free template. ### Configuration diff --git a/assets/Configuration.md b/assets/Configuration.md index 3e4b484..e240e92 100644 --- a/assets/Configuration.md +++ b/assets/Configuration.md @@ -272,7 +272,7 @@ Cerasmarter supports native MQTT device discovery. No manual Home Assistant YAML - `TempUnit` is used by all discovered temperature entities. Normally this is `°C` or `°F`. - `OffDelay` is the number of seconds after which an active binary sensor returns to off without a newer active state. Set it to `0` to disable this behavior. -The controller publishes one retained device-discovery payload to `/device//config`. Runtime data uses `junkerscontrol//...`; changing the discovery prefix does not change state or command topics. +The controller publishes one retained device-discovery payload to `/device//config`. Runtime data uses `cerasmarter//...`; changing the discovery prefix does not change state or command topics. Treat `DeviceId` as stable after Home Assistant has discovered the controller. When the ID or discovery prefix is changed through the web interface, the controller removes its previous retained discovery record before reconnecting with the new identity. If the broker is unavailable during that change, remove the old device or retained discovery topic manually. diff --git a/data/frontend/auxsensors.html b/data/frontend/auxsensors.html index d4624aa..cf1a574 100644 --- a/data/frontend/auxsensors.html +++ b/data/frontend/auxsensors.html @@ -8,6 +8,7 @@ CERASMARTER Auxiliary Sensors Configuration + @@ -81,6 +82,7 @@

Configured Sensors

+ diff --git a/data/frontend/canalyzer.html b/data/frontend/canalyzer.html index 303d2d2..c6a0a0d 100644 --- a/data/frontend/canalyzer.html +++ b/data/frontend/canalyzer.html @@ -8,6 +8,7 @@ CERASMARTER Can Analyzer + @@ -54,6 +55,9 @@

Watch CAN Messages

ID + Value name + 1 @@ -91,6 +95,7 @@

Watch CAN Messages

+ @@ -101,6 +106,7 @@

Watch CAN Messages

loadKnownAddresses(); let messages = []; let knownAddresses = []; + const knownAddressNames = new Map(); let unknownAddresses = []; let previousMessage = []; @@ -116,18 +122,28 @@

Watch CAN Messages

const addresses = await getConfigJson("/api/config/canbus"); const keys = getDeepKeys(addresses); keys.forEach(e => { - let value = jsonPathToValue(addresses, e); - knownAddresses.push(value); + const value = String(jsonPathToValue(addresses, e)).toUpperCase(); + if (!knownAddressNames.has(value)) knownAddressNames.set(value, []); + knownAddressNames.get(value).push(formatCanValueName(e)); + if (!knownAddresses.includes(value)) knownAddresses.push(value); }) knownAddresses.sort(); knownAddresses.forEach((e) => { _("known-addresses").innerHTML += `
- +
`; }) } + function formatCanValueName(path) { + return path.split('.').map(part => part.replace(/([a-z0-9])([A-Z])/g, '$1 $2')).join(' · '); + } + + function getCanValueName(id) { + return (knownAddressNames.get(id.toUpperCase()) || []).join(' / '); + } + function onSwitchAllAddresses() { knownAddresses.forEach((e) => { _(`${e}-label`).checked = _("ignoreKnown").checked; @@ -150,12 +166,12 @@

Watch CAN Messages

function addToKnownAddress(id) { const msgId = `0x${id.toUpperCase()}`; const found = unknownAddresses.findIndex((e) => e === msgId); - unknownAddresses.slice(found, 1); + if (found > -1) unknownAddresses.splice(found, 1); _(`${id.toLowerCase()}-unknown`).remove(); knownAddresses.push(`0x${id}`); _("known-addresses").innerHTML += `
- +
` } @@ -168,7 +184,7 @@

Watch CAN Messages

const msgId = `0x${id.toUpperCase()}`; const found = knownAddresses.findIndex((e) => e === msgId); if (found > -1) { - unknownAddresses.slice(found, 1); + knownAddresses.splice(found, 1); _(`${id.toLowerCase()}-enabled`).remove(); } unknownAddresses.push(`0x${id.toUpperCase()}`); @@ -259,6 +275,7 @@

Watch CAN Messages

${canIdKnown(`0x${msgId}`) ? "" : "*"} 0x${msgId.toUpperCase()} + ${getCanValueName(`0x${msgId}`)} ${msgData} `; if (hasDifferentValues) { @@ -274,7 +291,7 @@

Watch CAN Messages

diffMsg += `${d === 0 ? "" : numberWithPrefix(d) + "(dec)"}`; }) _("can-msg").innerHTML += ` - + Δ ${diffMsg} @@ -305,4 +322,4 @@

Watch CAN Messages

}, false); } - \ No newline at end of file + diff --git a/data/frontend/canbus.html b/data/frontend/canbus.html index 0c2ed95..81b1d0d 100644 --- a/data/frontend/canbus.html +++ b/data/frontend/canbus.html @@ -8,6 +8,7 @@ CERASMARTER CAN-Bus + @@ -186,6 +187,7 @@
Mixed Circuit
+ @@ -222,4 +224,4 @@
Mixed Circuit
} - \ No newline at end of file + diff --git a/data/frontend/control.html b/data/frontend/control.html new file mode 100644 index 0000000..cdd37a6 --- /dev/null +++ b/data/frontend/control.html @@ -0,0 +1,32 @@ + +Cerasmarter - Fallback Heating Control +
+

Fallback Heating Control

+
Immediate control
These runtime values are sent directly to the heating controller. They are not saved to configuration.json and may later be replaced by MQTT or Home Assistant commands.
+
+
Heating
+
+
°C
+
°C
+
+
+
+
+
+
+
Room and boost
+
°C
+
°C
+
°C
+
s
+
+
+
+
Valve scaling
+
+
%
+
%
+
+
Hot Water
°C
+
+
diff --git a/data/frontend/css/theme.css b/data/frontend/css/theme.css new file mode 100644 index 0000000..c5aa554 --- /dev/null +++ b/data/frontend/css/theme.css @@ -0,0 +1,135 @@ +:root { + color-scheme: light; +} + +html[data-theme="dark"] { + color-scheme: dark; + --ui-bg: #12161c; + --ui-surface: #1b222c; + --ui-surface-alt: #252e3a; + --ui-text: #e8edf2; + --ui-muted: #aeb8c4; + --ui-border: #3b4654; +} + +html[data-theme="dark"] body { + background-color: var(--ui-bg); + color: var(--ui-text); +} + +html[data-theme="dark"] .card, +html[data-theme="dark"] .list-group-item, +html[data-theme="dark"] .modal-content, +html[data-theme="dark"] .dropdown-menu, +html[data-theme="dark"] .table, +html[data-theme="dark"] .form-control, +html[data-theme="dark"] .form-select, +html[data-theme="dark"] .input-group-text { + background-color: var(--ui-surface); + color: var(--ui-text); + border-color: var(--ui-border); +} + +html[data-theme="dark"] .dropdown-item, +html[data-theme="dark"] .form-check-label, +html[data-theme="dark"] code { + color: var(--ui-text); +} + +html[data-theme="dark"] .dropdown-item:hover, +html[data-theme="dark"] .dropdown-item:focus, +html[data-theme="dark"] .dropdown-item.active { + background-color: var(--ui-surface-alt); + color: #fff; +} + +html[data-theme="dark"] .table > :not(caption) > * > * { + background-color: transparent; + color: var(--ui-text); + border-color: var(--ui-border); +} + +html[data-theme="dark"] .table-light, +html[data-theme="dark"] .bg-light { + --bs-table-bg: var(--ui-surface-alt); + background-color: var(--ui-surface-alt) !important; + color: var(--ui-text) !important; +} + +html[data-theme="dark"] .table-warning { + --bs-table-bg: #4b3b18; + --bs-table-color: #fff2c7; +} + +html[data-theme="dark"] .table-info { + --bs-table-bg: #173f4b; + --bs-table-color: #d8f7ff; +} + +html[data-theme="dark"] .text-muted, +html[data-theme="dark"] .form-text { + color: var(--ui-muted) !important; +} + +html[data-theme="dark"] .form-control:disabled, +html[data-theme="dark"] .form-control[readonly] { + background-color: #2c3541; +} + +html[data-theme="dark"] .btn-close { + filter: invert(1) grayscale(100%) brightness(180%); +} + +.ui-preferences { + display: flex; + align-items: center; + gap: .75rem; + padding: .35rem .75rem; +} + +.ui-preferences .form-select { + min-width: 7.5rem; +} + +.theme-toggle-button { + align-items: center; + display: inline-flex; + height: 2rem; + justify-content: center; + padding: 0; + width: 2rem; +} + +.theme-icon-dark { + display: none; +} + +#ui-dark-mode:checked + .theme-toggle-button .theme-icon-light { + display: none; +} + +#ui-dark-mode:checked + .theme-toggle-button .theme-icon-dark { + display: inline-block; +} + +.mqtt-config-form .col-form-label { + overflow-wrap: anywhere; + hyphens: auto; +} + +.mqtt-config-form .row > [class*="col-sm-"] { + min-width: 0; +} + +.runtime-card .display-6 { font-size: 1.8rem; } +.runtime-card .bi { opacity: .7; } +.runtime-chart { height: 230px; width: 100%; } +.runtime-chart canvas { height: 100%; width: 100%; } +.control-form .card { height: 100%; } +.control-form .form-check { min-height: 2rem; } + +@media (min-width: 992px) { + .ui-preferences { + padding: 0; + } +} diff --git a/data/frontend/filemanager.html b/data/frontend/filemanager.html index b8b0e7f..95da933 100644 --- a/data/frontend/filemanager.html +++ b/data/frontend/filemanager.html @@ -8,6 +8,7 @@ Manage Files + @@ -77,12 +78,16 @@

File Manager

+
+
+
Reload Configuration
+

Use this after manually uploading or replacing /configuration.json. The file is validated before the controller restarts.

+
+ +
+
-
-
@@ -91,6 +96,7 @@

File Manager

+ diff --git a/data/frontend/firmware.html b/data/frontend/firmware.html index ddcf5e2..8c5b5fc 100644 --- a/data/frontend/firmware.html +++ b/data/frontend/firmware.html @@ -8,6 +8,7 @@ CERASMARTER Firmware + @@ -59,6 +60,7 @@

Update Firmware & Filesystem

+ @@ -68,4 +70,4 @@

Update Firmware & Filesystem

_("upload_form").addEventListener('submit',startUpdate); - \ No newline at end of file + diff --git a/data/frontend/general.html b/data/frontend/general.html index a250d57..1505eff 100644 --- a/data/frontend/general.html +++ b/data/frontend/general.html @@ -8,6 +8,7 @@ CERASMARTER Configuration + @@ -139,6 +140,7 @@

Offline Fail-safe

+ - - - - - - \ No newline at end of file +
+
Temperature History
Last five minutes
+
+
Current Feed Temperature   Feed Setpoint   Outside Temperature
+
+ +
+
    +
  • Model
  • +
  • RAM
  • +
  • Application Flash
  • +
  • Filesystem
  • +
  • CAN-Bus Module
  • +
  • CAN-Bus Errors
  • +
  • MQTT Status
  • +
+
+
+ + + diff --git a/data/frontend/js/control.js b/data/frontend/js/control.js new file mode 100644 index 0000000..6e9fcd7 --- /dev/null +++ b/data/frontend/js/control.js @@ -0,0 +1,29 @@ +loadNavigation(); +const controlForm = _("control-form"); +const booleanFields = new Set(["Enabled", "OverrideSetpoint", "DynamicAdaption", "ValveScaling", "Boost", "FastHeatup"]); + +async function loadControlValues() { + try { + const runtime = await fetch('/api/runtime').then(r => r.json()); + Object.entries(runtime.Command).forEach(([key, value]) => { + const el = _(key); if (!el) return; + if (el.type === 'checkbox') el.checked = Boolean(value); else el.value = value; + }); + } catch (error) { showControlStatus(false, "Control values could not be loaded."); } +} + +controlForm.addEventListener('submit', async event => { + event.preventDefault(); + const payload = {}; + new FormData(controlForm).forEach((value, key) => payload[key] = booleanFields.has(key) ? true : Number(value)); + booleanFields.forEach(key => payload[key] = _(key).checked); + try { + const response = await fetch('/api/control', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)}); + const result = await response.json(); + if (!response.ok) throw new Error(result.msg || response.statusText); + showControlStatus(true, "Control values applied."); + await loadControlValues(); + } catch (error) { showControlStatus(false, error.message); } +}); +function showControlStatus(ok, message) { _("control-status").innerHTML = `
${translate(message)}
`; } +loadControlValues(); diff --git a/data/frontend/js/dashboard.js b/data/frontend/js/dashboard.js new file mode 100644 index 0000000..8d3929f --- /dev/null +++ b/data/frontend/js/dashboard.js @@ -0,0 +1,70 @@ +const dashboardSamples = []; +loadNavigation(); +i18nReady.then(() => { + refreshDashboard(); + setInterval(refreshDashboard, 5000); +}); + +function temperature(value) { return Number.isFinite(Number(value)) ? `${Number(value).toFixed(1)} °C` : "--"; } +function setBadge(id, ok, good, bad) { + const el = _(id); el.textContent = translate(ok ? good : bad); + el.className = `badge ${ok ? "bg-success" : "bg-danger"}`; +} + +async function refreshDashboard() { + try { + const [runtime, status] = await Promise.all([fetch('/api/runtime').then(r => r.json()), getSystemStatus()]); + _( "outside").textContent = temperature(runtime.General.OutsideTemperature); + _("feed-current").textContent = temperature(runtime.Heating.FeedCurrent); + _("feed-target").textContent = temperature(runtime.Heating.CalculatedFeedSetpoint); + _("water-current").textContent = temperature(runtime.HotWater.Current); + _("water-target").textContent = temperature(runtime.HotWater.Setpoint); + _("flame").textContent = translate(runtime.General.FlameLit ? "common.on" : "common.off"); + _("flame-icon").className = `bi bi-fire ${runtime.General.FlameLit ? "text-danger" : "text-muted"}`; + _("power").textContent = `${runtime.Heating.Power}%`; + const healthy = runtime.System.Wifi && runtime.System.CanErrors === 0; + _("runtime-status").textContent = translate(runtime.System.FailSafe ? "dashboard.failsafe" : (healthy ? "dashboard.live" : "dashboard.degraded")); + _("runtime-status").className = `badge ${runtime.System.FailSafe ? "bg-warning text-dark" : (healthy ? "bg-success" : "bg-danger")}`; + dashboardSamples.push([runtime.Heating.FeedCurrent, runtime.Heating.CalculatedFeedSetpoint, runtime.General.OutsideTemperature]); + if (dashboardSamples.length > 60) dashboardSamples.shift(); + drawChart(); + _("model").textContent = `${status.model} r${status.revision} · ${status.cores} cores`; + const usedHeap = status.heap - status.freeheap; + setUsageBar("heap", "prog-heap", usedHeap, status.heap); + setUsageBar("sketch", "prog-sketch", status.sketchsize, status.freesketch); + setUsageBar("filesystem", "prog-filesystem", status.filesystemused, status.filesystemsize); + const canOk = status.canstatus === 0; + setBadge("can", canOk, "common.connected", CanErrorCodes[status.canstatus] || "common.error"); + setBadge("canerrorcount", status.canerrorcount === 0, "0", String(status.canerrorcount)); + setBadge("mqtt", status.mqtt, "common.connected", "common.disconnected"); + } catch (error) { + _("runtime-status").textContent = translate("common.unavailable"); + _("runtime-status").className = "badge bg-danger"; + } +} + +function setUsageBar(labelId, barId, used, total) { + const percent = total > 0 ? Math.min(100, used / total * 100) : 0; + _(labelId).textContent = `${humanReadableSize(used)} / ${humanReadableSize(total)} · ${percent.toFixed(1)}%`; + const bar = _(barId); + bar.style.width = `${percent}%`; + bar.textContent = `${Math.round(percent)}%`; + bar.setAttribute('aria-valuenow', percent.toFixed(1)); + bar.className = `progress-bar ${percent >= 90 ? 'bg-danger' : (percent >= 75 ? 'bg-warning text-dark' : 'bg-primary')}`; +} + +function drawChart() { + const canvas = _("temperature-chart"), rect = canvas.getBoundingClientRect(), scale = devicePixelRatio || 1; + canvas.width = rect.width * scale; canvas.height = rect.height * scale; + const ctx = canvas.getContext('2d'); ctx.scale(scale, scale); + const w = rect.width, h = rect.height, pad = 25; + const values = dashboardSamples.flat().filter(Number.isFinite); + const min = Math.min(...values, 0) - 2, max = Math.max(...values, 50) + 2; + ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--ui-border') || '#dee2e6'; ctx.lineWidth = 1; + for (let i = 0; i < 5; i++) { const y = pad + i * (h - 2 * pad) / 4; ctx.beginPath(); ctx.moveTo(pad, y); ctx.lineTo(w - pad, y); ctx.stroke(); } + ['#dc3545','#0d6efd','#198754'].forEach((color, series) => { + ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.beginPath(); + dashboardSamples.forEach((sample, i) => { const x = pad + i * (w - 2 * pad) / Math.max(59, dashboardSamples.length - 1); const y = h - pad - (sample[series] - min) * (h - 2 * pad) / (max - min); i ? ctx.lineTo(x,y) : ctx.moveTo(x,y); }); ctx.stroke(); + }); +} +window.addEventListener('resize', drawChart); diff --git a/data/frontend/js/filemanager.js b/data/frontend/js/filemanager.js index 56ab547..daf42ab 100644 --- a/data/frontend/js/filemanager.js +++ b/data/frontend/js/filemanager.js @@ -28,8 +28,8 @@ function listFiles(path) { if (!isDir) { table += `${fileName}`; table += `${humanReadableSize(size)}`; - table += ``; - table += ``; + table += ``; + table += ``; } else { table += `
dir
${fileName}`; table += ``; @@ -157,7 +157,7 @@ function renameFile(originalFile, newName) { } function progressHandler(event) { - _("loaded_n_total").innerHTML = "Uploaded " + humanReadableSize(event.loaded); + _("loaded_n_total").innerHTML = translate("Uploaded") + " " + humanReadableSize(event.loaded); const percent = (event.loaded / event.total) * 100; const roundedPercent = Math.round(percent); _("progressBar").style = "width: " + roundedPercent + "%;"; @@ -221,7 +221,7 @@ async function reloadConfiguration() { if (!response.ok) { throw new Error(result.msg || "Configuration validation failed."); } - _("statusdetails").innerHTML = `${result.msg} The device will be unavailable briefly.`; + _("statusdetails").innerHTML = `${result.msg} ${translate("The device will be unavailable briefly.")}`; } catch (error) { _("statusdetails").innerHTML = ``; button.disabled = false; diff --git a/data/frontend/js/firmware.js b/data/frontend/js/firmware.js index c01c7a8..87664fb 100644 --- a/data/frontend/js/firmware.js +++ b/data/frontend/js/firmware.js @@ -17,7 +17,7 @@ function startUpdate(event) { function progressHandler(event) { //_("loaded_n_total").innerHTML = "Uploaded " + event.loaded + " bytes of " + event.total; // event.total doesnt show accurate total file size - _("loaded_n_total").innerHTML = "Uploaded " + humanReadableSize(event.loaded); + _("loaded_n_total").innerHTML = translate("Uploaded") + " " + humanReadableSize(event.loaded); const percent = (event.loaded / event.total) * 100; const roundedPercent = Math.round(percent); _("progressBar").style = "width: " + roundedPercent + "%;"; @@ -32,8 +32,14 @@ function completeHandler(event) { _("progressBar").style.width = 0; _("progressBar").setAttribute('aria-valuenow', 0); _("progressBar").innerHTML = "0%"; - _("status").innerHTML = ``; _("upload_form").reset(); _("upload_form").hidden = false; -} \ No newline at end of file +} diff --git a/data/frontend/js/framework.js b/data/frontend/js/framework.js index 8da678a..b28faae 100644 --- a/data/frontend/js/framework.js +++ b/data/frontend/js/framework.js @@ -48,7 +48,8 @@ function loadNavigation() { } catch (error) { console.log("Missing Nav-link to activate."); } - + applyUiTheme(); + applyTranslations(nav); } } @@ -156,9 +157,9 @@ function serializeForm(formId) { } function rebootButton() { - _("statusdetails").innerHTML = "Invoking Reboot ..."; + _("statusdetails").innerHTML = translate("Invoking Reboot ..."); const xhr = new XMLHttpRequest(); xhr.open("GET", "/reboot", true); xhr.send(); window.open("/reboot", "_self"); -} \ No newline at end of file +} diff --git a/data/frontend/js/i18n.js b/data/frontend/js/i18n.js new file mode 100644 index 0000000..0e77f53 --- /dev/null +++ b/data/frontend/js/i18n.js @@ -0,0 +1,115 @@ +const UiLocales = ["en", "de"]; +const UiResources = {}; +let UiReverseEnglish = new Map(); +let UiTranslationObserver; +const UiOriginalText = new WeakMap(); +const UiOriginalAttributes = new WeakMap(); + +function getUiLanguage() { + const saved = localStorage.getItem("ui-language"); + if (UiLocales.includes(saved)) return saved; + return navigator.language && navigator.language.toLowerCase().startsWith("de") ? "de" : "en"; +} + +function flattenResource(source, prefix = "", result = {}) { + Object.entries(source || {}).forEach(([name, value]) => { + const key = prefix ? `${prefix}.${name}` : name; + if (value && typeof value === "object" && !Array.isArray(value)) flattenResource(value, key, result); + else result[key] = value; + }); + return result; +} + +function resolveTranslation(locale, key) { + return key.split(".").reduce((value, part) => value && value[part], UiResources[locale]); +} + +function translate(key, values = {}) { + const resourceKey = resolveTranslation("en", key) !== undefined ? key : UiReverseEnglish.get(key); + let result = resourceKey ? resolveTranslation(getUiLanguage(), resourceKey) : undefined; + if (result === undefined && resourceKey) result = resolveTranslation("en", resourceKey); + if (result === undefined) result = key; + Object.entries(values).forEach(([name, value]) => result = result.replaceAll(`{${name}}`, value)); + return result; +} + +function translateElement(element) { + if (!element || element.nodeType !== Node.ELEMENT_NODE) return; + const key = element.dataset.i18n; + if (key) element.textContent = translate(key); + const attributeKeys = {"placeholder":"i18nPlaceholder", "title":"i18nTitle", "aria-label":"i18nAriaLabel"}; + Object.entries(attributeKeys).forEach(([attribute, datasetKey]) => { + const attributeKey = element.dataset[datasetKey]; + if (attributeKey) { + element.setAttribute(attribute, translate(attributeKey)); + return; + } + if (!element.hasAttribute(attribute)) return; + let originals = UiOriginalAttributes.get(element); + if (!originals) { originals = {}; UiOriginalAttributes.set(element, originals); } + if (!(attribute in originals)) originals[attribute] = element.getAttribute(attribute); + element.setAttribute(attribute, translate(originals[attribute])); + }); + element.querySelectorAll("[data-i18n], [data-i18n-placeholder], [data-i18n-title], [data-i18n-aria-label]").forEach(child => { + if (child !== element) translateElement(child); + }); +} + +function translateLegacyText(root) { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const nodes = []; + while (walker.nextNode()) nodes.push(walker.currentNode); + nodes.forEach(node => { + if (!node.parentElement || node.parentElement.closest("script, style, code, [data-i18n]")) return; + if (!UiOriginalText.has(node)) UiOriginalText.set(node, node.nodeValue); + const original = UiOriginalText.get(node); + const trimmed = original.trim().replace(/\s+/g, " "); + if (!UiReverseEnglish.has(trimmed)) { node.nodeValue = original; return; } + node.nodeValue = original.match(/^\s*/)[0] + translate(UiReverseEnglish.get(trimmed)) + original.match(/\s*$/)[0]; + }); +} + +function applyTranslations(root = document.documentElement) { + document.documentElement.lang = getUiLanguage(); + translateElement(root); + translateLegacyText(root); + const selector = document.getElementById("ui-language"); + if (selector) selector.value = getUiLanguage(); +} + +function setUiLanguage(language) { + localStorage.setItem("ui-language", UiLocales.includes(language) ? language : "en"); + applyTranslations(); + document.dispatchEvent(new CustomEvent("ui-language-changed")); +} + +function getUiTheme() { + const saved = localStorage.getItem("ui-theme"); + if (saved === "dark" || saved === "light") return saved; + return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} +function applyUiTheme(theme = getUiTheme()) { + document.documentElement.setAttribute("data-theme", theme); + const toggle = document.getElementById("ui-dark-mode"); + if (toggle) toggle.checked = theme === "dark"; +} +function setUiTheme(dark) { + const theme = dark ? "dark" : "light"; + localStorage.setItem("ui-theme", theme); + applyUiTheme(theme); +} + +const i18nReady = Promise.all(UiLocales.map(async locale => { + const response = await fetch(`/frontend/i18n/${locale}.json`); + if (!response.ok) throw new Error(`Unable to load ${locale} translations (${response.status}).`); + UiResources[locale] = await response.json(); +})).then(() => { + UiReverseEnglish = new Map(Object.entries(flattenResource(UiResources.en)).map(([key, value]) => [value, key])); + applyTranslations(); + UiTranslationObserver = new MutationObserver(mutations => mutations.forEach(mutation => mutation.addedNodes.forEach(node => { + if (node.nodeType === Node.ELEMENT_NODE) { translateElement(node); translateLegacyText(node); } + }))); + UiTranslationObserver.observe(document.documentElement, {childList: true, subtree: true}); +}).catch(error => console.error("i18n initialization failed:", error)); + +applyUiTheme(); diff --git a/data/frontend/leds.html b/data/frontend/leds.html index 0f602ed..76845a7 100644 --- a/data/frontend/leds.html +++ b/data/frontend/leds.html @@ -8,6 +8,7 @@ CERASMARTER LED Configuration + @@ -90,6 +91,7 @@

LED Configuration

+ diff --git a/data/frontend/mqtt.html b/data/frontend/mqtt.html index b565fad..45c2522 100644 --- a/data/frontend/mqtt.html +++ b/data/frontend/mqtt.html @@ -8,6 +8,7 @@ CERASMARTER MQTT Configuration + @@ -28,32 +29,32 @@

Server Configuration

-
+
- -
+ +
- -
+ +
- -
+ +
- -
+ +
@@ -62,7 +63,7 @@

Server Configuration

-
+
-
+
- -
+ +
Must match the MQTT discovery prefix configured in Home Assistant.
- -
+ +
Use a unique value for every controller on the MQTT broker. Changing it replaces the controller's discovered device.
- -
+ +
- -
+ +
Seconds before an active binary sensor resets; use 0 to disable.
- -
+ +
-
+
@@ -137,10 +138,10 @@

Topics Configuration

-
+
- -
+ +
This topic is used for @@ -148,8 +149,8 @@

Topics Configuration

- -
+ +
@@ -158,9 +159,9 @@

Topics Configuration

-