Skip to content

Instantly share code, notes, and snippets.

@PhilippMundhenk
Created September 20, 2026 20:00
Show Gist options
  • Select an option

  • Save PhilippMundhenk/25a4a52b384748b7ba18c34e240c37a7 to your computer and use it in GitHub Desktop.

Select an option

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)
# =====================================================================
# 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
# =====================================================================
# 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
# =====================================================================
# 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