Skip to content

About

ESPHome component for the Vent-Axia Sentinel Kinetic MVHR, driven through its wired-remote serial port

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

esphome-vent-axia

An ESPHome component for the Vent-Axia Sentinel Kinetic MVHR, driven through its wired-remote serial port.

The component impersonates the wired remote. It decodes the unit's display into typed Home Assistant entities and drives the keypad to read and write the unit's settings, so the YAML config is a list of entities rather than a program.

Developed against a Sentinel Kinetic B, firmware V32/05.

Status

Under construction. See PLAN.md for the design and the staged rollout.

Features

  • Live status decode — airflow, fan RPM and drive percentage, supply/extract air temperatures, indoor temperature and humidity (plus a 5-minute average), filter hours remaining, and the raw display lines, all as typed sensors instead of scraped strings.

  • State flags as binary sensors — summer bypass, boost, purge, defrost, dryout, a humidity-sensor boost annunciator, filter-change-due and MVHR link status, plus a busy sensor so a slow keypad-driven operation (up to ~30s) is visible on a dashboard instead of looking hung.

  • Diagnostic page scrape — a scheduled sequence walks the unit's diagnostic pages and exposes frost protection state/mode, sensor and 24V-rail fault flags, wireless-receiver and wall-switch status, serial number and firmware version.

  • Settings read/write — summer bypass on/off and its indoor/outdoor temperature targets, read back from the unit (not held optimistically) and writable through Home Assistant numbers and switches.

  • Clock sync — a weekly scheduled job keeps the unit's clock correct; it has no daylight-saving awareness and drifts after ~2 weeks without mains power.

  • Boost and purge as a set-point — an airflow_mode select (Normal / Boost 30 min / Boost 60 min / Boost Continuous / Purge) rather than key presses, since the unit's Main key is a cumulative counter. Continuous boost is reported and commandable. It shows no countdown on the display, so it is decoded as "boosting, not purging, and no countdown seen at any point in this boost episode" — held for longer than the status line's own alternation timeout before being believed, so that a timed boost expiring (countdown gone, boost flag not yet aged out) is never mistaken for it. That confirmation costs ~20s, so a continuous boost takes about that long to appear.

  • A switch-driven Boost Continuous cannot be cancelled from Home Assistant — established on hardware, not merely suspected. A wired wall switch (commonly a switched live taken off a bathroom or toilet light, so it stays asserted for as long as the light is on) holds the unit's own boost input directly. The unit goes on cycling its status loop and accepting key presses, but the boost does not move: its tap counter is not what is holding it. Nothing but the switch releasing will clear it.

    Selecting Normal during one therefore fails, on purpose and quickly. SetAirflowMode watches the display across its normalising taps and, after two consecutive Main taps that move neither the airflow percentage nor the countdown, gives up and logs that the boost appears held on by an external switched live. That takes a few seconds rather than the full four-tap guard, and it names the cause instead of reporting that it does not know.

    A Switched Live Boost Input binary sensor reads this from diagnostic page 05, which is the only page that reports it — the Wall Switch SW1-3 sensors read OFF right through a switch-initiated boost, asserted or not, so they are not a corroborating signal for anything here. Note that page 05 arrives on the ~15-minute diagnostics scrape and is stale by construction: treat it as an explanation after the fact, not a live interlock. The status line remains the live evidence.

  • Raw keypad escape hatch — Up/Down/Set/Main exposed as bounded-duration buttons (never hold-switches, which could stick a key down forever across a reboot) for cases the decoded entities don't cover.

  • Structural mutual exclusion — a single sequence engine (Runner) pumps one command sequence at a time, so overlapping operations are refused and logged rather than racing on the wire.

  • Chip-agnostic — no hardware timer ISR, no chip-family #ifdef. CI compiles ESP8266, ESP32 (Arduino) and ESP32 (IDF) examples.

  • Host-testable core — the protocol framing, display/status decode, diagnostic table and sequence state machines compile and run outside ESPHome, so most of the logic is covered by a fast, hardware-free test suite (tests/).

Design notes

Portable core

protocol, screens, display, parser, status, diagnostics, keypad and sequence (with its seq_*.cpp bodies) include no ESPHome headers. They are plain C++ compiled both into the firmware and into the host test suite in tests/, which is what makes the protocol, the status-line decode and the menu-driving state machines testable without hardware. Only vent_axia.cpp and the platform files (sensor.py and friends) touch esphome/components/....

If a core file stops compiling on the host, it has grown an ESPHome dependency and the dependency belongs on the other side of that line.

Where the measured constants come from

Most timing constants in this component are not defaults or guesses -- they are measurements taken from the physical unit, and the comment at each one records the observation behind it. Some of those comments cite files by the paths mhrv_orig/controls.yaml, mhrv_orig/summer_bypass.yaml and mhrv_orig/vent-axia-esphome-project.md.

mhrv_orig/ is not part of this repository. It is the earlier YAML-and-lambda implementation this component replaces, kept as a local reference alongside it, and the paths read as though they were relative to this repo when they are not. Treat those citations as naming the prior implementation, not a file you can open from a clone.

The constants themselves stand on their own -- each carries its observed failure or measurement in the comment beside it (for example, key_gap at 250 ms dropping roughly one press in ten, or the 14.0 s measured between the last Boost Airflow frame and boosting() dropping). Nothing here requires reading the older project to understand why a number is what it is.

Chip agnostic

There is no chip-family #ifdef anywhere, and no interrupt handler. Held keys are retransmitted from loop() on a millis() comparison, which is the single decision that keeps the component portable: the previous implementation needed two incompatible hardware-timer backends, and its ESP32 one used an Arduino-ESP32 2.x API that 3.x removed.

CI compiles example/esp8266.yaml, example/esp32-arduino.yaml and example/esp32-idf.yaml, so a platform-specific regression fails the build.

Only the ESP8266 build is validated against real hardware, because that is the only dongle in hand. ESP32 support means "compiles, and has no known platform dependency" -- not "tested on a unit".

Hardware

  • Board: alextrical's wired-remote-replacement dongle (hardware repo), early revision, ESP8266EX.
  • Link: the MVHR's 2-wire wired-remote port (RJ9/4P4C) at 9600 8N1. These are true RS232 levels (+/-9 V), not TTL -- the dongle's MAX3232 is doing necessary work.
  • Not the separate BMS/MODBUS socket, which is untested.

Home Assistant card

lovelace-card/sentinel-remote-card.js is a custom Lovelace card that reinterprets the physical wired remote (16x2 display, Boost/Down/Select/Up) as a dashboard panel, plus the things the physical remote has no room for: a vent glyph that spins in proportion to actual airflow (with a live boost countdown beside it), an alert rail that stays empty until something needs attention, two labelled chip groups (Climate, System) of numeric readouts, an airflow-mode selector (which is also how purge is started and stopped), and a More settings disclosure — closed by default — holding the summer-bypass switch, the two bypass temperature setpoints, and the row of maintenance actions.

Sentinel Remote Card showing Boost Airflow at 48%, 22 minutes remaining

Note: the screenshot above is out of date. It predates the alert rail, the airflow-mode row, the grouped Climate/System chips, and the More settings disclosure, and shows an older single-line chip row (humidity and filter hours). It needs a retake.

Installation

  1. Download lovelace-card/sentinel-remote-card.js and copy it into <config>/www/sentinel-remote-card.js (create the www folder in your Home Assistant config directory if it doesn't exist yet — you can use the Studio Code Server / File Editor add-on, or Samba/SSH).

  2. In Home Assistant: Settings -> Dashboards -> ⋮ (top right) -> Resources -> Add Resource.

    • URL: /local/sentinel-remote-card.js?v=1
    • Resource type: JavaScript Module

    The ?v=1 is not decoration — see Updating the card below. Adding it now means later updates are a one-character edit.

  3. Reload the dashboard (or do a hard browser refresh).

  4. Add a new card, choose Manual / Edit in YAML, and paste in a config like the one below.

Updating the card

Copying a newer sentinel-remote-card.js over the old one is often not enough on its own. Browsers cache the file aggressively, and the resource URL is what they key that cache on, so an unchanged URL can keep serving the old card indefinitely — through dashboard reloads, and sometimes through a full Home Assistant restart. The usual symptom is a change that appears on one device but not another, or new config options being silently ignored.

The fix is to change the URL, which makes it a different file as far as the browser is concerned:

  1. Copy the new file over <config>/www/sentinel-remote-card.js.
  2. Settings -> Dashboards -> ⋮ -> Resources, open the card's resource, and bump the version query: ?v=1 becomes ?v=2, and so on. Any value works — only changing it matters.
  3. Reload the dashboard.

If you skipped the query string when first adding the resource, add one now (/local/sentinel-remote-card.js?v=2); it works the same as bumping an existing one. A hard refresh (Ctrl/Cmd-Shift-R) sometimes clears it too, but it has to be repeated on every browser and device that has ever loaded the dashboard, which is why bumping the version is the reliable route.

For your "Vent-Axia MHRV" ESPHome device specifically, this is ready to paste as-is (entity IDs pulled from your live device page):

type: custom:sentinel-remote-card
title: Vent Axia MVHR
line1_entity: sensor.house_vent_axia_mhrv_display_line_1_top
line2_entity: sensor.house_vent_axia_mhrv_display_line_2_bottom
boost_button: button.house_vent_axia_mhrv_key_main
down_button: button.house_vent_axia_mhrv_key_down
select_button: button.house_vent_axia_mhrv_key_set
up_button: button.house_vent_axia_mhrv_key_up
airflow_entity: sensor.house_vent_axia_mhrv_airflow
airflow_mode_entity: select.house_vent_axia_mhrv_airflow_mode
busy_entity: binary_sensor.house_vent_axia_mhrv_mvhr_busy
link_entity: binary_sensor.house_vent_axia_mhrv_mvhr_link

# Chips, in display order within their group (Climate / System — see below).
# boost_remaining isn't listed here even though it's still a `chips:` id: it
# has no group, and rides next to the airflow badge in the header instead.
chips: [supply_temp, extract_temp, boost_remaining]
supply_temp_entity: sensor.house_vent_axia_mhrv_supply_air_temperature_to_house
extract_temp_entity: sensor.house_vent_axia_mhrv_extract_air_temperature_from_house
indoor_temp_entity: sensor.house_vent_axia_mhrv_indoor_temperature_unit_sensor
humidity_entity: sensor.house_vent_axia_mhrv_indoor_humidity_in_extract_air
humidity_avg_entity: sensor.house_vent_axia_mhrv_indoor_humidity_5_minute_average
supply_rpm_entity: sensor.house_vent_axia_mhrv_supply_fan_speed
extract_rpm_entity: sensor.house_vent_axia_mhrv_extract_fan_speed
boost_remaining_entity: sensor.house_vent_axia_mhrv_boost_time_remaining
diagnostics_updated_entity: sensor.house_vent_axia_mhrv_diagnostics_last_updated

# Alert rail. Every one of these stays invisible until it fires.
bypass_entity: binary_sensor.house_vent_axia_mhrv_summer_bypass_active
antifrost_entity: binary_sensor.house_vent_axia_mhrv_frost_protection_active
antifrost_mode_entity: sensor.house_vent_axia_mhrv_frost_protection_mode
defrost_entity: binary_sensor.house_vent_axia_mhrv_defrost_active
dryout_entity: binary_sensor.house_vent_axia_mhrv_dryout_mode
humidity_boost_entity: binary_sensor.house_vent_axia_mhrv_humidity_boost
purge_entity: binary_sensor.house_vent_axia_mhrv_purge_active
switched_live_entity: binary_sensor.house_vent_axia_mhrv_switched_live_boost_input
filter_due_entity: binary_sensor.house_vent_axia_mhrv_filter_change_due
filter_entity: sensor.house_vent_axia_mhrv_filter_hours_remaining
supply_fault_entity: binary_sensor.house_vent_axia_mhrv_supply_air_sensor_fault_t1
extract_fault_entity: binary_sensor.house_vent_axia_mhrv_extract_air_sensor_fault_t2
rail_fault_entity: binary_sensor.house_vent_axia_mhrv_24_v_rail_fault_fuse_fs1

boost_active_entity: binary_sensor.house_vent_axia_mhrv_boost_active

# Settings and the maintenance actions below share one "More settings"
# disclosure, closed by default — see Settings below.
summer_mode_entity: switch.house_vent_axia_mhrv_summer_mode_enable_bypass
bypass_indoor_temp_entity: number.house_vent_axia_mhrv_bypass_minimum_indoor_temperature
bypass_outdoor_temp_entity: number.house_vent_axia_mhrv_bypass_minimum_outdoor_temperature

# Maintenance actions. Omit any you would rather not have one tap away.
refresh_diagnostics_button: button.house_vent_axia_mhrv_refresh_diagnostic_sensors
refresh_settings_button: button.house_vent_axia_mhrv_refresh_summer_settings
sync_clock_button: button.house_vent_axia_mhrv_sync_mvhr_clock
reset_filter_button: button.house_vent_axia_mhrv_reset_filter_timer

Options

Only the display and the four buttons are required. Every status option is opt-in by presence: name the entity and the icon or chip appears, delete the line and it is gone.

Option Effect
line1_entity, line2_entity The two LCD rows. Alternatively display_entity for a single sensor holding both, split on display_separator (default "\n"). One form or the other is required.
boost_button, down_button, select_button, up_button The four keys. Up/Down repeat while held, matching the physical remote's fast-scroll.
airflow_mode_entity The Normal / 30m / 60m / Cont / Purge segmented row — the only way to start or stop a purge from the card. See Boost button vs airflow mode.
airflow_entity Spins the header vent glyph in proportion to airflow, from one turn per 3.2 s at low flow to 0.8 s at 100 %, with the percentage beside it. Still when flow is zero.
busy_entity Shows a progress bar under the LCD and locks the mode row and actions while a keypad operation or sequence is in flight. Worth setting: a mode change takes ~25–30 s, and without it the card looks hung.
link_entity Greys the whole panel, raises an MVHR offline pill and disables the controls when no frames are arriving from the unit.
chips, alerts Ordered lists of ids controlling which readouts appear and in what order — see below. Omit either and the historical default is used.
diagnostics_updated_entity Adds "updated hh:mm" to the tooltip of every chip fed by the diagnostics scrape, so you can tell how stale those figures are.
boost_active_entity Glows the panel edge and pulses the Boost button while a boost is running.
running_entity Fallback fixed-rate spin for the vent glyph when airflow_entity is not set. Redundant if it is.
filter_warning_threshold Level at which the filter alert's fallback trigger fires. Defaults to 336 when the sensor reports h, otherwise 14.
summer_mode_entity, bypass_indoor_temp_entity, bypass_outdoor_temp_entity The settings panel, behind More settings — see Settings. Each is independently opt-in.
refresh_diagnostics_button, refresh_settings_button, sync_clock_button, reset_filter_button Maintenance actions, also behind More settings. reset_filter_button is guarded by a two-tap confirm — see Button combos.
title, accent_color, theme Panel heading, LCD/accent colour (default #3ddc84), and auto / light / dark.

The More settings disclosure itself needs no option of its own: it appears automatically once at least one settings entity or maintenance button is configured, and stays closed until tapped.

Chips

chips: is an ordered list of ids. Listing an id shows that chip, in that position within its group; leaving it out hides the chip without having to delete its entity line. Each still needs its *_entity key set to appear at all. Every id but boost_remaining belongs to one of two labelled groups — Climate or System — each wrapping onto as many lines as it needs rather than the whole row clipping at the card edge.

chips: [supply_temp, extract_temp, humidity]
Id Entity key Group Notes
supply_temp supply_temp_entity Climate Supply air, to the house (T1).
extract_temp extract_temp_entity Climate Extract air, from the house (T2).
indoor_temp indoor_temp_entity Climate The unit's own room sensor. Not the same as the unit's "Indoor Temp" menu screen, which is the bypass setpoint.
humidity humidity_entity Climate Relative humidity in the extract air.
humidity_avg humidity_avg_entity Climate The 5-minute average — steadier for a glance than the instantaneous figure.
co2 co2_entity Climate This hardware has no CO2 source; the id exists for other units.
supply_rpm, extract_rpm supply_rpm_entity, extract_rpm_entity System Fan speeds. The one readout that distinguishes "commanded 30 %" from "actually turning".
supply_pwm, extract_pwm supply_pwm_entity, extract_pwm_entity System Motor drive percentage. Rising drive at constant RPM is the early signal of a blocked filter or duct.
filter_hours filter_entity System Filter life remaining. Not shown by default — the alert rail carries the actionable version.
boost_remaining boost_remaining_entity (none — header badge) Countdown, next to the airflow percentage, hidden automatically when it reads zero. Live, not diagnostic, which is why it isn't in either group.

Default when chips: is omitted: supply_temp, extract_temp, humidity, co2, boost_remaining — the set and order the card used before this key existed.

Everything except humidity-vs-co2 comes off the ~15 minute diagnostics scrape rather than the live status frames, so those chips can be a quarter of an hour old; diagnostics_updated_entity puts the scrape time in the tooltip.

Alerts

Same idea for the alert rail. Every pill stays invisible until it fires, so naming all of them costs nothing.

Id Entity key Tint
offline link_entity Red. Fires when the link is down.
supply_fault, extract_fault supply_fault_entity, extract_fault_entity Red. T1/T2 sensor faults.
rail_fault rail_fault_entity Red. 24 V rail — check fuse FS1.
bypass bypass_entity Accent. Summer bypass open.
antifrost antifrost_entity (+ antifrost_mode_entity for detail text) Accent.
filter filter_due_entity, falling back to thresholding filter_entity Amber.
defrost defrost_entity Accent.
dryout dryout_entity Accent.
humidity_boost humidity_boost_entity Accent. The unit's internal humidity sensor (or a proportional 0-10V sensor) raising airflow on its own — see below.
purge purge_entity Accent.
switched_live switched_live_entity Accent. The most useful pill on the rail — see below.

Bypass, antifrost, defrost, dryout, humidity_boost and purge are accent-tinted rather than amber on purpose: each is the unit protecting itself or the house automatically, and must not read as a fault. Amber is for the filter, the one alert that needs you to do something; red is for things that are actually broken.

humidity_boost decodes the alpha symbol the manual documents for a proportional sensor or the internal humidity sensor boosting airflow — the unit shows no menu message and no diagnostic page behind this, so without the pill the airflow simply rises with nothing on the card to explain why. It clears itself once humidity drops; nothing to act on.

switched_live earns its place because a wall switched live holding the boost on is otherwise indistinguishable from the card being broken: selecting Normal during one fails, on purpose, and nothing but the switch releasing will clear it. It is read from the diagnostics scrape, so it can lag the boost it explains by up to ~15 minutes — the tooltip says so.

The alert rail renders nothing at all while the unit is healthy, so anything appearing between the LCD and the chips is worth a look.

Settings

Below the remote sits a More settings disclosure, closed by default, that holds the summer-bypass switch, the two bypass temperature setpoints as sliders, and the maintenance action buttons — occasional-use surfaces that don't need to sit open under the remote every time the card is glanced at. It appears only once at least one of the following is configured:

Option Entity Notes
summer_mode_entity switch.* Toggles the "Summer Mode (Enable Bypass)" menu setting.
bypass_indoor_temp_entity number.*, 16–40 °C Bypass indoor target. Min/max/step are read from the entity itself.
bypass_outdoor_temp_entity number.*, 5–20 °C Bypass outdoor cut-off. Only reachable through the unit's Indoor Temp editor screen — see number.py.

None of the three is optimistic: like airflow_mode_entity, the switch's control() and the numbers' writes only start a WriteSetting run on the unit — what the card shows next comes solely from the unit's own settings readback (refresh_settings_button / the nightly read_settings sequence), not a value predicted client-side. A slider only writes on release (or on a committing keypress), not on every tick of the drag; from that point the thumb and readout turn amber and pulse — sent, not yet confirmed — until the unit's own readback echoes the same value back, or WriteSetting's own Runner timeout gives up on the run, whichever comes first. The same amber state already applies while the slider is being dragged, so there's no jarring colour change at the moment you let go — dragging and "sent, not yet confirmed" read as one continuous state.

Boost button vs airflow mode

Both change the boost, and they are not the same thing.

  • The Boost button taps the unit's Main key, exactly as the physical remote does. That key is a cumulative counter with no usable timeout, so repeated taps walk through boost states rather than setting one.
  • The airflow mode row drives the airflow_mode select, which is an absolute set-point: "Boost 60 min" means be in a 60 minute boost, from wherever the unit currently is. The component works out the normalising key presses needed to get there.

Use the mode row to choose a state; the Boost button is there for remote fidelity and for the cases the decoded entities don't cover.

Because the select is deliberately not optimistic — it reports only what the unit's own status line confirms — a tapped segment shows a dashed pending outline until the unit agrees, which takes ~25–30 s (and up to 20 s more to confirm a continuous boost). Setting busy_entity alongside it is strongly recommended.

Button combos

The physical remote has three held gestures, and the card reaches all three — but by pressing the buttons that run the corresponding sequence, not by trying to hold keys itself.

Physical gesture Card equivalent
Hold Up+Main, then Down for 8 s Refresh action → the fetch_diagnostics sequence
Hold Main for 5.5 s Airflow mode row → Purge → the SetAirflowMode sequence
Hold Up+Down for 5.5 s Filter action (two-tap confirm) → the ResetFilter sequence

Why not hold the keys directly? Home Assistant's button.press is fire-and-forget; there is no "hold this entity" service. A real hold would have to be a hold_key call followed later by a release_keys call, with the network in between — and a lost release leaves a key asserted until the firmware's 30 s watchdog clears it. That is the exact failure this project already designed out once, which is why the raw keys are bounded-duration buttons rather than hold-switches. The card's Up/Down fast-scroll is honest about the same limit: it is repeated short taps, not a hold.

Timing a hold on the device side, inside a sequence that can also check the screen before and verify the result afterwards, is both safer and more capable. ResetFilter, for instance, refuses outright if the display isn't on the status screen, and confirms against diagnostic page 23 afterwards.

The filter reset is the one irreversible operation the component exposes, and on the remote its deliberateness comes from having to hold two keys for 5.5 seconds. A single dashboard tap has none of that, so the card supplies its own: the first tap only arms the button (it turns amber and reads Confirm?) and nothing is sent unless a second tap follows within four seconds.

Running the tests

cd tests
cmake -B build -S . && cmake --build build && ./build/run_tests

No dependencies beyond a C++17 compiler and CMake.

Acknowledgements

This project would not exist without the reverse-engineering and hardware work of others:

This is an independent rewrite: same protocol and hardware target, different architecture (see "Design notes" above) and license.

Licence

GPL-3.0 -- see LICENSE.

About

ESPHome component for the Vent-Axia Sentinel Kinetic MVHR, driven through its wired-remote serial port

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages