6 Commits
Author SHA1 Message Date
Rene Nulsch 9d52a3bdac bump dev version 2026-08-28 08:50:35 +02:00
Rene Nulsch 4ad41e9977 Merge pull request #7 from ReneNulschDE/fix/reduce-recorder-load
Stop flooding the recorder with timestamp and false-precision noise
2026-08-28 08:42:51 +02:00
Rene NulschandClaude Opus 5 62bc1c150a Stop flooding the recorder with timestamp and false-precision noise
Measured on a real installation with two bikes: the two "Last Seen"
sensors were the number one and number two writers in the entire Home
Assistant database, ahead of a three-phase energy meter. The integration
accounted for 26.5% of all recorder writes on that system.

Last Seen moved on every BLE notification, about once a second, or 2834
rows an hour per bike. The entity exists to say how fresh the values are,
which needs nothing near that resolution, so it now advances at
LAST_SEEN_RESOLUTION granularity. `self._last_seen` stays exact - that is
what gets persisted and restored - only the entity is throttled. Measured
after: 120 rows an hour, the theoretical maximum for a 30s window.

The distance sensors carried four decimals of false precision, because
the ebikemotion frames divide by 10000: a range that is really "14.6" was
reported as 14.6065, then 14.6529, then 14.6297, each one another state
write and another database row. They are rounded to 0.1 km now.

Home Assistant offers integrations no way to exclude an entity from the
recorder - `entity_filter` comes from user configuration only, and
`_unrecorded_attributes` covers attributes rather than states - so
reducing how often the state changes is the only fix that works without
asking every user to edit configuration.yaml.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyabWZzd7HoLpyBwEa5Zzh
2026-08-28 08:36:09 +02:00
Rene Nulsch a38b2efef5 Merge pull request #6 from ReneNulschDE/fix/reconnect-latency-and-advertisement-storm
Reconnect right after a dropped link instead of waiting out the poll
2026-08-28 00:33:34 +02:00
Rene NulschandClaude Opus 5 7c1a23a8b4 Reconnect right after a dropped link instead of waiting out the poll
Measured on a real bike: after every unexpected disconnect the integration
sat idle for 26.5s before reconnecting, in a suspiciously tight band across
nine consecutive drops.

Two causes, both introduced with the restore work:

`async_set_updated_data` runs on every BLE notification, which reschedules
the coordinator's next poll to now + SCAN_INTERVAL. The last notification
lands just before the link dies, bleak reports the disconnect ~3.5s later,
so the poll that would reconnect is still ~26.5s out.

The advertisement watch was supposed to cover exactly this, but does not:
instrumenting the callback showed one advertisement in seven minutes across
five drop cycles. These bikes stop advertising after an unexpected link
loss, so the watch only helps when the bike is switched on fresh.

The disconnected callback now starts a reconnect itself. A link that did
not survive MIN_LINK_SECONDS_FOR_FAST_RECONNECT is left to the poll so a
bike that cannot hold a connection at all does not spin. Measured after
the change: 0.7s instead of 26.5s.

Also fixes an advertisement storm. Home Assistant invokes bluetooth
callbacks on every advertisement, and this bike advertises every 0.27s, so
while disconnected the callback queued a background task several times a
second - roughly a hundred per reconnect window - all serialising behind
_connect_lock, each running a full establish_connection retry cycle against
a proxy already struggling to hold the link. A claim guard reduces that to
one attempt in flight at a time; the test covers 50 advertisements.

Keeps the advertisement counter as debug output - it is what made the
second cause visible, and it is the first thing to look at when a bike
reconnects slowly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyabWZzd7HoLpyBwEa5Zzh
2026-08-28 00:24:42 +02:00
Rene NulschandClaude Opus 5 76c3d91ff9 Bring README in line with v0.0.7
The connection switch is called Auto-connect now, the sensor list was
three entries short and counted 11 instead of 14, and the minimum Home
Assistant version disagreed with hacs.json.

Also documents that values are only stored once the bike has connected
at least once, and adds the passive-proxy case to troubleshooting - a
bike seen only by a Shelly looks identical to a bike that is switched
off unless you read the log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyabWZzd7HoLpyBwEa5Zzh
2026-08-27 23:27:58 +02:00
6 changed files with 293 additions and 24 deletions
+26 -14
View File
@@ -23,16 +23,17 @@ This integration has been developed and tested with:
## Features
This integration provides real-time monitoring of your E-Bike through Bluetooth LE connection with 11 sensors, 1 binary sensor, and 1 switch:
This integration provides real-time monitoring of your E-Bike through Bluetooth LE connection with 14 sensors, 1 binary sensor, and 1 switch:
### Connection Control
- **Connection Switch** - Control the BLE connection to your E-Bike
- Turning off this switch will disconnect from the bike and **the bike will shut down after approximately 5 minutes**. You must manually turn the bike back on or connect it to power to reconnect!
- Use this switch to save energy when you don't need active monitoring
- The bike will automatically turn off about 5 minutes after disconnection to conserve battery
- **Auto-connect** (Switch) - Controls whether the integration may connect to your E-Bike
- This is the connection *wish*, not the connection status. It stays on while the bike is away, so the integration reconnects on its own once the bike is switched on.
- Turning it off disconnects from the bike and **the bike will shut down after approximately 5 minutes**. You must manually turn the bike back on or connect it to power to reconnect!
- Use it to save energy when you don't need active monitoring
- The position is remembered across Home Assistant restarts
- **Connected** (Binary Sensor) - Shows current BLE connection status to the bike
- **Connected** (Binary Sensor) - The actual live BLE connection status
### Battery Sensors
- **Battery State of Charge** (%)
@@ -40,14 +41,19 @@ This integration provides real-time monitoring of your E-Bike through Bluetooth
- **Battery Remaining Energy** (Wh)
### Motor Sensors
- **Assist Level**
- **Assist Level** - disabled by default
- **Motor Temperature** (°C)
- **Speed** (km/h)
### E-Bike Management (EBM)
- **Odometer** (km)
- **Range** (km)
- **Trip A Distance** (km)
- **Trip A Range** (km) - disabled by default
- **Light Status**
- **EBM Status**
Trip A values are only reported by newer X20 bikes (device names starting with `HUS`). On X25 / X35+ bikes they stay "Unknown".
### Diagnostics
- **Last Seen** (Timestamp) - When the bike last sent data, so you can tell how fresh the values are
@@ -60,9 +66,9 @@ The integration automatically retrieves and displays:
## Requirements
- Home Assistant 2024.1.6 or newer
- Home Assistant 2024.6.0 or newer
- Bluetooth adapter with BLE support
- Bluetooth proxies with active connections are supported (ex. EspHome) - Shelly is not support as active direct connections are not possible.
- Bluetooth proxies with **active connections** are supported (e.g. ESPHome). Shelly proxies are passive-only: they can see the bike but never connect to it.
- E-Bike with Mahle SmartBike system (compatible with MySmartBike or ebikemotion app)
## Installation
@@ -106,6 +112,7 @@ The integration will automatically discover iWoc and HUS devices in range via Bl
- Make sure your E-Bike is turned on and in range
- Check that Bluetooth is enabled on your Home Assistant host
- Verify that the device name starts with "iWoc" or "HUS" (please report other device names)
- Check the log. If it says the bike *is advertising but no adapter or proxy that supports active connections can reach it*, the bike is only being seen by a passive proxy (e.g. a Shelly). You need a local Bluetooth adapter or an ESPHome proxy with `active: true` in range of the bike.
### Connection issues
@@ -115,7 +122,8 @@ The integration will automatically discover iWoc and HUS devices in range via Bl
### Sensor values not updating
- The integration updates data every 30 seconds when connected
- While connected, the bike pushes data continuously and the sensors update within a second
- The 30-second interval is only the retry timer used while the bike is *not* connected
- Some sensors may show "Unknown" until the bike sends that specific data
- Check if the bike is actively transmitting data (try riding or using the display)
@@ -129,18 +137,22 @@ The integration keeps working when the bike is switched off or out of range:
are **not** restored and show "Unknown" until the bike connects again — a
stale speed reading would look like live data from a parked bike
- Use **Connected** and **Last Seen** to tell live data from last known values
- The connection switch keeps its position across restarts, so a bike you
- The **Auto-connect** switch keeps its position across restarts, so a bike you
deliberately disconnected is not woken up again by a Home Assistant restart
### Connection Switch
Values are only stored from the moment the bike connects. Directly after
installing or updating the integration, sensors read "Unknown" until the bike
has been connected once — from then on they survive restarts.
- **To disconnect**: Turn off the "Connection" switch in Home Assistant
### Auto-connect Switch
- **To disconnect**: Turn off the "Auto-connect" switch in Home Assistant
- ⚠️ This will shut down your bike after approximately 5 minutes!
- The bike will remain on for about 5 minutes before automatically powering off
- **To reconnect**:
1. First, manually turn on your bike OR connect it to power
2. Wait for the bike to be fully powered on
3. Turn on the "Connection" switch in Home Assistant
3. Turn on the "Auto-connect" switch in Home Assistant
- **Use case**: Turn off the connection when you don't need monitoring to save your bike's battery
@@ -53,3 +53,14 @@ VOLATILE_FIELDS: Final[dict[str, tuple[str, ...]]] = {
"battery_secondary": ("current", "is_charging"),
"ebm": ("status", "accel_y", "accel_z"),
}
# A link that survived at least this long is worth reconnecting immediately when
# it drops; anything shorter is left to the regular poll so a bike that cannot
# hold a connection does not spin in a reconnect loop.
MIN_LINK_SECONDS_FOR_FAST_RECONNECT: Final = 5.0
# Granularity of the "Last Seen" entity. Notifications arrive about once a
# second; publishing each one made this timestamp the single biggest recorder
# writer in a real installation. Its job - telling you how fresh the values are
# - needs nothing near that resolution. The precise value is still persisted.
LAST_SEEN_RESOLUTION: Final = 30 # seconds
@@ -35,6 +35,8 @@ from .const import (
PROTOCOL_REQUEST_MESSAGE,
CLOSE_MESSAGE,
SCAN_INTERVAL,
LAST_SEEN_RESOLUTION,
MIN_LINK_SECONDS_FOR_FAST_RECONNECT,
STORAGE_SAVE_DELAY,
STORAGE_VERSION,
RESTORE_STATE_KEYS,
@@ -84,6 +86,9 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._last_seen: datetime | None = None
self._save_armed = False
self._unreachable_reason: str | None = None
self._connecting = False
self._advertisements_seen = 0
self._connected_since: float | None = None
self._store: Store[dict[str, Any]] = Store(
hass, STORAGE_VERSION, storage_key(entry)
)
@@ -168,6 +173,24 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"disabled" if self._manual_disconnect else "enabled",
)
@callback
def _publish_last_seen(self) -> None:
"""Move the entity-visible timestamp forward at most every 30 seconds.
`self._last_seen` stays exact - it is what gets persisted - but writing
it to the entity on every notification produced roughly 2800 database
rows per hour per bike, more than any other entity in a real install.
"""
if self._last_seen is None:
return
published = self._parser.state.get("last_seen")
if (
published is not None
and (self._last_seen - published).total_seconds() < LAST_SEEN_RESOLUTION
):
return
self._parser.state["last_seen"] = self._last_seen
def _persist_data(self) -> dict[str, Any]:
"""Build the payload written to .storage."""
return {
@@ -213,15 +236,36 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
BluetoothScanningMode.ACTIVE,
)
@callback
def _async_claim_connect(self) -> bool:
"""Reserve the right to run one connect attempt.
Home Assistant invokes the bluetooth callback on *every* advertisement,
and these bikes advertise several times a second. Without a claim, a
disconnected bike would queue hundreds of connect tasks behind
`_connect_lock`, each running a full `establish_connection` retry cycle
against a proxy that is already struggling to hold the link.
"""
if self._is_connected or self._manual_disconnect or self._connecting:
return False
self._connecting = True
return True
@callback
def _async_device_appeared(
self, service_info: BluetoothServiceInfoBleak, change: BluetoothChange
) -> None:
"""Handle the bike showing up in range."""
if self._is_connected or self._manual_disconnect:
self._advertisements_seen += 1
if not self._async_claim_connect():
return
_LOGGER.debug(
"Advertisement from %s (%d seen) - connecting now",
self._address,
self._advertisements_seen,
)
self._entry.async_create_background_task(
self.hass, self._async_try_connect(), f"{DOMAIN} connect {self._address}"
self.hass, self._async_run_connect(), f"{DOMAIN} connect {self._address}"
)
@callback
@@ -239,6 +283,25 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._is_connected = False
self.async_update_listeners()
# Don't sit out the poll interval. These bikes stop advertising after an
# unexpected link loss, so the advertisement watch does not fire, and
# every notification has just pushed the poll timer another 30s out -
# leaving the bike disconnected far longer than necessary.
held_for = (
self.hass.loop.time() - self._connected_since
if self._connected_since is not None
else 0.0
)
self._connected_since = None
if held_for < MIN_LINK_SECONDS_FOR_FAST_RECONNECT:
# A link that died almost immediately would spin; let the poll retry.
return
if not self._async_claim_connect():
return
self._entry.async_create_background_task(
self.hass, self._async_run_connect(), f"{DOMAIN} reconnect {self._address}"
)
async def async_first_connect(self) -> None:
"""Attempt the initial connection without blocking setup."""
if self._manual_disconnect:
@@ -250,6 +313,12 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
await self._async_try_connect()
async def _async_try_connect(self) -> None:
"""Run a connect attempt unless one is already in flight."""
if not self._async_claim_connect():
return
await self._async_run_connect()
async def _async_run_connect(self) -> None:
"""Connect, reporting the expected 'bike is off' failures once each."""
try:
await self._connect()
@@ -257,6 +326,8 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._async_report_unreachable(str(ex))
except Exception as ex: # noqa: BLE001
self._async_report_unreachable(f"Connection attempt failed: {ex}")
finally:
self._connecting = False
def _async_report_unreachable(self, reason: str) -> None:
"""Log why we cannot connect - once per distinct reason.
@@ -375,7 +446,14 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
except Exception:
state["rssi"] = None
state["last_seen"] = self._last_seen
self._publish_last_seen()
_LOGGER.debug(
"%s: connected=%s advertisements_seen=%s rssi=%s",
self._address,
self._is_connected,
self._advertisements_seen,
state["rssi"],
)
return state
async def _connect(self) -> None:
@@ -418,6 +496,7 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return
self._is_connected = True
self._connected_since = self.hass.loop.time()
self._unreachable_reason = None
_LOGGER.debug("Connected to %s", self._address)
@@ -449,7 +528,7 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._parser.handle_message(bytes(data))
self._last_seen = dt_util.utcnow()
self._parser.state["last_seen"] = self._last_seen
self._publish_last_seen()
self._schedule_save()
# Update coordinator data
@@ -24,5 +24,5 @@
"bleak>=0.21.0",
"bleak-retry-connector>=3.1.0"
],
"version": "0.0.2"
"version": "1.1.0"
}
+15 -4
View File
@@ -28,6 +28,17 @@ from .const import DOMAIN, MANUFACTURER, MODEL, CONF_DEVICE_NAME
from .coordinator import MySmartBikeCoordinator
def round_km(value: Any) -> float | None:
"""Round a distance to 0.1 km.
The ebikemotion frames carry these as `read32 / 10000`, which yields four
decimals of false precision - 14.6065, 14.6529, 14.6297 for a range that is
really "14.6". Every flicker in the last digit would be another state write
and another database row.
"""
return None if value is None else round(float(value), 1)
def safe_get(data: dict[str, Any] | None, *keys: str) -> Any:
"""Safely get nested dictionary values."""
if data is None:
@@ -108,7 +119,7 @@ SENSORS: tuple[MySmartBikeSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.DISTANCE,
state_class=SensorStateClass.TOTAL_INCREASING,
icon="mdi:counter",
value_fn=lambda data: safe_get(data, "ebm", "odometry"),
value_fn=lambda data: round_km(safe_get(data, "ebm", "odometry")),
),
MySmartBikeSensorEntityDescription(
key="range",
@@ -117,7 +128,7 @@ SENSORS: tuple[MySmartBikeSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.DISTANCE,
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:map-marker-distance",
value_fn=lambda data: safe_get(data, "ebm", "autonomy"),
value_fn=lambda data: round_km(safe_get(data, "ebm", "autonomy")),
),
MySmartBikeSensorEntityDescription(
key="trip_distance",
@@ -126,7 +137,7 @@ SENSORS: tuple[MySmartBikeSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.DISTANCE,
state_class=SensorStateClass.TOTAL_INCREASING,
icon="mdi:bike",
value_fn=lambda data: safe_get(data, "ebm", "trip_odometry"),
value_fn=lambda data: round_km(safe_get(data, "ebm", "trip_odometry")),
),
MySmartBikeSensorEntityDescription(
key="trip_range",
@@ -136,7 +147,7 @@ SENSORS: tuple[MySmartBikeSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:map-marker-distance",
entity_registry_enabled_default=False,
value_fn=lambda data: safe_get(data, "ebm", "trip_autonomy"),
value_fn=lambda data: round_km(safe_get(data, "ebm", "trip_autonomy")),
),
MySmartBikeSensorEntityDescription(
key="light",
@@ -269,7 +269,12 @@ async def test_reconnects_when_bike_appears(
"""An advertisement triggers a connect instead of waiting for the poll."""
await setup_offline(hass, restore_config_entry)
coordinator = restore_config_entry.runtime_data
coordinator._is_connected = False
await wait_connected(hass, coordinator)
# Drop the link the way bleak reports one
coordinator._async_client_disconnected(coordinator._client)
await hass.async_block_till_done()
assert coordinator.is_connected is False
with patch.object(
coordinator, "_connect", new_callable=AsyncMock
@@ -446,3 +451,154 @@ async def test_unreachable_warning_is_not_repeated(
await hass.async_block_till_done()
assert "turn on the bike" not in caplog.text
async def test_advertisement_storm_starts_one_connect_attempt(
hass: HomeAssistant,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_in_range,
mock_bluetooth_service_info,
) -> None:
"""Home Assistant fires the callback on every advertisement.
These bikes advertise several times a second, so an unguarded callback
would queue a connect task per advertisement, all serialising behind
_connect_lock and each hammering the proxy with a full retry cycle.
"""
await setup_offline(hass, restore_config_entry)
coordinator = restore_config_entry.runtime_data
await wait_connected(hass, coordinator)
coordinator._async_client_disconnected(coordinator._client)
await hass.async_block_till_done()
started = 0
release = asyncio.Event()
async def slow_connect() -> None:
nonlocal started
started += 1
await release.wait()
with patch.object(coordinator, "_connect", side_effect=slow_connect):
for _ in range(50):
coordinator._async_device_appeared(mock_bluetooth_service_info, None)
await asyncio.sleep(0)
await hass.async_block_till_done()
assert started == 1, f"{started} connect attempts for 50 advertisements"
release.set()
await hass.async_block_till_done()
# ...and the claim is released, so a later advertisement can still connect
assert coordinator._connecting is False
async def test_fast_reconnect_after_established_link_drops(
hass: HomeAssistant,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_in_range,
) -> None:
"""A dropped link reconnects at once instead of waiting out the poll.
These bikes stop advertising after an unexpected disconnect, so the
advertisement watch never fires, and every notification has just pushed the
poll timer another SCAN_INTERVAL out.
"""
await setup_offline(hass, restore_config_entry)
coordinator = restore_config_entry.runtime_data
await wait_connected(hass, coordinator)
# Pretend the link had been up comfortably longer than the spin guard
coordinator._connected_since = hass.loop.time() - 120
with patch.object(coordinator, "_connect", new_callable=AsyncMock) as mock_connect:
coordinator._async_client_disconnected(coordinator._client)
await hass.async_block_till_done()
mock_connect.assert_called_once()
async def test_no_fast_reconnect_when_link_died_immediately(
hass: HomeAssistant,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_in_range,
) -> None:
"""A link that collapses at once must not spin; the poll retries instead."""
await setup_offline(hass, restore_config_entry)
coordinator = restore_config_entry.runtime_data
await wait_connected(hass, coordinator)
coordinator._connected_since = hass.loop.time() # just connected
with patch.object(coordinator, "_connect", new_callable=AsyncMock) as mock_connect:
coordinator._async_client_disconnected(coordinator._client)
await hass.async_block_till_done()
mock_connect.assert_not_called()
async def test_last_seen_is_throttled(
hass: HomeAssistant,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_in_range,
freezer,
) -> None:
"""A timestamp that moves on every packet floods the recorder.
Notifications arrive about once a second; the entity only needs to say how
fresh the values are, so it moves at LAST_SEEN_RESOLUTION granularity while
the exact time is still what gets persisted.
"""
from custom_components.mysmartbike_ble.const import LAST_SEEN_RESOLUTION
await setup_offline(hass, restore_config_entry)
coordinator = restore_config_entry.runtime_data
frame = bytearray.fromhex("246a245a2300008402e1000001f50148494a2340")
coordinator._notification_handler(0, frame)
first = coordinator.data["last_seen"]
assert first is not None
# A burst of notifications inside the window must not move the entity
for _ in range(10):
freezer.tick(timedelta(seconds=1))
coordinator._notification_handler(0, frame)
assert coordinator.data["last_seen"] == first
# ...while the persisted timestamp keeps tracking reality
assert coordinator.last_seen > first
# Past the window it moves again
freezer.tick(timedelta(seconds=LAST_SEEN_RESOLUTION))
coordinator._notification_handler(0, frame)
assert coordinator.data["last_seen"] > first
async def test_distances_are_rounded_to_100m(
hass: HomeAssistant,
hass_storage,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_out_of_range,
) -> None:
"""Four decimals of false precision would be a database row each."""
seed_storage(
hass_storage,
{
**STORED_STATE,
"ebm": {
**STORED_STATE["ebm"],
"odometry": 806.1488,
"autonomy": 14.6065,
"trip_odometry": 12.3456,
"trip_autonomy": 58.9876,
},
},
)
await setup_offline(hass, restore_config_entry)
assert hass.states.get(entity_id_for(hass, "_odometer")).state == "806.1"
assert hass.states.get(entity_id_for(hass, "_range")).state == "14.6"
assert hass.states.get(entity_id_for(hass, "_trip_distance")).state == "12.3"