Created
September 20, 2026 20:00
-
-
Save PhilippMundhenk/25a4a52b384748b7ba18c34e240c37a7 to your computer and use it in GitHub Desktop.
Kettler Golf M ergometer on ESP32: ESPHome config + Home Assistant session logging and Matrix training reports (mundhenk.org)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # ===================================================================== | |
| # Training reports, delivered to Matrix. Fully templated - no LLM. | |
| # rest_command kept for future experiments; nothing calls it. | |
| # ===================================================================== | |
| # ADJUST for your setup: | |
| # - notify.matrix_fitness: your notify service (Matrix or any other) | |
| # - rest_command url: your Ollama host (only needed for LLM experiments) | |
| # - sensor.body_log: from kettler_session.yaml (optional) | |
| # ===================================================================== | |
| rest_command: | |
| ollama_generate: | |
| url: "http://ollama.local:11434/api/chat" | |
| method: POST | |
| content_type: "application/json" | |
| timeout: 600 | |
| payload: > | |
| { | |
| "model": "{{ model | default('qwen2.5:7b', true) }}", | |
| "messages": [ | |
| {"role": "system", "content": {{ (system | default('You write short factual reports.', true)) | to_json }}}, | |
| {"role": "user", "content": {{ (prompt | default('', true)) | to_json }}} | |
| ], | |
| "stream": false, | |
| "think": false, | |
| "options": { | |
| "temperature": 0.2, "top_p": 0.9, | |
| "num_ctx": 8192, | |
| "num_predict": {{ max_tokens | default(220) }} | |
| } | |
| } | |
| script: | |
| # ================================================================= | |
| # POST-RIDE | |
| # ================================================================= | |
| post_ride_report: | |
| alias: Post-ride report | |
| mode: single | |
| sequence: | |
| - variables: | |
| r: "{{ (state_attr('sensor.kettler_ride_history','rides') or [{}])[-1] }}" | |
| prev: "{{ ((state_attr('sensor.kettler_ride_history','rides') or [])[-2:-1] or [{}])[0] }}" | |
| samples: "{{ state_attr('sensor.kettler_session_log','samples') or [] }}" | |
| mx: "{{ states('sensor.hr_max_estimate') | float(180) }}" | |
| body: "{{ state_attr('sensor.body_log','days') or [] }}" | |
| hrr1: "{{ states('sensor.hrr1') | int(0) }}" | |
| hrr2: "{{ states('sensor.hrr2') | int(0) }}" | |
| pause_min: > | |
| {% set ns = namespace(f=-1, l=-1) %} | |
| {% for s in samples %} | |
| {% if s.rpm > 5 %} | |
| {% if ns.f < 0 %}{% set ns.f = loop.index0 %}{% endif %} | |
| {% set ns.l = loop.index0 %} | |
| {% endif %} | |
| {% endfor %} | |
| {{ (samples[ns.f:ns.l+1] | selectattr('rpm','le',5) | list | count) * 2 | |
| if ns.f >= 0 else 0 }} | |
| drift: > | |
| {% set act = samples | selectattr('rpm','gt',5) | selectattr('hr','gt',40) | list %} | |
| {% set n = act | count %} | |
| {% if n >= 6 %} | |
| {% set q = (n / 3) | int %} | |
| {{ ((act[-q:] | map(attribute='hr') | list | average(0)) | |
| - (act[:q] | map(attribute='hr') | list | average(0))) | round(0) | int }} | |
| {% else %}0{% endif %} | |
| pct: "{{ (r.get('hr_avg', 0) | float(0) / (mx | float(180)) * 100) | round(0) | int }}" | |
| zone: >- | |
| {% set p = pct | int %} | |
| {%- if p < 60 %}Recovery | |
| {%- elif p < 70 %}Endurance | |
| {%- elif p < 80 %}Tempo | |
| {%- elif p < 90 %}Threshold | |
| {%- else %}Maximal{% endif %} | |
| hrr_label: >- | |
| {% set v = hrr1 | int %} | |
| {%- if v >= 30 %}excellent | |
| {%- elif v >= 20 %}good | |
| {%- elif v >= 12 %}fair | |
| {%- else %}weak{% endif %} | |
| body_text: >- | |
| {% set ns = namespace(l=[]) %} | |
| {% set ns.l = ns.l + [ | |
| zone ~ " ride: avg HR " ~ r.hr_avg ~ " (" ~ pct ~ "% of max " ~ (mx|int) ~ | |
| "), peak " ~ r.hr_max ~ ", cadence " ~ r.rpm_avg ~ " rpm, level " ~ r.lvl_avg ~ "." | |
| ] %} | |
| {% if drift | int > 3 %} | |
| {% set ns.l = ns.l + [ | |
| "HR drifted +" ~ (drift|int) ~ " bpm at steady resistance - normal fluid loss and heat over " ~ r.min ~ " min." | |
| ] %} | |
| {% elif drift | int < -3 %} | |
| {% set ns.l = ns.l + [ | |
| "HR fell " ~ ((drift|int)|abs) ~ " bpm through the ride - you warmed up hot or eased off later." | |
| ] %} | |
| {% else %} | |
| {% set ns.l = ns.l + ["HR held steady across the ride - well-paced effort."] %} | |
| {% endif %} | |
| {% if pause_min | int > 2 %} | |
| {% set ns.l = ns.l + [ | |
| "Rest of ~" ~ pause_min ~ " min mid-ride; HR dropped, then returned to load." | |
| ] %} | |
| {% endif %} | |
| {% set ns.l = ns.l + [ | |
| "Recovery: " ~ hrr1 ~ " bpm at 1 min (" ~ hrr_label ~ "), " ~ hrr2 ~ " bpm at 2 min. Above 20 is strong." | |
| ] %} | |
| {% if prev.get('min') %} | |
| {% set dmin = r.min - prev.min %} | |
| {% set dhr = (r.hr_avg - prev.hr_avg) | round(0) | int %} | |
| {% set dkm = (r.km - prev.km) | round(1) %} | |
| {% set ns.l = ns.l + [ | |
| "vs " ~ prev.date ~ ": " ~ (dmin|abs) ~ " min " ~ ("longer" if dmin > 0 else "shorter") ~ | |
| ", " ~ (dkm|abs) ~ " km " ~ ("further" if dkm > 0 else "shorter") ~ | |
| ", HR " ~ (dhr|abs) ~ " bpm " ~ ("higher" if dhr > 0 else "lower") ~ "." | |
| ] %} | |
| {% if (r.lvl_avg - prev.lvl_avg) | abs < 0.6 %} | |
| {% if dhr < -2 %} | |
| {% set ns.l = ns.l + ["Same resistance, lower HR - fitness trending up."] %} | |
| {% elif dhr > 2 %} | |
| {% set ns.l = ns.l + ["Same resistance, higher HR - less rested, or warmer room."] %} | |
| {% endif %} | |
| {% else %} | |
| {% set ns.l = ns.l + ["Resistance differed, so the HR change is not a fitness signal."] %} | |
| {% endif %} | |
| {% else %} | |
| {% set ns.l = ns.l + ["First recorded ride - this becomes your baseline."] %} | |
| {% endif %} | |
| {% if body | count >= 8 and body[-1].kg > 0 and body[-8].kg > 0 %} | |
| {% set dkg = (body[-1].kg - body[-8].kg) | round(1) %} | |
| {% set ns.l = ns.l + [ | |
| "Weight " ~ ("+" if dkg > 0 else "") ~ dkg ~ " kg over 7 days - mostly hydration at this scale." | |
| ] %} | |
| {% endif %} | |
| {% if r.min < 30 %} | |
| {% set ns.l = ns.l + ["Next: aim for 35 min at this resistance before adding load."] %} | |
| {% elif hrr1 | int < 12 %} | |
| {% set ns.l = ns.l + ["Next: keep the same load; weak recovery suggests rest, not more volume."] %} | |
| {% elif pct | int < 65 %} | |
| {% set ns.l = ns.l + ["Next: try one level higher for 10 min to lift the average."] %} | |
| {% else %} | |
| {% set ns.l = ns.l + ["Next: add 5 min at the same level."] %} | |
| {% endif %} | |
| {{ ns.l | join('\n') }} | |
| - action: notify.matrix_fitness | |
| data: | |
| message: | | |
| 🚴 {{ r.date }} · {{ r.min }} min · {{ r.km }} km | |
| {{ body_text }} | |
| # ================================================================= | |
| # WEEKLY | |
| # ================================================================= | |
| weekly_fitness_report: | |
| alias: Weekly fitness report | |
| mode: single | |
| sequence: | |
| - variables: | |
| week: > | |
| {{ (state_attr('sensor.kettler_ride_history','rides') or []) | |
| | selectattr('date','ge', (now() - timedelta(days=7)).strftime('%Y-%m-%d')) | |
| | list }} | |
| prev_week: > | |
| {{ (state_attr('sensor.kettler_ride_history','rides') or []) | |
| | selectattr('date','ge', (now() - timedelta(days=14)).strftime('%Y-%m-%d')) | |
| | selectattr('date','lt', (now() - timedelta(days=7)).strftime('%Y-%m-%d')) | |
| | list }} | |
| body: "{{ state_attr('sensor.body_log','days') or [] }}" | |
| body_text: >- | |
| {% set ns = namespace(l=[]) %} | |
| {% set n = week | count %} | |
| {% set pn = prev_week | count %} | |
| {% set mins = week | sum(attribute='min') %} | |
| {% set pmins = prev_week | sum(attribute='min') %} | |
| {% if n == 0 %} | |
| {% set ns.l = ns.l + ["No rides this week. Last week: " ~ pn ~ " rides, " ~ pmins ~ " min."] %} | |
| {% else %} | |
| {% set ns.l = ns.l + [ | |
| "Volume: " ~ n ~ " rides, " ~ mins ~ " min, " ~ (week|sum(attribute='km')|round(1)) ~ " km" ~ | |
| (" (last week " ~ pn ~ " rides, " ~ pmins ~ " min)." if pn > 0 else ", no rides last week.") | |
| ] %} | |
| {% set hr = (week | map(attribute='hr_avg') | list | average(0)) | round(0) | int %} | |
| {% set lvl = (week | map(attribute='lvl_avg') | list | average(0)) | round(1) %} | |
| {% if pn > 0 %} | |
| {% set phr = (prev_week | map(attribute='hr_avg') | list | average(0)) | round(0) | int %} | |
| {% set plvl = (prev_week | map(attribute='lvl_avg') | list | average(0)) | round(1) %} | |
| {% set ns.l = ns.l + [ | |
| "Intensity: avg HR " ~ hr ~ " at level " ~ lvl ~ " (last week " ~ phr ~ " at level " ~ plvl ~ ")." | |
| ] %} | |
| {% if (lvl - plvl) | abs < 0.6 %} | |
| {% if hr < phr - 1 %} | |
| {% set ns.l = ns.l + ["Same load, HR " ~ (phr - hr) ~ " bpm lower - aerobic fitness improving."] %} | |
| {% elif hr > phr + 1 %} | |
| {% set ns.l = ns.l + ["Same load, HR " ~ (hr - phr) ~ " bpm higher - fatigue or poor sleep."] %} | |
| {% else %} | |
| {% set ns.l = ns.l + ["Same load, same HR - holding steady."] %} | |
| {% endif %} | |
| {% else %} | |
| {% set ns.l = ns.l + ["Resistance changed between weeks, so HR is not comparable."] %} | |
| {% endif %} | |
| {% else %} | |
| {% set ns.l = ns.l + ["Intensity: avg HR " ~ hr ~ " at level " ~ lvl ~ "."] %} | |
| {% endif %} | |
| {% set hrr = (week | map(attribute='hrr1') | list | average(0)) | round(0) | int %} | |
| {% set ns.l = ns.l + [ | |
| "Recovery: HRR1 averaged " ~ hrr ~ " bpm" ~ | |
| (" (strong)." if hrr >= 20 else (" (fair)." if hrr >= 12 else " (weak - prioritise rest).")) | |
| ] %} | |
| {% set ns.l = ns.l + [ | |
| "Longest ride: " ~ (week | map(attribute='min') | max) ~ " min. Energy: " ~ | |
| (week | sum(attribute='kcal')) ~ " kcal." | |
| ] %} | |
| {% endif %} | |
| {% if body | count >= 28 and body[-1].kg > 0 and body[-28].kg > 0 %} | |
| {% set dkg = (body[-1].kg - body[-28].kg) | round(1) %} | |
| {% set dfat = (body[-1].fat - body[-28].fat) | round(1) %} | |
| {% set dmus = (body[-1].muscle - body[-28].muscle) | round(1) %} | |
| {% set ns.l = ns.l + [ | |
| "Body, 4 weeks: weight " ~ ("+" if dkg > 0 else "") ~ dkg ~ " kg, fat " ~ | |
| ("+" if dfat > 0 else "") ~ dfat ~ " pp, muscle " ~ ("+" if dmus > 0 else "") ~ dmus ~ " kg." | |
| ] %} | |
| {% else %} | |
| {% set ns.l = ns.l + ["Body: not enough history yet for a four-week trend."] %} | |
| {% endif %} | |
| {% if n == 0 %} | |
| {% set ns.l = ns.l + ["Next week: one 30 min ride is enough to restart the habit."] %} | |
| {% elif n < 3 %} | |
| {% set ns.l = ns.l + ["Next week: three rides. Frequency matters more than length right now."] %} | |
| {% elif (week | map(attribute='min') | max) < 45 %} | |
| {% set ns.l = ns.l + ["Next week: keep three rides, push one to 50 min."] %} | |
| {% else %} | |
| {% set ns.l = ns.l + ["Next week: hold the volume, add one level of resistance to a single ride."] %} | |
| {% endif %} | |
| {{ ns.l | join('\n') }} | |
| - action: notify.matrix_fitness | |
| data: | |
| message: | | |
| 📊 Weekly review · {{ week | count }} rides · {{ week | sum(attribute='min') }} min | |
| {{ body_text }} | |
| automation: | |
| - alias: "Fitness: post-ride report" | |
| id: fitness_post_ride_report | |
| mode: single | |
| triggers: | |
| - trigger: state | |
| entity_id: sensor.kettler_ride_history | |
| conditions: | |
| - condition: template | |
| value_template: "{{ trigger.to_state.state | int(0) > trigger.from_state.state | int(0) }}" | |
| actions: | |
| - delay: "00:05:00" | |
| - action: script.post_ride_report | |
| - alias: "Fitness: weekly report" | |
| id: fitness_weekly_report | |
| mode: single | |
| triggers: | |
| - trigger: time | |
| at: "09:00:00" | |
| conditions: | |
| - condition: time | |
| weekday: [sun] | |
| actions: | |
| - action: script.weekly_fitness_report |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # ===================================================================== | |
| # ESPHome: kettler v4.1.0 — CONSOLE-LESS | |
| # | |
| # Pot JST: | |
| # GPIO25 = LOW | |
| # GPIO33 = wiper / ADC | |
| # GPIO32 = HIGH | |
| # | |
| # Reed: | |
| # GPIO15 = signal | |
| # GND = other side | |
| # ===================================================================== | |
| # ADJUST for your setup: | |
| # - secrets: wifi_ssid, wifi_password, api_key_kettler, ota_password | |
| # - ble_client mac_address: your heart-rate strap's BLE MAC | |
| # - load v_min / v_max: measure YOUR wiper voltage at dial min and max | |
| # ===================================================================== | |
| esphome: | |
| name: kettler | |
| friendly_name: Kettler Ergometer | |
| project: | |
| name: diy.kettler | |
| version: "4.1.0" | |
| on_boot: | |
| priority: 600 | |
| then: | |
| - logger.log: | |
| format: "=== KETTLER FIRMWARE v4.1.0 (console-less) ===" | |
| level: WARN | |
| - output.turn_off: pot_low | |
| - output.turn_on: pot_high | |
| esp32: | |
| board: esp32dev | |
| framework: | |
| type: esp-idf | |
| wifi: | |
| ssid: !secret wifi_ssid | |
| password: !secret wifi_password | |
| api: | |
| encryption: | |
| key: !secret api_key_kettler | |
| ota: | |
| - platform: esphome | |
| password: !secret ota_password | |
| logger: | |
| level: DEBUG | |
| bluetooth_proxy: | |
| active: true | |
| esp32_ble_tracker: | |
| scan_parameters: | |
| active: true | |
| ble_client: | |
| - mac_address: AA:BB:CC:DD:EE:FF # ADJUST: your strap MAC | |
| id: hr_strap | |
| # ===================================================================== | |
| # POTENTIOMETER EXCITATION | |
| # ===================================================================== | |
| output: | |
| - platform: gpio | |
| pin: GPIO25 | |
| id: pot_low | |
| - platform: gpio | |
| pin: GPIO32 | |
| id: pot_high | |
| # ===================================================================== | |
| # SENSORS | |
| # ===================================================================== | |
| sensor: | |
| - platform: ble_client | |
| type: characteristic | |
| ble_client_id: hr_strap | |
| name: "Heart Rate" | |
| id: heart_rate | |
| service_uuid: '180D' | |
| characteristic_uuid: '2A37' | |
| notify: true | |
| unit_of_measurement: "bpm" | |
| icon: mdi:heart-pulse | |
| accuracy_decimals: 0 | |
| lambda: |- | |
| if (x.size() < 2) return NAN; | |
| if (x[0] & 0x01) return (float)(x[1] | (x[2] << 8)); | |
| return (float)x[1]; | |
| - platform: ble_client | |
| type: characteristic | |
| ble_client_id: hr_strap | |
| name: "Strap Battery" | |
| service_uuid: '180F' | |
| characteristic_uuid: '2A19' | |
| notify: true | |
| unit_of_measurement: "%" | |
| device_class: battery | |
| entity_category: diagnostic | |
| accuracy_decimals: 0 | |
| lambda: |- | |
| return x.empty() ? NAN : (float)x[0]; | |
| # ------------------------------------------------------------------- | |
| # CADENCE | |
| # Reed switch between GPIO15 and GND | |
| # 1 pulse per crank revolution | |
| # ------------------------------------------------------------------- | |
| - platform: pulse_meter | |
| pin: | |
| number: GPIO15 | |
| mode: | |
| input: true | |
| pullup: true | |
| name: "Cadence" | |
| id: cadence | |
| unit_of_measurement: "rpm" | |
| icon: mdi:bike | |
| accuracy_decimals: 0 | |
| internal_filter: 100ms | |
| timeout: 3s | |
| # ------------------------------------------------------------------- | |
| # LOAD POTENTIOMETER | |
| # GPIO33 = wiper | |
| # | |
| # Measured: | |
| # MIN = 0.60 V | |
| # MAX = 3.00 V | |
| # ------------------------------------------------------------------- | |
| - platform: adc | |
| pin: GPIO33 | |
| id: load_wiper_v | |
| name: "Load Raw Wiper" | |
| attenuation: 12db | |
| unit_of_measurement: "V" | |
| accuracy_decimals: 3 | |
| update_interval: 250ms | |
| filters: | |
| - sliding_window_moving_average: | |
| window_size: 4 | |
| send_every: 2 | |
| # ------------------------------------------------------------------- | |
| # LOAD % | |
| # ------------------------------------------------------------------- | |
| - platform: template | |
| name: "Load" | |
| id: load_percent | |
| unit_of_measurement: "%" | |
| icon: mdi:speedometer | |
| accuracy_decimals: 0 | |
| update_interval: 1s | |
| lambda: |- | |
| const float v_min = 0.60; | |
| const float v_max = 3.00; | |
| float v = id(load_wiper_v).state; | |
| if (isnan(v)) | |
| return NAN; | |
| float pct = | |
| (v - v_min) / | |
| (v_max - v_min) * | |
| 100.0; | |
| return clamp(pct, 0.0f, 100.0f); | |
| # ------------------------------------------------------------------- | |
| # LOAD LEVEL 1–10 | |
| # ------------------------------------------------------------------- | |
| - platform: template | |
| name: "Level" | |
| id: load_level | |
| unit_of_measurement: "" | |
| icon: mdi:numeric | |
| accuracy_decimals: 0 | |
| update_interval: 1s | |
| lambda: |- | |
| if (isnan(id(load_percent).state)) | |
| return NAN; | |
| return roundf( | |
| 1.0 + | |
| id(load_percent).state / 100.0 * 9.0 | |
| ); | |
| # ------------------------------------------------------------------- | |
| # SPEED ESTIMATE | |
| # 60 rpm ~= 21 km/h | |
| # ------------------------------------------------------------------- | |
| - platform: template | |
| name: "Speed" | |
| id: speed_kmh | |
| unit_of_measurement: "km/h" | |
| icon: mdi:speedometer | |
| accuracy_decimals: 1 | |
| update_interval: 1s | |
| lambda: |- | |
| if (isnan(id(cadence).state)) | |
| return 0; | |
| const float kmh_per_rpm = 0.35; | |
| return id(cadence).state * | |
| kmh_per_rpm; | |
| # ------------------------------------------------------------------- | |
| # DISTANCE | |
| # ------------------------------------------------------------------- | |
| - platform: integration | |
| name: "Distance" | |
| sensor: speed_kmh | |
| time_unit: h | |
| unit_of_measurement: "km" | |
| icon: mdi:map-marker-distance | |
| restore: true | |
| # ------------------------------------------------------------------- | |
| # POWER ESTIMATE | |
| # | |
| # Placeholder model until calibrated against known wattage. | |
| # ------------------------------------------------------------------- | |
| - platform: template | |
| name: "Power Estimate" | |
| id: power_est | |
| unit_of_measurement: "W" | |
| icon: mdi:flash | |
| accuracy_decimals: 0 | |
| update_interval: 1s | |
| lambda: |- | |
| if (isnan(id(cadence).state) || | |
| id(cadence).state < 5) | |
| return 0; | |
| return | |
| (20.0 + | |
| id(load_percent).state * 1.6) | |
| * | |
| (id(cadence).state / 60.0); | |
| # ------------------------------------------------------------------- | |
| # ENERGY | |
| # ------------------------------------------------------------------- | |
| - platform: integration | |
| name: "Energy" | |
| sensor: power_est | |
| time_unit: h | |
| unit_of_measurement: "Wh" | |
| icon: mdi:lightning-bolt | |
| restore: true | |
| # ------------------------------------------------------------------- | |
| # WIFI | |
| # ------------------------------------------------------------------- | |
| - platform: wifi_signal | |
| name: "WiFi Signal" | |
| update_interval: 60s | |
| entity_category: diagnostic | |
| # ===================================================================== | |
| # BINARY SENSORS | |
| # ===================================================================== | |
| binary_sensor: | |
| - platform: template | |
| name: "Workout Active" | |
| id: workout_active | |
| icon: mdi:bike-fast | |
| lambda: |- | |
| if (isnan(id(cadence).state)) | |
| return false; | |
| return id(cadence).state > 5; | |
| filters: | |
| - delayed_off: 15s | |
| # ===================================================================== | |
| # BUTTONS | |
| # ===================================================================== | |
| button: | |
| - platform: restart | |
| name: "Restart" | |
| entity_category: diagnostic |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # ===================================================================== | |
| # Kettler session recording + heart-rate derivation | |
| # Samples every 2 minutes, one history row per ride, daily body snapshot. | |
| # ===================================================================== | |
| # ADJUST for your setup: | |
| # - birth_year in "HR Max Estimate" (drives HR max / zones) | |
| # - sensor.kettler_ergometer_* / binary_sensor.kettler_ergometer_*: | |
| # the entity ids of your ESPHome node (see kettler.yaml) | |
| # - sensor.bathroom_scale_*: your scale's entities (bodymiscale) — | |
| # drop the daily body snapshot block if you have no smart scale | |
| # ===================================================================== | |
| template: | |
| # ---------------- heart-rate reference values ---------------- | |
| - sensor: | |
| - name: "HR Max Estimate" | |
| unique_id: hr_max_estimate | |
| unit_of_measurement: bpm | |
| icon: mdi:heart-flash | |
| state: > | |
| {% set birth_year = 1980 %}{# ADJUST: your birth year #} | |
| {% set age = now().year - birth_year %} | |
| {{ (208 - 0.7 * age) | round(0) }} | |
| - name: "HR Percent Max" | |
| unique_id: hr_percent_max | |
| unit_of_measurement: "%" | |
| icon: mdi:heart-percent | |
| state: > | |
| {% set hr = states('sensor.kettler_ergometer_heart_rate') | float(0) %} | |
| {% set mx = states('sensor.hr_max_estimate') | float(0) %} | |
| {{ (hr / mx * 100) | round(0) if (hr > 30 and mx > 100) else 0 }} | |
| - name: "HR Zone" | |
| unique_id: hr_zone | |
| icon: mdi:heart-pulse | |
| state: > | |
| {% set hr = states('sensor.kettler_ergometer_heart_rate') | float(0) %} | |
| {% set mx = states('sensor.hr_max_estimate') | float(0) %} | |
| {% if hr < 30 or mx < 100 %}Idle | |
| {% elif hr < mx * 0.6 %}Easy | |
| {% elif hr < mx * 0.7 %}Fat Burn | |
| {% elif hr < mx * 0.8 %}Aerobic | |
| {% elif hr < mx * 0.9 %}Anaerobic | |
| {% else %}Max{% endif %} | |
| # ---------------- highest heart rate ever seen ---------------- | |
| - triggers: | |
| - trigger: state | |
| entity_id: sensor.kettler_ergometer_heart_rate | |
| sensor: | |
| - name: "HR Max Observed" | |
| unique_id: hr_max_observed | |
| unit_of_measurement: bpm | |
| icon: mdi:heart-flash | |
| state: > | |
| {% set hr = trigger.to_state.state | float(0) %} | |
| {% set prev = this.state | float(0) %} | |
| {{ hr | round(0) if (30 < hr < 220 and hr > prev) else prev | round(0) }} | |
| # ---------------- heart-rate recovery ---------------- | |
| - triggers: | |
| - trigger: state | |
| entity_id: binary_sensor.kettler_ergometer_workout_active | |
| to: "off" | |
| conditions: | |
| - condition: numeric_state | |
| entity_id: sensor.kettler_ergometer_heart_rate | |
| above: 40 | |
| sensor: | |
| - name: "HR Recovery Peak" | |
| unique_id: hr_recovery_peak | |
| unit_of_measurement: bpm | |
| state: "{{ states('sensor.kettler_ergometer_heart_rate') | int(0) }}" | |
| - triggers: | |
| - trigger: state | |
| entity_id: binary_sensor.kettler_ergometer_workout_active | |
| to: "off" | |
| for: "00:01:00" | |
| conditions: | |
| - condition: numeric_state | |
| entity_id: sensor.kettler_ergometer_heart_rate | |
| above: 40 | |
| sensor: | |
| - name: "HRR1" | |
| unique_id: hrr1 | |
| unit_of_measurement: bpm | |
| icon: mdi:heart-minus | |
| state: > | |
| {% set peak = states('sensor.hr_recovery_peak') | int(0) %} | |
| {% set nowhr = states('sensor.kettler_ergometer_heart_rate') | int(0) %} | |
| {{ [peak - nowhr, 0] | max if (peak > 40 and nowhr > 40) else this.state }} | |
| attributes: | |
| rating: > | |
| {% set v = (states('sensor.hr_recovery_peak') | int(0)) | |
| - (states('sensor.kettler_ergometer_heart_rate') | int(0)) %} | |
| {% if v >= 30 %}Excellent | |
| {% elif v >= 20 %}Good | |
| {% elif v >= 12 %}Fair | |
| {% else %}Poor{% endif %} | |
| - triggers: | |
| - trigger: state | |
| entity_id: binary_sensor.kettler_ergometer_workout_active | |
| to: "off" | |
| for: "00:02:00" | |
| conditions: | |
| - condition: numeric_state | |
| entity_id: sensor.kettler_ergometer_heart_rate | |
| above: 40 | |
| sensor: | |
| - name: "HRR2" | |
| unique_id: hrr2 | |
| unit_of_measurement: bpm | |
| icon: mdi:heart-minus | |
| state: > | |
| {% set peak = states('sensor.hr_recovery_peak') | int(0) %} | |
| {% set nowhr = states('sensor.kettler_ergometer_heart_rate') | int(0) %} | |
| {{ [peak - nowhr, 0] | max if (peak > 40 and nowhr > 40) else this.state }} | |
| # ---------------- 2-minute sample log ---------------- | |
| - triggers: | |
| - trigger: time_pattern | |
| minutes: "/2" | |
| id: sample | |
| - trigger: state | |
| entity_id: binary_sensor.kettler_ergometer_workout_active | |
| to: "on" | |
| id: reset | |
| conditions: | |
| - condition: template | |
| value_template: > | |
| {{ trigger.id == 'reset' | |
| or is_state('binary_sensor.kettler_ergometer_workout_active','on') }} | |
| variables: | |
| new_samples: > | |
| {% if trigger.id == 'reset' %} | |
| {{ [] }} | |
| {% else %} | |
| {% set prev = state_attr('sensor.kettler_session_log','samples') or [] %} | |
| {{ (prev + [{ | |
| 't': now().strftime('%H:%M'), | |
| 'hr': states('sensor.kettler_ergometer_heart_rate') | int(0), | |
| 'rpm': states('sensor.kettler_ergometer_cadence') | int(0), | |
| 'w': states('sensor.kettler_ergometer_power_estimate') | int(0), | |
| 'lvl': states('sensor.kettler_ergometer_level') | int(0), | |
| 'km': states('sensor.kettler_ergometer_distance') | float(0) | round(2), | |
| 'kcal': states('sensor.kettler_ergometer_energy') | float(0) | round(0) | |
| }])[-90:] }} | |
| {% endif %} | |
| sensor: | |
| - name: "Kettler Session Log" | |
| unique_id: kettler_session_log | |
| unit_of_measurement: samples | |
| state: "{{ new_samples | length }}" | |
| attributes: | |
| samples: "{{ new_samples }}" | |
| # ---------------- one row per finished ride ---------------- | |
| - triggers: | |
| - trigger: state | |
| entity_id: binary_sensor.kettler_ergometer_workout_active | |
| to: "off" | |
| for: "00:05:00" | |
| conditions: | |
| - condition: template | |
| value_template: > | |
| {{ (state_attr('sensor.kettler_session_log','samples') or []) | |
| | selectattr('rpm','gt',5) | list | count >= 3 }} | |
| variables: | |
| act: > | |
| {{ (state_attr('sensor.kettler_session_log','samples') or []) | |
| | selectattr('rpm','gt',5) | list }} | |
| new_history: > | |
| {% set prev = state_attr('sensor.kettler_ride_history','rides') or [] %} | |
| {{ (prev + [{ | |
| 'date': now().strftime('%Y-%m-%d'), | |
| 'start': act[0].t, | |
| 'min': (act | count) * 2, | |
| 'hr_avg': (act | map(attribute='hr') | select('gt',40) | list | average(0)) | round(0), | |
| 'hr_max': act | map(attribute='hr') | max, | |
| 'rpm_avg': (act | map(attribute='rpm') | list | average(0)) | round(0), | |
| 'w_avg': (act | map(attribute='w') | list | average(0)) | round(0), | |
| 'lvl_avg': (act | map(attribute='lvl') | list | average(0)) | round(1), | |
| 'km': ((act | map(attribute='km') | max) - (act | map(attribute='km') | min)) | round(2), | |
| 'kcal': ((act | map(attribute='kcal') | max) - (act | map(attribute='kcal') | min)) | round(0), | |
| 'hrr1': states('sensor.hrr1') | int(0) | |
| }])[-40:] }} | |
| sensor: | |
| - name: "Kettler Ride History" | |
| unique_id: kettler_ride_history | |
| unit_of_measurement: rides | |
| state: "{{ new_history | length }}" | |
| attributes: | |
| rides: "{{ new_history }}" | |
| # ---------------- daily body snapshot ---------------- | |
| - triggers: | |
| - trigger: time | |
| at: "23:45:00" | |
| conditions: | |
| - condition: numeric_state | |
| entity_id: sensor.bathroom_scale_weight | |
| above: 30 | |
| variables: | |
| new_body: > | |
| {% set prev = state_attr('sensor.body_log','days') or [] %} | |
| {{ (prev + [{ | |
| 'date': now().strftime('%Y-%m-%d'), | |
| 'kg': states('sensor.bathroom_scale_weight') | float(0) | round(1), | |
| 'fat': states('sensor.bathroom_scale_body_fat') | float(0) | round(1), | |
| 'muscle': states('sensor.bathroom_scale_muscle_mass') | float(0) | round(1), | |
| 'water': states('sensor.bathroom_scale_water') | float(0) | round(1), | |
| 'bmi': states('sensor.bathroom_scale_bmi') | float(0) | round(1), | |
| 'visceral': states('sensor.bathroom_scale_visceral_fat') | float(0) | round(1) | |
| }])[-90:] }} | |
| sensor: | |
| - name: "Body Log" | |
| unique_id: body_log | |
| unit_of_measurement: days | |
| state: "{{ new_body | length }}" | |
| attributes: | |
| days: "{{ new_body }}" | |
| recorder: | |
| exclude: | |
| entities: | |
| - sensor.kettler_session_log |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment