inital commit

This commit is contained in:
Rene Nulsch
2025-11-21 22:38:55 +01:00
commit 4e66d2563b
28 changed files with 2694 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
name: Validate with HACS
on:
push:
pull_request:
schedule:
- cron: "0 0 * * *"
jobs:
validate:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4"
- name: HACS validation
uses: "hacs/action@main"
with:
category: "integration"
+14
View File
@@ -0,0 +1,14 @@
name: Validate with hassfest
on:
push:
pull_request:
schedule:
- cron: "0 0 * * *"
jobs:
validate:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4"
- uses: home-assistant/actions/hassfest@master
+39
View File
@@ -0,0 +1,39 @@
name: Publish
on:
release:
types: [published]
jobs:
publish:
name: Publish release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Get integration name
id: get_integration_name
run: |
integration_name=$(ls custom_components)
echo "integration_name=$integration_name" >> $GITHUB_OUTPUT
- name: Adjust version number
if: github.event_name == 'release'
shell: bash
run: |
yq -i -o json '.version="${{ github.ref_name }}"' custom_components/${{ steps.get_integration_name.outputs.integration_name }}/manifest.json
- name: Create zip
if: github.event_name == 'release'
shell: bash
run: |
cd custom_components/${{ steps.get_integration_name.outputs.integration_name }}
zip ${{ steps.get_integration_name.outputs.integration_name }}.zip -r ./
- name: Upload zip to release
if: github.event_name == 'release'
uses: softprops/action-gh-release@v2
with:
files: custom_components/${{ steps.get_integration_name.outputs.integration_name }}/${{ steps.get_integration_name.outputs.integration_name }}.zip
tag_name: ${{ github.ref_name }}
+123
View File
@@ -0,0 +1,123 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
*.manifest
*.spec
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# IDEs
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Home Assistant
*.db
*.db-shm
*.db-wal
.storage/
.cloud/
deps/
tts/
# Local development tools (not for public repo)
_local/
ble_logs/
mahle/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Rene
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+147
View File
@@ -0,0 +1,147 @@
# MySmartBike BLE Integration for Home Assistant
[![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration)
[![GitHub release](https://img.shields.io/github/release/renenulschde/ha-mysmartbike-ble.svg)](https://github.com/renenulschde/ha-mysmartbike-ble/releases)
Home Assistant custom component for E-Bikes with Mahle SmartBike systems (X35+, ebikemotion) via Bluetooth Low Energy (BLE).
Many premium E-Bike brands use Mahle drive systems and the MySmartBike app for connectivity. This integration works with bikes from various manufacturers including Schindelhauer, Orbea, Bianchi, Pinarello, Scott, and others that use the Mahle BLE protocol.
## Tested Bikes
This integration has been developed and tested with:
| Brand | Model | Status |
|-------|-------|--------|
| Schindelhauer | Arthur IX | Fully tested |
**Your bike not listed?** If you have an E-Bike that uses the MySmartBike app (or ebikemotion app), it will likely work with this integration. Please open an issue to report compatibility!
## Features
This integration provides real-time monitoring of your E-Bike through Bluetooth LE connection with 11 sensors, 1 binary sensor, and 1 switch:
### Connection Control
- **Connection Switch** - Control the BLE connection to your E-Bike
- ⚠️ **IMPORTANT WARNING**: 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
- **Connected** (Binary Sensor) - Shows current BLE connection status to the bike
### Battery Sensors
- **Battery State of Charge** (%)
- **Battery Temperature** (°C)
- **Battery Remaining Energy** (Wh)
### Motor Sensors
- **Assist Level**
- **Motor Temperature** (°C)
- **Speed** (km/h)
### E-Bike Management (EBM)
- **Odometer** (km)
- **Range** (km)
- **Light Status**
- **EBM Status**
### Device Information
The integration automatically retrieves and displays:
- **Serial Number** (VIN) - 17-character bike serial number
- **Protocol Version** - BLE protocol version (e.g., 1.02, 3.00)
## Requirements
- Home Assistant 2024.1.0 or newer
- Bluetooth adapter with BLE support
- E-Bike with Mahle SmartBike system (compatible with MySmartBike or ebikemotion app)
## Installation
### HACS (Recommended)
1. Open HACS in Home Assistant
2. Click on "Integrations"
3. Click the three dots in the top right corner
4. Select "Custom repositories"
5. Add this repository URL: `https://github.com/renenulschde/ha-mysmartbike-ble`
6. Select category "Integration"
7. Click "Add"
8. Search for "MySmartBike BLE" in HACS
9. Click "Download"
10. Restart Home Assistant
### Manual Installation
1. Download the latest release from the [releases page](https://github.com/renenulschde/ha-mysmartbike-ble/releases)
2. Extract the files
3. Copy the `custom_components/mysmartbike_ble` folder to your Home Assistant `custom_components` directory
4. Restart Home Assistant
## Configuration
The integration is configured through the Home Assistant UI:
1. Go to **Settings****Devices & Services**
2. Click **+ Add Integration**
3. Search for **MySmartBike BLE**
4. Select your iWoc device from the list
5. Click **Submit**
The integration will automatically discover iWoc devices in range via Bluetooth.
## Troubleshooting
### Device not found
- 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"
### Connection issues
- Ensure no other device is connected to the E-Bike via Bluetooth
- Try restarting the Bluetooth service on your Home Assistant host
- Check the Home Assistant logs for detailed error messages
### Sensor values not updating
- The integration updates data every 30 seconds when 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)
### Connection Switch
- **To disconnect**: Turn off the "Connection" 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
- **Use case**: Turn off the connection when you don't need monitoring to save your bike's battery
## Development
This integration is based on the BLE protocol from Mahle SmartBike systems.
### Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
### Protocol Documentation
The integration parses the following message types:
- Battery messages (voltage, SoC, current, temperature)
- Motor messages (speed, power, torque, assist level)
- EBM messages (odometer, range, light status)
- Assist level messages
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Disclaimer
This is an unofficial integration and is not affiliated with or endorsed by Mahle, Schindelhauer, Orbea, or any other E-Bike manufacturer. Use at your own risk.
@@ -0,0 +1,105 @@
"""The MySmartBike BLE integration."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components import bluetooth
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN, CONF_DEVICE_ADDRESS
from .coordinator import MySmartBikeCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up MySmartBike BLE from a config entry."""
_LOGGER.debug("Setting up MySmartBike BLE integration for entry_id: %s", entry.entry_id)
address = entry.data[CONF_DEVICE_ADDRESS]
_LOGGER.debug("Device address from config: %s", address)
# Get BLE device
_LOGGER.debug("Looking up BLE device with address: %s", address)
ble_device = bluetooth.async_ble_device_from_address(hass, address, connectable=True)
if not ble_device:
# Log warning only once per config entry
# Use hass.data for warning flag as it's separate from coordinator runtime_data
hass.data.setdefault(DOMAIN, {})
warning_key = f"warned_{entry.entry_id}"
if not hass.data[DOMAIN].get(warning_key):
_LOGGER.warning(
"MySmartBike device with address %s not found. "
"Make sure the bike is powered on and in range. "
"Home Assistant will retry automatically",
address
)
hass.data[DOMAIN][warning_key] = True
raise ConfigEntryNotReady(f"Could not find MySmartBike device with address {address}")
# Clear warning flag when device is found
if DOMAIN in hass.data:
hass.data[DOMAIN].pop(f"warned_{entry.entry_id}", None)
_LOGGER.debug("Found BLE device: %s", ble_device)
# Create coordinator
_LOGGER.debug("Creating coordinator for device %s", address)
coordinator = MySmartBikeCoordinator(hass, ble_device, entry)
# Perform first refresh
_LOGGER.debug("Performing first coordinator refresh")
await coordinator.async_config_entry_first_refresh()
_LOGGER.debug(
"First refresh completed - coordinator state: is_connected=%s, manual_disconnect=%s",
coordinator.is_connected,
coordinator._manual_disconnect,
)
# Store coordinator in runtime_data
entry.runtime_data = coordinator
_LOGGER.debug("Coordinator stored in entry.runtime_data")
# Forward entry setup to platforms
_LOGGER.debug("Forwarding entry setup to platforms: %s", PLATFORMS)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
_LOGGER.debug("Platform setup completed")
_LOGGER.debug("MySmartBike BLE integration setup completed successfully for entry_id: %s", entry.entry_id)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
_LOGGER.debug("Unloading MySmartBike BLE integration for entry_id: %s", entry.entry_id)
# Unload platforms
_LOGGER.debug("Unloading platforms: %s", PLATFORMS)
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
_LOGGER.debug("Platform unload result: %s", unload_ok)
if unload_ok:
coordinator: MySmartBikeCoordinator = entry.runtime_data
_LOGGER.debug(
"Coordinator retrieved from runtime_data - state: is_connected=%s, manual_disconnect=%s",
coordinator.is_connected,
coordinator._manual_disconnect,
)
await coordinator.async_shutdown()
_LOGGER.debug("Coordinator shutdown completed")
# Clean up warning flag from hass.data
if DOMAIN in hass.data:
hass.data[DOMAIN].pop(f"warned_{entry.entry_id}", None)
else:
_LOGGER.warning("Platform unload was not successful")
_LOGGER.debug("MySmartBike BLE integration unload completed for entry_id: %s (result: %s)", entry.entry_id, unload_ok)
return unload_ok
@@ -0,0 +1,68 @@
"""Binary sensor platform for MySmartBike BLE integration."""
from __future__ import annotations
import logging
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import MySmartBikeCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up MySmartBike BLE binary sensor entities."""
coordinator: MySmartBikeCoordinator = entry.runtime_data
async_add_entities([MySmartBikeConnectionSensor(coordinator, entry)])
class MySmartBikeConnectionSensor(CoordinatorEntity[MySmartBikeCoordinator], BinarySensorEntity):
"""Binary sensor showing BLE connection status to the bike."""
_attr_has_entity_name = True
_attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
def __init__(
self,
coordinator: MySmartBikeCoordinator,
entry: ConfigEntry,
) -> None:
"""Initialize the binary sensor."""
super().__init__(coordinator)
self._attr_unique_id = f"{entry.entry_id}_connected"
self._attr_device_info = {
"identifiers": {(DOMAIN, entry.entry_id)},
}
if coordinator.vin:
self._attr_device_info["serial_number"] = coordinator.vin
if coordinator.protocol_version:
self._attr_device_info["sw_version"] = coordinator.protocol_version
self._attr_translation_key = "connected"
@property
def is_on(self) -> bool:
"""Return True if connected to the bike."""
return self.coordinator.is_connected
@property
def name(self) -> str:
"""Return the name of the sensor."""
return "Connected"
@property
def icon(self) -> str:
"""Return the icon."""
return "mdi:bluetooth-connect" if self.is_on else "mdi:bluetooth-off"
@@ -0,0 +1,145 @@
"""Config flow for MySmartBike BLE integration."""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
)
from homeassistant.const import CONF_ADDRESS
from homeassistant.data_entry_flow import FlowResult
from .const import DOMAIN, CONF_DEVICE_ADDRESS, CONF_DEVICE_NAME, CONF_LOG_BLE_MESSAGES
_LOGGER = logging.getLogger(__name__)
class MySmartBikeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for MySmartBike BLE."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovery_info: BluetoothServiceInfoBleak | None = None
self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {}
@staticmethod
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> config_entries.OptionsFlow:
"""Get the options flow for this handler."""
return MySmartBikeOptionsFlowHandler()
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> FlowResult:
"""Handle the bluetooth discovery step."""
_LOGGER.debug("Discovered BLE device: %s", discovery_info)
await self.async_set_unique_id(discovery_info.address)
self._abort_if_unique_id_configured()
self._discovery_info = discovery_info
return await self.async_step_bluetooth_confirm()
async def async_step_bluetooth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Confirm discovery."""
assert self._discovery_info is not None
if user_input is not None:
return self.async_create_entry(
title=self._discovery_info.name,
data={
CONF_DEVICE_NAME: self._discovery_info.name,
CONF_DEVICE_ADDRESS: self._discovery_info.address,
},
)
self._set_confirm_only()
return self.async_show_form(
step_id="bluetooth_confirm",
description_placeholders={
"name": self._discovery_info.name,
},
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the user step to pick discovered device."""
if user_input is not None:
address = user_input[CONF_ADDRESS]
await self.async_set_unique_id(address, raise_on_progress=False)
self._abort_if_unique_id_configured()
discovery_info = self._discovered_devices[address]
return self.async_create_entry(
title=discovery_info.name,
data={
CONF_DEVICE_NAME: discovery_info.name,
CONF_DEVICE_ADDRESS: discovery_info.address,
},
)
current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass, False):
if (
discovery_info.address in current_addresses
or discovery_info.address in self._discovered_devices
):
continue
# Check if device name starts with "iWoc"
if discovery_info.name and discovery_info.name.startswith("iWoc"):
self._discovered_devices[discovery_info.address] = discovery_info
if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_ADDRESS): vol.In(
{
address: f"{info.name} ({info.address})"
for address, info in self._discovered_devices.items()
}
)
}
),
)
class MySmartBikeOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle options flow for MySmartBike BLE."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Manage the options."""
if user_input is not None:
_LOGGER.debug("Options flow: User input received: %s", user_input)
return self.async_create_entry(title="", data=user_input)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Optional(
CONF_LOG_BLE_MESSAGES,
default=self.config_entry.options.get(CONF_LOG_BLE_MESSAGES, False),
): bool,
}
),
)
@@ -0,0 +1,38 @@
"""Constants for the MySmartBike BLE integration."""
from typing import Final
DOMAIN: Final = "mysmartbike_ble"
# BLE UUIDs
WRITE_UUID: Final = "0000FFE2-0000-1000-8000-00805F9B34FB"
NOTIFY_UUID: Final = "0000FFD1-0000-1000-8000-00805F9B34FB"
# BLE Messages
VIN_REQUEST_MESSAGE: Final = bytearray([0x24, 0x53, 0x24, 0x56, 0x23, 0x40]) # $S$V#@
PROTOCOL_REQUEST_MESSAGE: Final = bytearray([0x24, 0x53, 0x24, 0x50, 0x23, 0x40]) # $S$P#@
CLOSE_MESSAGE: Final = bytearray([0x24, 0x44, 0x24, 0x49, 0x23, 0x40]) # $D$I#@
# Legacy alias
WAKEUP_MESSAGE: Final = VIN_REQUEST_MESSAGE
# Message lengths
BATTERY_MESSAGE_LENGTH: Final = 17
MOTOR_MESSAGE_LENGTH: Final = 18
EBM_MESSAGE_LENGTH: Final = 17
# Connection settings
MAX_CONNECT_ATTEMPTS: Final = 3
BLACKLIST_DURATION: Final = 300 # 5 minutes in seconds
CONNECTION_TIMEOUT: Final = 120 # seconds
SCAN_INTERVAL: Final = 30 # seconds
# Device info
MANUFACTURER: Final = "Mahle"
MODEL: Final = "iWoc BLE"
# Config entry keys
CONF_DEVICE_NAME: Final = "device_name"
CONF_DEVICE_ADDRESS: Final = "device_address"
# Options
CONF_LOG_BLE_MESSAGES: Final = "log_ble_messages"
@@ -0,0 +1,463 @@
"""Coordinator for MySmartBike BLE integration."""
from __future__ import annotations
import asyncio
import logging
import os
from datetime import datetime, timedelta
from typing import Any
from bleak import BleakClient
from bleak.exc import BleakError
from bleak_retry_connector import (
BleakClientWithServiceCache,
establish_connection,
)
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import BluetoothServiceInfoBleak
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
DOMAIN,
WRITE_UUID,
NOTIFY_UUID,
VIN_REQUEST_MESSAGE,
PROTOCOL_REQUEST_MESSAGE,
CLOSE_MESSAGE,
SCAN_INTERVAL,
CONF_LOG_BLE_MESSAGES,
CONF_DEVICE_NAME,
)
from .parsers import BikeDataParser
_LOGGER = logging.getLogger(__name__)
class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to manage fetching MySmartBike data."""
def __init__(
self,
hass: HomeAssistant,
ble_device: BluetoothServiceInfoBleak,
entry: ConfigEntry,
) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=SCAN_INTERVAL),
)
self._ble_device = ble_device
self._entry = entry
self._client: BleakClient | None = None
self._parser = BikeDataParser()
self._is_connected = False
self._notify_task: asyncio.Task | None = None
self._manual_disconnect = False # Track if user manually disconnected
_LOGGER.debug(
"Coordinator initialized: address=%s, is_connected=%s, manual_disconnect=%s, scan_interval=%s",
ble_device.address,
self._is_connected,
self._manual_disconnect,
SCAN_INTERVAL,
)
@property
def address(self) -> str:
"""Return the address of the device."""
return self._ble_device.address
@property
def is_connected(self) -> bool:
"""Return connection status."""
_LOGGER.debug(
"Coordinator.is_connected property called: returning %s (manual_disconnect: %s)",
self._is_connected,
self._manual_disconnect,
)
return self._is_connected
@property
def vin(self) -> str | None:
"""Return the VIN/serial number if available."""
return self._parser.vin
@property
def protocol_version(self) -> str | None:
"""Return the protocol version if available."""
return self._parser.protocol_version
async def async_disconnect(self) -> None:
"""Disconnect from the device (user initiated)."""
_LOGGER.debug(
"Coordinator.async_disconnect called for %s (user initiated) - current state: is_connected=%s, manual_disconnect=%s, client=%s",
self._ble_device.address,
self._is_connected,
self._manual_disconnect,
self._client is not None,
)
# Mark as manually disconnected to prevent auto-reconnect
self._manual_disconnect = True
_LOGGER.debug("Coordinator.async_disconnect: Set manual_disconnect=True")
if self._client:
_LOGGER.debug("Coordinator.async_disconnect: Client exists, cleaning up connection")
client_to_cleanup = self._client
self._client = None # Clear reference immediately
self._is_connected = False
try:
# Only send close message if still connected
if client_to_cleanup.is_connected:
# Send close message to bike before disconnecting
_LOGGER.debug("Coordinator.async_disconnect: Sending close message ($D$I#@)")
try:
await client_to_cleanup.write_gatt_char(WRITE_UUID, CLOSE_MESSAGE)
_LOGGER.debug("Coordinator.async_disconnect: Close message sent")
await asyncio.sleep(0.5)
except Exception as ex:
_LOGGER.debug("Coordinator.async_disconnect: Error sending close message: %s", ex)
# Stop notifications
try:
await client_to_cleanup.stop_notify(NOTIFY_UUID)
_LOGGER.debug("Coordinator.async_disconnect: Stopped notifications")
except Exception as ex:
_LOGGER.debug("Coordinator.async_disconnect: Error stopping notifications: %s", ex)
# Disconnect from device
try:
await client_to_cleanup.disconnect()
_LOGGER.debug("Coordinator.async_disconnect: Disconnected from device")
except Exception as ex:
_LOGGER.debug("Coordinator.async_disconnect: Error during disconnect: %s", ex)
else:
_LOGGER.debug("Coordinator.async_disconnect: Client exists but not connected, skipping disconnect")
except Exception as ex:
_LOGGER.debug("Coordinator.async_disconnect: Unexpected error during disconnect: %s", ex, exc_info=True)
finally:
# Force delete the client object to help garbage collection
del client_to_cleanup
# Give BLE adapter significant time to release connection slot
_LOGGER.debug("Coordinator.async_disconnect: Waiting for connection slot release (3 seconds)")
await asyncio.sleep(3.0)
_LOGGER.debug("Coordinator.async_disconnect: Cleaned up client (is_connected=%s)", self._is_connected)
else:
_LOGGER.debug("Coordinator.async_disconnect: No client to disconnect")
self._is_connected = False
_LOGGER.debug(
"Coordinator.async_disconnect completed - final state: is_connected=%s, manual_disconnect=%s",
self._is_connected,
self._manual_disconnect,
)
async def async_reconnect(self) -> None:
"""Reconnect to the device (user initiated)."""
_LOGGER.debug(
"Coordinator.async_reconnect called for %s (user initiated) - current state: is_connected=%s, manual_disconnect=%s, client=%s",
self._ble_device.address,
self._is_connected,
self._manual_disconnect,
self._client is not None,
)
# Clean up any existing client first
if self._client:
_LOGGER.debug("Coordinator.async_reconnect: Found existing client, cleaning up first")
old_client = self._client
self._client = None
self._is_connected = False
try:
if old_client.is_connected:
await old_client.disconnect()
_LOGGER.debug("Coordinator.async_reconnect: Disconnected existing client")
except Exception as ex:
_LOGGER.debug("Coordinator.async_reconnect: Error disconnecting old client: %s", ex)
finally:
del old_client
# Wait longer for connection slot to be released
_LOGGER.debug("Coordinator.async_reconnect: Waiting for connection slot release (3 seconds)")
await asyncio.sleep(3.0)
_LOGGER.debug("Coordinator.async_reconnect: Cleaned up old client and waited for slot release")
# Clear manual disconnect flag to allow auto-reconnect
self._manual_disconnect = False
_LOGGER.debug("Coordinator.async_reconnect: Set manual_disconnect=False")
try:
await self._connect()
_LOGGER.debug(
"Coordinator.async_reconnect completed - final state: is_connected=%s, manual_disconnect=%s",
self._is_connected,
self._manual_disconnect,
)
except Exception as ex:
# Only log as error if it's not a "device not reachable" issue
error_str = str(ex).lower()
if "not reachable" in error_str or "turn on the bike" in error_str:
_LOGGER.debug("Coordinator.async_reconnect: Device not reachable, will retry later")
else:
_LOGGER.error("Coordinator.async_reconnect failed: %s", ex, exc_info=True)
raise
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch data from the device."""
_LOGGER.debug(
"Coordinator._async_update_data called - current state: is_connected=%s, manual_disconnect=%s",
self._is_connected,
self._manual_disconnect,
)
# Don't auto-reconnect if user manually disconnected
if not self._is_connected and not self._manual_disconnect:
_LOGGER.debug("Coordinator._async_update_data: Not connected and not manual disconnect, attempting auto-reconnect")
try:
await self._connect()
_LOGGER.debug("Coordinator._async_update_data: Auto-reconnect successful (is_connected=%s)", self._is_connected)
except Exception as ex:
# Only log as warning if device is not reachable, otherwise debug
error_str = str(ex).lower()
if "not reachable" in error_str or "turn on the bike" in error_str:
_LOGGER.debug("Coordinator._async_update_data: Auto-reconnect skipped - device not reachable")
else:
_LOGGER.debug("Coordinator._async_update_data: Auto-reconnect failed: %s", ex)
elif not self._is_connected and self._manual_disconnect:
_LOGGER.debug("Coordinator._async_update_data: Not connected but manual_disconnect=True, skipping auto-reconnect")
else:
_LOGGER.debug("Coordinator._async_update_data: Already connected, no action needed")
# Return current state from parser, ensure it's never None
state = self._parser.state or {
"battery_primary": None,
"battery_secondary": None,
"motor": None,
"assist": None,
"ebm": None,
}
# Add RSSI (signal strength) to state
# Get latest service info which contains current RSSI
try:
service_info = bluetooth.async_last_service_info(
self.hass, self._ble_device.address, connectable=True
)
state["rssi"] = service_info.rssi if service_info else None
except Exception as ex:
_LOGGER.debug("Could not get RSSI: %s", ex)
state["rssi"] = None
_LOGGER.debug("Coordinator._async_update_data: Returning state (has_data=%s, rssi=%s)", self._parser.state is not None, state.get("rssi"))
return state
async def _connect(self) -> None:
"""Connect to the device and start notifications."""
_LOGGER.debug(
"Coordinator._connect: Attempting to connect to %s (current is_connected=%s, manual_disconnect=%s, client=%s)",
self._ble_device.address,
self._is_connected,
self._manual_disconnect,
self._client is not None,
)
# Clean up any existing client before connecting
if self._client:
_LOGGER.warning("Coordinator._connect: Client already exists, cleaning up before new connection")
old_client = self._client
self._client = None
try:
if old_client.is_connected:
await old_client.disconnect()
except Exception as ex:
_LOGGER.debug("Coordinator._connect: Error cleaning up old client: %s", ex)
finally:
del old_client
_LOGGER.debug("Coordinator._connect: Waiting for connection slot release (3 seconds)")
await asyncio.sleep(3.0)
try:
_LOGGER.debug("Coordinator._connect: Calling establish_connection for %s", self._ble_device.address)
self._client = await establish_connection(
BleakClientWithServiceCache,
self._ble_device,
self._ble_device.address,
)
_LOGGER.debug("Coordinator._connect: Successfully connected to %s, client=%s", self._ble_device.address, self._client)
# Start notifications first
_LOGGER.debug("Coordinator._connect: Starting notifications on UUID %s", NOTIFY_UUID)
await self._client.start_notify(NOTIFY_UUID, self._notification_handler)
_LOGGER.debug("Coordinator._connect: Started notifications successfully")
# Request VIN/serial number ($S$V#@)
_LOGGER.debug("Coordinator._connect: Requesting VIN/serial number")
await self._client.write_gatt_char(WRITE_UUID, VIN_REQUEST_MESSAGE)
_LOGGER.debug("Coordinator._connect: VIN request sent")
# Small delay between requests
await asyncio.sleep(0.2)
# Request protocol version ($S$P#@)
_LOGGER.debug("Coordinator._connect: Requesting protocol version")
await self._client.write_gatt_char(WRITE_UUID, PROTOCOL_REQUEST_MESSAGE)
_LOGGER.debug("Coordinator._connect: Protocol request sent")
self._is_connected = True
_LOGGER.debug("Coordinator._connect: Set is_connected=True")
except (BleakError, asyncio.TimeoutError) as ex:
self._is_connected = False
# Check if error is due to device not being reachable (turned off)
error_str = str(ex).lower()
if "no longer reachable" in error_str or "out of connection slots" in error_str:
_LOGGER.warning(
"Coordinator._connect: Device %s is not reachable or powered off. "
"Turn on the bike to connect.",
self._ble_device.address
)
raise UpdateFailed(
f"Device {self._ble_device.address} is not reachable. "
"Please turn on the bike."
) from ex
else:
_LOGGER.error(
"Coordinator._connect: Failed to connect to device %s: %s (is_connected set to False)",
self._ble_device.address,
ex,
exc_info=True,
)
raise UpdateFailed(f"Failed to connect to device: {ex}") from ex
def _notification_handler(self, sender: int, data: bytearray) -> None:
"""Handle notification data."""
_LOGGER.debug("Received notification from %s: %s", sender, data.hex())
# Recognize message type before saving
message_type = self._parser.recognize_message_type(bytes(data))
# Save BLE message to file if option is enabled (run in executor to avoid blocking)
if self._entry.options.get(CONF_LOG_BLE_MESSAGES, False):
self.hass.async_add_executor_job(self._save_ble_message, data, message_type)
# Parse the message
self._parser.handle_message(bytes(data))
# Update coordinator data
self.async_set_updated_data(self._parser.state)
def _save_ble_message(self, data: bytearray, message_type: str = "unknown") -> None:
"""Save BLE message to a file."""
try:
# Get device name from config
device_name = self._entry.data.get(CONF_DEVICE_NAME, "unknown_device")
# Sanitize device name for use in filename
safe_device_name = "".join(c if c.isalnum() or c in ("-", "_") else "_" for c in device_name)
# Create date string for filename (one file per day): YYYYMMDD
date_str = datetime.now().strftime("%Y%m%d")
# Create filename with device name and date
filename = f"{safe_device_name}_{date_str}_ble_messages.log"
# Get component directory path
component_dir = os.path.dirname(os.path.abspath(__file__))
log_dir = os.path.join(component_dir, "messages")
# Create directory if it doesn't exist
os.makedirs(log_dir, exist_ok=True)
# Full file path
filepath = os.path.join(log_dir, filename)
# Try to decode data as string (handle non-UTF8 data gracefully)
try:
data_str = data.decode("utf-8", errors="replace")
# Replace control characters and non-printable chars with their hex representation
data_str_clean = "".join(
c if c.isprintable() else f"\\x{ord(c):02x}" for c in data_str
)
except Exception:
data_str_clean = "<decode error>"
# Format message with timestamp (human-readable with milliseconds)
timestamp_readable = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
# Format the hex part with message type, then pad to column 75 for the string value
hex_part = f"[{timestamp_readable}] Type: {message_type:20} Hex: {data.hex()}"
# Pad to column 75 (or at least add separator if hex is already longer)
padding = max(75 - len(hex_part), 2)
message_line = f"{hex_part}{' ' * padding}String: {data_str_clean}\n"
# Append to file
with open(filepath, "a", encoding="utf-8") as f:
f.write(message_line)
_LOGGER.debug("Saved BLE message to: %s", filepath)
except Exception as ex:
_LOGGER.error("Failed to save BLE message to file: %s", ex, exc_info=True)
async def async_shutdown(self) -> None:
"""Shutdown the coordinator."""
_LOGGER.debug(
"Coordinator.async_shutdown called - current state: is_connected=%s, manual_disconnect=%s, client=%s",
self._is_connected,
self._manual_disconnect,
self._client is not None,
)
if self._client:
_LOGGER.debug("Coordinator.async_shutdown: Client exists, cleaning up connection")
client_to_cleanup = self._client
self._client = None
self._is_connected = False
try:
# Only send close message if still connected
if client_to_cleanup.is_connected:
# Send close message to bike before disconnecting
try:
_LOGGER.debug("Coordinator.async_shutdown: Sending close message ($D$I#@)")
await client_to_cleanup.write_gatt_char(WRITE_UUID, CLOSE_MESSAGE)
_LOGGER.debug("Coordinator.async_shutdown: Close message sent")
await asyncio.sleep(0.5)
except Exception as ex:
_LOGGER.debug("Coordinator.async_shutdown: Error sending close message: %s", ex)
# Stop notifications
try:
await client_to_cleanup.stop_notify(NOTIFY_UUID)
_LOGGER.debug("Coordinator.async_shutdown: Stopped notifications")
except Exception as ex:
_LOGGER.debug("Coordinator.async_shutdown: Error stopping notifications: %s", ex)
# Disconnect from device
try:
await client_to_cleanup.disconnect()
_LOGGER.debug("Coordinator.async_shutdown: Disconnected from device")
except Exception as ex:
_LOGGER.debug("Coordinator.async_shutdown: Error during disconnect: %s", ex)
else:
_LOGGER.debug("Coordinator.async_shutdown: Client exists but not connected, skipping disconnect")
except Exception as ex:
_LOGGER.debug("Coordinator.async_shutdown: Unexpected error during shutdown: %s", ex, exc_info=True)
finally:
del client_to_cleanup
_LOGGER.debug("Coordinator.async_shutdown: Cleaned up client")
else:
_LOGGER.debug("Coordinator.async_shutdown: No client to clean up")
self._is_connected = False
_LOGGER.debug("Coordinator.async_shutdown completed")
@@ -0,0 +1,17 @@
{
"domain": "mysmartbike_ble",
"name": "MySmartBike BLE",
"codeowners": ["@renenulschde"],
"config_flow": true,
"documentation": "https://github.com/renenulschde/ha-mysmartbike-ble",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["bleak>=0.21.0", "bleak-retry-connector>=3.1.0"],
"version": "0.0.0",
"homeassistant": "2024.6.0",
"bluetooth": [
{
"local_name": "iWoc*"
}
]
}
@@ -0,0 +1,343 @@
"""Message parsers for MySmartBike BLE integration."""
import logging
from typing import Dict, Optional, Any
from .const import (
BATTERY_MESSAGE_LENGTH,
MOTOR_MESSAGE_LENGTH,
EBM_MESSAGE_LENGTH,
)
_LOGGER = logging.getLogger(__name__)
def read16(data: bytes, offset: int) -> int:
"""Read 16-bit value from data at offset (big-endian, as per Mahle protocol)."""
return ((data[offset] & 0xFF) << 8) | (data[offset + 1] & 0xFF)
def read24(data: bytes, offset: int) -> int:
"""Read 24-bit value from data at offset (big-endian, as per Mahle protocol)."""
return (
((data[offset] & 0xFF) << 16)
| ((data[offset + 1] & 0xFF) << 8)
| (data[offset + 2] & 0xFF)
)
def read32(data: bytes, offset: int) -> int:
"""Read 32-bit value from data at offset (big-endian, as per Mahle protocol)."""
return (
((data[offset] & 0xFF) << 24)
| ((data[offset + 1] & 0xFF) << 16)
| ((data[offset + 2] & 0xFF) << 8)
| (data[offset + 3] & 0xFF)
)
def read_unsigned_byte(byte_val: int) -> int:
"""Read unsigned byte value."""
return byte_val & 0xFF
class BikeDataParser:
"""Parser for bike BLE messages."""
def __init__(self):
"""Initialize parser."""
self.state: Dict[str, Optional[Dict[str, Any]]] = {
"battery_primary": None,
"battery_secondary": None,
"motor": None,
"assist": None,
"ebm": None,
}
self.battery_packet_counter = 0
self.vin: Optional[str] = None
self.protocol_version: Optional[str] = None
def parse_battery_message(self, message: bytes) -> Optional[Dict[str, Any]]:
"""Parse battery message and update state."""
if len(message) < BATTERY_MESSAGE_LENGTH:
return None
# Read values
voltage = read16(message, 5) / 10.0
soc = read_unsigned_byte(message[7])
temp_status = message[8]
current = read16(message, 9) / 10.0
nominal_capacity = read16(message, 11) / 10.0
remaining_wh = read16(message, 13) / 10.0
# Get battery number and cycles from combined field at offset 15
# Format: value = (battery_number * 10000) + cycles
# e.g., 10036 means battery 1, 36 cycles
combined_raw = read16(message, 15) if len(message) >= 19 else None
battery_number = (combined_raw // 10000) if combined_raw else 1
cycles = (combined_raw % 10000) if combined_raw else None
# Construct battery data dictionary
data = {
"voltage": voltage,
"soc": soc,
"temperature": temp_status,
"current": current,
"nominal_capacity": nominal_capacity,
"remaining_wh": remaining_wh,
"cycles": cycles,
}
# Handle secondary vs primary battery
if battery_number == 2:
# Secondary battery detected
self.battery_packet_counter = 0
self.state["battery_secondary"] = data
elif battery_number == 1:
# Primary battery
self.battery_packet_counter += 1
self.state["battery_primary"] = data
# After 4 consecutive primary battery packets, reset secondary battery
if self.battery_packet_counter >= 4:
self.state["battery_secondary"] = {
"voltage": 0.0,
"soc": 0.0,
"temperature": 0,
"current": 0.0,
"nominal_capacity": 0.0,
"remaining_wh": 0.0,
"cycles": None,
}
return data
def parse_motor_message(self, message: bytes) -> Optional[Dict[str, Any]]:
"""Parse motor message and update state."""
if len(message) < MOTOR_MESSAGE_LENGTH:
return None
# Extract values from message
assist_level = message[5]
temperature_celsius = message[6]
power_amp = float(read16(message, 7)) / 10.0
speed_kmh = float(read16(message, 9)) / 10.0
# Additional values
wheel_speed = read_unsigned_byte(message[11])
torque_pct = message[12]
power_max = float(read16(message, 13)) / 10.0
max_torque_pct = message[15]
# Update state with motor data
data = {
"assist_level": assist_level,
"temperature_celsius": temperature_celsius,
"power_amp": power_amp,
"speed_kmh": speed_kmh,
"wheel_speed_rpm": wheel_speed,
"torque_motor_pct": torque_pct,
"power_max_amp": power_max,
"max_torque_motor_pct": max_torque_pct,
}
self.state["motor"] = data
return data
def parse_assist_level_message(self, message: bytes) -> Optional[Dict[str, Any]]:
"""Parse assist level message and update state."""
if len(message) == 10:
data = {
"min": int(chr(message[5])),
"max": int(chr(message[6])),
"current": int(chr(message[7])),
}
self.state["assist"] = data
return data
elif len(message) == 9:
result = message.decode("utf-8", errors="ignore")[5:7]
data = {
"sync_result": result,
"success": result == "OK",
}
self.state["assist"] = data
return data
return None
def parse_vin_message(self, message: bytes) -> Optional[str]:
"""Parse VIN/serial number message.
Formats:
- $s$V#<serial>#@ - standard format with 17 char serial
- R0<serial>@ - alternative format (20 chars total)
"""
text = message.decode("utf-8", errors="ignore")
# Standard format: $s$V#<serial>#@
if text.startswith("$s$V#") and text.endswith("#@"):
vin = text[5:-2] # Extract between $s$V# and #@
if len(vin) == 17:
self.vin = vin
_LOGGER.info("Parsed VIN/serial number: %s", vin)
return vin
# Alternative format: R0<serial>@ (20 chars total)
if len(text) == 20 and text.endswith("@") and text.startswith("R0"):
vin = text[2:-1] # Extract between R0 and @
if len(vin) == 17:
self.vin = vin
_LOGGER.info("Parsed VIN/serial number (R0 format): %s", vin)
return vin
_LOGGER.debug("Could not parse VIN from message: %s", text)
return None
def parse_protocol_message(self, message: bytes) -> Optional[str]:
"""Parse protocol version message.
Format: $s$P#<version>#@ - e.g., $s$P#1.02#@
Error: $s$P#ER#@ indicates error
"""
text = message.decode("utf-8", errors="ignore")
# Standard format: $s$P#<version>#@
if text.startswith("$s$P#") and text.endswith("#@"):
version = text[5:-2] # Extract between $s$P# and #@
if version and version != "ER":
self.protocol_version = version
_LOGGER.info("Parsed protocol version: %s", version)
return version
elif version == "ER":
_LOGGER.warning("Protocol version request returned error")
return None
_LOGGER.debug("Could not parse protocol from message: %s", text)
return None
def parse_ebm_message(self, message: bytes) -> Optional[Dict[str, Any]]:
"""Parse EBM (E-Bike Management) message."""
if len(message) < EBM_MESSAGE_LENGTH:
return None
# EbmParserEbm format: 32-bit reads directly from message (big-endian)
# Raw values are in decimeters, divide by 10000 to get km
# (Mahle code divides by 10 to get meters, then displays as km by /1000)
if len(message) < 15:
return None
odometry_km = read32(message, 5) / 10000.0
autonomy_km = read32(message, 9) / 10000.0
is_light_on = message[13] == 1
status = read_unsigned_byte(message[14])
# EbmParserEbm only parses bytes 5-14, bytes 15-16 are suffix #@
data = {
"odometry": odometry_km,
"autonomy": autonomy_km,
"is_light_on": is_light_on,
"status": status,
}
self.state["ebm"] = data
return data
def recognize_message_type(self, message: bytes) -> str:
"""Recognize message type from message content."""
text = message.decode("ascii", errors="ignore")
# Handle standard format messages ($..#@)
if text.startswith("$") and text.endswith("#@"):
main_type = text[1]
sub_type = text[3] if len(text) > 3 else None
if main_type == "b":
return "battery"
elif main_type == "d":
if sub_type == "I":
return "diagnosis_init"
elif sub_type == "R":
return "diagnosis_read"
elif sub_type == "E":
return "diagnosis_end"
elif sub_type == "Z":
return "security_session"
elif sub_type == "C":
return "coding_device"
elif sub_type == "V":
return "write_vin"
elif sub_type == "T":
return "status"
elif main_type == "j" and sub_type == "Z":
return "ebm"
elif main_type == "m":
if sub_type == "A":
return "assist"
elif sub_type == "Z":
return "motor"
elif sub_type == "M":
return "engine_maps"
elif sub_type == "R":
return "reset_trip"
elif main_type == "s":
if sub_type == "V":
return "vin"
elif sub_type == "P":
return "protocol"
elif main_type == "M" and sub_type == "M":
return "engine_maps"
elif main_type == "i" and sub_type == "C":
return "calibrate"
# Handle special format messages (ending with @)
elif text.endswith("@"):
main_type = text[0]
if main_type == "T":
return "status"
elif main_type == "C":
return "coding_device"
elif main_type == "R":
return "vin"
elif main_type == "Z":
return "security_challenge"
return "unknown"
def handle_message(self, data: bytes) -> None:
"""Handle received message data and update state."""
msg_type = self.recognize_message_type(data)
# Handle different message types
if msg_type == "battery":
self.parse_battery_message(data)
elif msg_type == "motor":
self.parse_motor_message(data)
elif msg_type == "assist":
self.parse_assist_level_message(data)
elif msg_type == "ebm":
self.parse_ebm_message(data)
elif msg_type == "vin":
self.parse_vin_message(data)
elif msg_type == "protocol":
self.parse_protocol_message(data)
elif msg_type in [
"diagnosis_init",
"diagnosis_read",
"diagnosis_end",
"security_session",
"coding_device",
"write_vin",
"status",
"engine_maps",
"reset_trip",
"calibrate",
"security_challenge",
]:
_LOGGER.debug("Received message of type: %s", msg_type)
else:
# Enhanced logging for unknown messages
prefix = " ".join([f"{b:02x}" for b in data[:5]])
_LOGGER.debug(
"Unknown message: type=%s, prefix=[%s], length=%d",
msg_type,
prefix,
len(data),
)
+201
View File
@@ -0,0 +1,201 @@
"""Sensor platform for MySmartBike BLE integration."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
UnitOfEnergy,
UnitOfLength,
UnitOfSpeed,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER, MODEL, CONF_DEVICE_NAME
from .coordinator import MySmartBikeCoordinator
def safe_get(data: dict[str, Any] | None, *keys: str) -> Any:
"""Safely get nested dictionary values."""
if data is None:
return None
result = data
for key in keys:
if result is None or not isinstance(result, dict):
return None
result = result.get(key)
return result
@dataclass
class MySmartBikeSensorEntityDescription(SensorEntityDescription):
"""Describes MySmartBike sensor entity."""
value_fn: callable[[dict[str, Any]], Any] | None = None
SENSORS: tuple[MySmartBikeSensorEntityDescription, ...] = (
# Battery Primary Sensors
MySmartBikeSensorEntityDescription(
key="battery_primary_soc",
name="Battery SoC",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: safe_get(data, "battery_primary", "soc"),
),
MySmartBikeSensorEntityDescription(
key="battery_primary_temperature",
name="Battery Temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: safe_get(data, "battery_primary", "temperature"),
),
MySmartBikeSensorEntityDescription(
key="battery_primary_remaining_wh",
name="Battery Remaining Energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY_STORAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: safe_get(data, "battery_primary", "remaining_wh"),
),
# Motor Sensors
MySmartBikeSensorEntityDescription(
key="motor_assist_level",
name="Assist Level",
icon="mdi:speedometer",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: safe_get(data, "motor", "assist_level"),
),
MySmartBikeSensorEntityDescription(
key="motor_temperature",
name="Motor Temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: safe_get(data, "motor", "temperature_celsius"),
),
MySmartBikeSensorEntityDescription(
key="motor_speed",
name="Speed",
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
device_class=SensorDeviceClass.SPEED,
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:speedometer",
value_fn=lambda data: safe_get(data, "motor", "speed_kmh"),
),
# EBM Sensors
MySmartBikeSensorEntityDescription(
key="odometer",
name="Odometer",
native_unit_of_measurement=UnitOfLength.KILOMETERS,
device_class=SensorDeviceClass.DISTANCE,
state_class=SensorStateClass.TOTAL_INCREASING,
icon="mdi:counter",
value_fn=lambda data: safe_get(data, "ebm", "odometry"),
),
MySmartBikeSensorEntityDescription(
key="range",
name="Range",
native_unit_of_measurement=UnitOfLength.KILOMETERS,
device_class=SensorDeviceClass.DISTANCE,
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:map-marker-distance",
value_fn=lambda data: safe_get(data, "ebm", "autonomy"),
),
MySmartBikeSensorEntityDescription(
key="light",
name="Light",
icon="mdi:lightbulb",
value_fn=lambda data: "On" if safe_get(data, "ebm", "is_light_on") else ("Off" if safe_get(data, "ebm") else None),
),
MySmartBikeSensorEntityDescription(
key="ebm_status",
name="EBM Status",
icon="mdi:information",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: safe_get(data, "ebm", "status"),
),
# Connection Diagnostic Sensors
MySmartBikeSensorEntityDescription(
key="rssi",
name="Signal Strength",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
icon="mdi:wifi",
value_fn=lambda data: safe_get(data, "rssi"),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up MySmartBike BLE sensors."""
coordinator: MySmartBikeCoordinator = entry.runtime_data
async_add_entities(
MySmartBikeSensor(coordinator, entry, description)
for description in SENSORS
)
class MySmartBikeSensor(CoordinatorEntity[MySmartBikeCoordinator], SensorEntity):
"""Representation of a MySmartBike sensor."""
entity_description: MySmartBikeSensorEntityDescription
_attr_has_entity_name = True
def __init__(
self,
coordinator: MySmartBikeCoordinator,
entry: ConfigEntry,
description: MySmartBikeSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
device_name = entry.data[CONF_DEVICE_NAME]
self._attr_unique_id = f"{entry.entry_id}_{description.key}"
# Build device info with optional serial number (VIN)
self._attr_device_info = {
"identifiers": {(DOMAIN, entry.entry_id)},
"name": device_name,
"manufacturer": MANUFACTURER,
"model": MODEL,
}
# Add serial number if available
if coordinator.vin:
self._attr_device_info["serial_number"] = coordinator.vin
# Add protocol version as software version
if coordinator.protocol_version:
self._attr_device_info["sw_version"] = coordinator.protocol_version
@property
def native_value(self) -> Any:
"""Return the state of the sensor."""
if self.entity_description.value_fn:
return self.entity_description.value_fn(self.coordinator.data)
return None
+129
View File
@@ -0,0 +1,129 @@
"""Switch platform for MySmartBike BLE integration."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import MySmartBikeCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up MySmartBike BLE switch entities."""
_LOGGER.debug("Setting up switch platform for entry_id: %s", entry.entry_id)
coordinator: MySmartBikeCoordinator = entry.runtime_data
_LOGGER.debug("Adding connection switch entity (coordinator.is_connected: %s)", coordinator.is_connected)
async_add_entities([MySmartBikeConnectionSwitch(coordinator, entry)])
class MySmartBikeConnectionSwitch(CoordinatorEntity[MySmartBikeCoordinator], SwitchEntity):
"""Switch to control BLE connection to the bike."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: MySmartBikeCoordinator,
entry: ConfigEntry,
) -> None:
"""Initialize the switch."""
super().__init__(coordinator)
self._attr_unique_id = f"{entry.entry_id}_connection"
# Build device info with optional serial number (VIN)
self._attr_device_info = {
"identifiers": {(DOMAIN, entry.entry_id)},
}
if coordinator.vin:
self._attr_device_info["serial_number"] = coordinator.vin
if coordinator.protocol_version:
self._attr_device_info["sw_version"] = coordinator.protocol_version
self._attr_translation_key = "connection"
_LOGGER.debug(
"Switch initialized: unique_id=%s, coordinator.is_connected=%s",
self._attr_unique_id,
coordinator.is_connected,
)
@property
def is_on(self) -> bool:
"""Return True if connection is desired (not manually disconnected)."""
# Switch represents the desired state, not the actual connection status
# If manual_disconnect is False, user wants to be connected
state = not self.coordinator._manual_disconnect
_LOGGER.debug(
"Switch is_on property called: returning %s (manual_disconnect=%s, is_connected=%s)",
state,
self.coordinator._manual_disconnect,
self.coordinator.is_connected
)
return state
@property
def icon(self) -> str:
"""Return the icon."""
return "mdi:bluetooth-connect" if self.is_on else "mdi:bluetooth-off"
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the switch - request connection to the bike."""
_LOGGER.debug(
"Switch.async_turn_on called (current coordinator.is_connected: %s, manual_disconnect: %s)",
self.coordinator.is_connected,
self.coordinator._manual_disconnect,
)
# Update state immediately - switch is now ON (connection desired)
self.async_write_ha_state()
try:
await self.coordinator.async_reconnect()
_LOGGER.debug(
"Switch.async_turn_on: reconnect completed (coordinator.is_connected: %s)",
self.coordinator.is_connected,
)
except Exception as ex:
# Provide user-friendly error message
error_msg = str(ex)
if "not reachable" in error_msg.lower():
_LOGGER.warning(
"Switch.async_turn_on: Cannot connect now - bike is not reachable. "
"Will auto-connect when bike is powered on."
)
else:
_LOGGER.error("Switch.async_turn_on: Failed to connect to bike: %s", ex, exc_info=True)
# Switch stays ON - coordinator will auto-reconnect when bike becomes available
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the switch - disconnect from the bike.
WARNING: This will turn off the bike after ~5 minutes! It must be manually
turned on again or connected to power.
"""
_LOGGER.warning(
"Switch.async_turn_off called - Disconnecting from bike (current coordinator.is_connected: %s). "
"Bike will turn off after approximately 5 minutes and must be manually turned on again or connected to power",
self.coordinator.is_connected,
)
try:
await self.coordinator.async_disconnect()
_LOGGER.debug(
"Switch.async_turn_off: disconnect completed (coordinator.is_connected: %s, manual_disconnect: %s)",
self.coordinator.is_connected,
self.coordinator._manual_disconnect,
)
self.async_write_ha_state()
_LOGGER.debug("Switch.async_turn_off: state written to HA")
except Exception as ex:
_LOGGER.error("Switch.async_turn_off: Failed to disconnect from bike: %s", ex, exc_info=True)
@@ -0,0 +1,39 @@
{
"config": {
"step": {
"bluetooth_confirm": {
"description": "Möchten Sie das MySmartBike-Gerät {name} zu Home Assistant hinzufügen?"
},
"user": {
"data": {
"address": "Gerät"
},
"description": "Wählen Sie Ihr MySmartBike-Gerät aus"
}
},
"abort": {
"already_configured": "Gerät ist bereits konfiguriert",
"no_devices_found": "Keine MySmartBike-Geräte gefunden. Stellen Sie sicher, dass Ihr Gerät eingeschaltet und in Reichweite ist."
}
},
"options": {
"step": {
"init": {
"data": {
"log_ble_messages": "BLE-Nachrichten in Datei speichern"
},
"description": "Erweiterte Einstellungen für MySmartBike BLE"
}
}
},
"entity": {
"switch": {
"connection": {
"name": "Verbindung",
"state_attributes": {
"description": "Bluetooth-Verbindung zum E-Bike. WARNUNG: Beim Ausschalten schaltet sich das Rad nach ca. 5 Minuten ab und muss manuell wieder eingeschaltet oder an den Strom angeschlossen werden!"
}
}
}
}
}
@@ -0,0 +1,39 @@
{
"config": {
"step": {
"bluetooth_confirm": {
"description": "Do you want to add the MySmartBike device {name} to Home Assistant?"
},
"user": {
"data": {
"address": "Device"
},
"description": "Select your MySmartBike device"
}
},
"abort": {
"already_configured": "Device is already configured",
"no_devices_found": "No MySmartBike devices found. Make sure your device is turned on and in range."
}
},
"options": {
"step": {
"init": {
"data": {
"log_ble_messages": "Save BLE messages to file"
},
"description": "Advanced settings for MySmartBike BLE"
}
}
},
"entity": {
"switch": {
"connection": {
"name": "Connection",
"state_attributes": {
"description": "Bluetooth connection to the e-bike. WARNING: Turning off will shut down the bike after approximately 5 minutes and it must be manually turned on again or connected to power!"
}
}
}
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "MySmartBike BLE",
"render_readme": true,
"domains": [
"sensor"
],
"iot_class": "local_polling",
"homeassistant": "2024.1.0",
"zip_release": true
}
+4
View File
@@ -0,0 +1,4 @@
[pytest]
testpaths = tests
norecursedirs = .git
asyncio_mode = auto
+5
View File
@@ -0,0 +1,5 @@
pytest>=7.4.0
pytest-asyncio>=0.21.0
pytest-cov>=4.1.0
pytest-homeassistant-custom-component>=0.13.0
homeassistant>=2024.1.0
+1
View File
@@ -0,0 +1 @@
"""Tests for MySmartBike BLE integration."""
+1
View File
@@ -0,0 +1 @@
"""Tests for components."""
@@ -0,0 +1 @@
"""Tests for MySmartBike BLE integration."""
@@ -0,0 +1,95 @@
"""Fixtures for MySmartBike BLE tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from homeassistant.const import CONF_ADDRESS
from custom_components.mysmartbike_ble.const import (
CONF_DEVICE_ADDRESS,
CONF_DEVICE_NAME,
DOMAIN,
)
from pytest_homeassistant_custom_component.common import MockConfigEntry
def _get_bluetooth_service_info() -> MagicMock:
"""Return a mock BluetoothServiceInfoBleak."""
service_info = MagicMock()
service_info.name = "iWoc1A36"
service_info.address = "AA:BB:CC:DD:EE:FF"
service_info.rssi = -60
service_info.manufacturer_data = {}
service_info.service_data = {}
service_info.service_uuids = []
service_info.source = "local"
return service_info
@pytest.fixture
def mock_bluetooth_service_info() -> MagicMock:
"""Return mock Bluetooth service info."""
return _get_bluetooth_service_info()
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return default mocked config entry."""
return MockConfigEntry(
domain=DOMAIN,
title="iWoc1A36",
data={
CONF_DEVICE_NAME: "iWoc1A36",
CONF_DEVICE_ADDRESS: "AA:BB:CC:DD:EE:FF",
},
unique_id="AA:BB:CC:DD:EE:FF",
)
@pytest.fixture
def mock_bleak_client() -> Generator[MagicMock]:
"""Return a mocked BleakClient."""
with patch(
"custom_components.mysmartbike_ble.coordinator.establish_connection",
autospec=True,
) as mock_client:
client = AsyncMock()
client.is_connected = True
client.write_gatt_char = AsyncMock()
client.start_notify = AsyncMock()
client.stop_notify = AsyncMock()
client.disconnect = AsyncMock()
mock_client.return_value = client
yield mock_client
@pytest.fixture
def mock_ble_device() -> Generator[MagicMock]:
"""Return a mocked BLE device."""
with patch(
"custom_components.mysmartbike_ble.config_flow.async_discovered_service_info",
autospec=True,
) as mock_devices:
mock_devices.return_value = [_get_bluetooth_service_info()]
yield mock_devices
@pytest.fixture
async def init_integration(
hass,
mock_config_entry: MockConfigEntry,
mock_bleak_client: MagicMock,
) -> MockConfigEntry:
"""Set up the MySmartBike BLE integration for testing."""
mock_config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.bluetooth.async_ble_device_from_address"
) as mock_ble_device_from_address:
mock_ble_device_from_address.return_value = _get_bluetooth_service_info()
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry
@@ -0,0 +1,141 @@
"""Test the MySmartBike BLE config flow."""
from unittest.mock import patch
import pytest
from homeassistant import config_entries
from homeassistant.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from custom_components.mysmartbike_ble.const import CONF_DEVICE_ADDRESS, CONF_DEVICE_NAME, DOMAIN
from .conftest import _get_bluetooth_service_info
async def test_bluetooth_discovery(hass: HomeAssistant) -> None:
"""Test discovery via Bluetooth."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=_get_bluetooth_service_info(),
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "bluetooth_confirm"
with patch(
"homeassistant.components.bluetooth.async_ble_device_from_address"
) as mock_ble_device:
mock_ble_device.return_value = _get_bluetooth_service_info()
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == "iWoc1A36"
assert result2["data"] == {
CONF_DEVICE_NAME: "iWoc1A36",
CONF_DEVICE_ADDRESS: "AA:BB:CC:DD:EE:FF",
}
async def test_bluetooth_discovery_already_configured(
hass: HomeAssistant, mock_config_entry
) -> None:
"""Test discovery aborts if already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=_get_bluetooth_service_info(),
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_user_flow_success(hass: HomeAssistant, mock_ble_device) -> None:
"""Test user flow - successful flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
with patch(
"homeassistant.components.bluetooth.async_ble_device_from_address"
) as mock_ble_device_from_address:
mock_ble_device_from_address.return_value = _get_bluetooth_service_info()
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"},
)
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == "iWoc1A36"
assert result2["data"] == {
CONF_DEVICE_NAME: "iWoc1A36",
CONF_DEVICE_ADDRESS: "AA:BB:CC:DD:EE:FF",
}
async def test_user_flow_no_devices_found(hass: HomeAssistant) -> None:
"""Test user flow - no devices found."""
with patch(
"custom_components.mysmartbike_ble.config_flow.async_discovered_service_info",
return_value=[],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
async def test_user_flow_already_configured(
hass: HomeAssistant, mock_config_entry, mock_ble_device
) -> None:
"""Test user flow - device already configured.
When the only discovered device is already configured,
it should abort immediately with no_devices_found.
"""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
async def test_user_flow_device_not_found_after_selection(
hass: HomeAssistant, mock_ble_device
) -> None:
"""Test user flow - device not found after selection."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
# Device disappears after selection (returns None)
with patch(
"homeassistant.components.bluetooth.async_ble_device_from_address",
return_value=None,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"},
)
# This should trigger ConfigEntryNotReady in the actual setup,
# but in config_flow it just proceeds to create the entry
assert result2["type"] == FlowResultType.CREATE_ENTRY
@@ -0,0 +1,316 @@
"""Test the MySmartBike BLE parsers with real message data."""
import pytest
from custom_components.mysmartbike_ble.parsers import (
BikeDataParser,
read16,
read24,
read32,
read_unsigned_byte,
)
class TestReadFunctions:
"""Test byte reading functions (big-endian)."""
def test_read16_big_endian(self):
"""Test 16-bit big-endian read."""
# Big-endian: first byte is most significant
data = bytes([0x12, 0x34])
assert read16(data, 0) == 0x1234
def test_read24_big_endian(self):
"""Test 24-bit big-endian read."""
data = bytes([0x12, 0x34, 0x56])
assert read24(data, 0) == 0x123456
def test_read32_big_endian(self):
"""Test 32-bit big-endian read."""
data = bytes([0x12, 0x34, 0x56, 0x78])
assert read32(data, 0) == 0x12345678
def test_read_unsigned_byte(self):
"""Test unsigned byte read."""
assert read_unsigned_byte(0xFF) == 255
assert read_unsigned_byte(0x00) == 0
assert read_unsigned_byte(0x7F) == 127
class TestEbmParser:
"""Test EBM message parsing with real data."""
# Real EBM message from log: 246a245a230056af68000bef6f00002340
# Expected: Odometer ~568.1 km, Range ~78.2 km
EBM_MESSAGE = bytes.fromhex("246a245a230056af68000bef6f00002340")
def test_ebm_message_recognition(self):
"""Test that EBM message type is recognized."""
parser = BikeDataParser()
msg_type = parser.recognize_message_type(self.EBM_MESSAGE)
assert msg_type == "ebm"
def test_ebm_odometry_parsing(self):
"""Test odometry value parsing from real EBM message."""
parser = BikeDataParser()
result = parser.parse_ebm_message(self.EBM_MESSAGE)
assert result is not None
# Odometry: 0x0056af68 = 5681000 / 10000 = 568.1 km
assert abs(result["odometry"] - 568.1) < 0.1
def test_ebm_autonomy_parsing(self):
"""Test autonomy/range value parsing from real EBM message."""
parser = BikeDataParser()
result = parser.parse_ebm_message(self.EBM_MESSAGE)
assert result is not None
# Autonomy: 0x000bef6f = 782191 / 10000 = 78.2 km
assert abs(result["autonomy"] - 78.2) < 0.1
def test_ebm_light_status(self):
"""Test light status parsing from real EBM message."""
parser = BikeDataParser()
result = parser.parse_ebm_message(self.EBM_MESSAGE)
assert result is not None
# Byte 13 = 0x00, so light is off
assert result["is_light_on"] is False
def test_ebm_state_update(self):
"""Test that parser state is updated after parsing."""
parser = BikeDataParser()
parser.parse_ebm_message(self.EBM_MESSAGE)
assert parser.state["ebm"] is not None
assert "odometry" in parser.state["ebm"]
assert "autonomy" in parser.state["ebm"]
class TestMotorParser:
"""Test motor message parsing with real data."""
# Real motor message from log: 246d245a230117000000000000004f642340
MOTOR_MESSAGE = bytes.fromhex("246d245a230117000000000000004f642340")
def test_motor_message_recognition(self):
"""Test that motor message type is recognized."""
parser = BikeDataParser()
msg_type = parser.recognize_message_type(self.MOTOR_MESSAGE)
assert msg_type == "motor"
def test_motor_parsing(self):
"""Test motor value parsing from real message."""
parser = BikeDataParser()
result = parser.parse_motor_message(self.MOTOR_MESSAGE)
assert result is not None
# Assist level at byte 5 = 0x01
assert result["assist_level"] == 1
# Temperature at byte 6 = 0x17 = 23°C
assert result["temperature_celsius"] == 23
class TestBatteryParser:
"""Test battery message parsing with real data."""
# Real battery message from log: 2462245a230193541700000877071a27342340
BATTERY_MESSAGE = bytes.fromhex("2462245a230193541700000877071a27342340")
def test_battery_message_recognition(self):
"""Test that battery message type is recognized."""
parser = BikeDataParser()
msg_type = parser.recognize_message_type(self.BATTERY_MESSAGE)
assert msg_type == "battery"
def test_battery_voltage(self):
"""Test battery voltage parsing."""
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
assert result is not None
# Voltage: 0x0193 = 403 / 10 = 40.3 V
assert abs(result["voltage"] - 40.3) < 0.1
def test_battery_soc(self):
"""Test battery state of charge parsing."""
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
assert result is not None
# SoC at byte 7 = 0x54 = 84%
assert result["soc"] == 84
def test_battery_temperature(self):
"""Test battery temperature parsing."""
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
assert result is not None
# Temperature at byte 8 = 0x17 = 23
assert result["temperature"] == 23
def test_battery_current(self):
"""Test battery current parsing."""
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
assert result is not None
# Current: 0x0000 = 0 / 10 = 0.0 A
assert result["current"] == 0.0
def test_battery_capacity(self):
"""Test battery nominal capacity parsing."""
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
assert result is not None
# Nominal capacity: 0x0877 = 2167 / 10 = 216.7 Wh
assert abs(result["nominal_capacity"] - 216.7) < 0.1
def test_battery_remaining(self):
"""Test battery remaining energy parsing."""
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
assert result is not None
# Remaining Wh: 0x071a = 1818 / 10 = 181.8 Wh
assert abs(result["remaining_wh"] - 181.8) < 0.1
def test_battery_cycles(self):
"""Test battery cycles parsing."""
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
assert result is not None
# Cycles: 0x2734 = 10036, cycles = 10036 % 10000 = 36
assert result["cycles"] == 36
def test_battery_number_detection(self):
"""Test primary/secondary battery detection."""
parser = BikeDataParser()
parser.parse_battery_message(self.BATTERY_MESSAGE)
# Battery number = 10036 / 10000 = 1 (primary)
assert parser.state["battery_primary"] is not None
assert parser.state["battery_primary"]["cycles"] == 36
class TestVinParser:
"""Test VIN/serial number message parsing."""
# Example VIN message: $s$V#SB000000002207203#@
# Hex: 24 73 24 56 23 + serial + 23 40
VIN_SERIAL = "SB000000002207203"
VIN_MESSAGE = f"$s$V#{VIN_SERIAL}#@".encode("utf-8")
def test_vin_message_recognition(self):
"""Test that VIN message type is recognized."""
parser = BikeDataParser()
msg_type = parser.recognize_message_type(self.VIN_MESSAGE)
assert msg_type == "vin"
def test_vin_parsing_standard_format(self):
"""Test VIN parsing from standard format $s$V#<serial>#@."""
parser = BikeDataParser()
result = parser.parse_vin_message(self.VIN_MESSAGE)
assert result == self.VIN_SERIAL
assert parser.vin == self.VIN_SERIAL
def test_vin_parsing_r0_format(self):
"""Test VIN parsing from R0 format (20 chars ending with @)."""
parser = BikeDataParser()
# R0 format: R0<17 char serial>@
serial = "AB123456789012345"
message = f"R0{serial}@".encode("utf-8")
result = parser.parse_vin_message(message)
assert result == serial
assert parser.vin == serial
def test_vin_handle_message_updates_state(self):
"""Test that handle_message updates VIN state."""
parser = BikeDataParser()
parser.handle_message(self.VIN_MESSAGE)
assert parser.vin == self.VIN_SERIAL
class TestAssistParser:
"""Test assist level message parsing with real data."""
# Real assist message from log: 246d2441233033312340
ASSIST_MESSAGE = bytes.fromhex("246d2441233033312340")
def test_assist_message_recognition(self):
"""Test that assist message type is recognized."""
parser = BikeDataParser()
msg_type = parser.recognize_message_type(self.ASSIST_MESSAGE)
assert msg_type == "assist"
def test_assist_parsing(self):
"""Test assist level parsing from real message."""
parser = BikeDataParser()
result = parser.parse_assist_level_message(self.ASSIST_MESSAGE)
assert result is not None
# Message is "031" which means min=0, max=3, current=1
assert result["min"] == 0
assert result["max"] == 3
assert result["current"] == 1
class TestProtocolParser:
"""Test protocol version message parsing."""
# Protocol message format: $s$P#<version>#@
PROTOCOL_MESSAGE_V102 = b"$s$P#1.02#@"
PROTOCOL_MESSAGE_V100 = b"$s$P#1.00#@"
PROTOCOL_MESSAGE_V300 = b"$s$P#3.00#@"
PROTOCOL_MESSAGE_ERROR = b"$s$P#ER#@"
def test_protocol_message_recognition(self):
"""Test that protocol message type is recognized."""
parser = BikeDataParser()
msg_type = parser.recognize_message_type(self.PROTOCOL_MESSAGE_V102)
assert msg_type == "protocol"
def test_protocol_parsing_v102(self):
"""Test protocol version 1.02 parsing."""
parser = BikeDataParser()
result = parser.parse_protocol_message(self.PROTOCOL_MESSAGE_V102)
assert result == "1.02"
assert parser.protocol_version == "1.02"
def test_protocol_parsing_v100(self):
"""Test protocol version 1.00 parsing."""
parser = BikeDataParser()
result = parser.parse_protocol_message(self.PROTOCOL_MESSAGE_V100)
assert result == "1.00"
assert parser.protocol_version == "1.00"
def test_protocol_parsing_v300(self):
"""Test protocol version 3.00 parsing."""
parser = BikeDataParser()
result = parser.parse_protocol_message(self.PROTOCOL_MESSAGE_V300)
assert result == "3.00"
assert parser.protocol_version == "3.00"
def test_protocol_parsing_error(self):
"""Test protocol error response handling."""
parser = BikeDataParser()
result = parser.parse_protocol_message(self.PROTOCOL_MESSAGE_ERROR)
assert result is None
assert parser.protocol_version is None
def test_protocol_handle_message_updates_state(self):
"""Test that handle_message updates protocol version."""
parser = BikeDataParser()
parser.handle_message(self.PROTOCOL_MESSAGE_V102)
assert parser.protocol_version == "1.02"
@@ -0,0 +1,162 @@
"""Test the MySmartBike BLE switch."""
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
def get_connection_switch_id(hass: HomeAssistant) -> str:
"""Get the connection switch entity ID."""
entity_registry = er.async_get(hass)
for entity in entity_registry.entities.values():
if entity.unique_id.endswith("_connection"):
return entity.entity_id
raise ValueError("Connection switch not found")
async def test_switch_setup(hass: HomeAssistant, init_integration) -> None:
"""Test switch setup."""
entity_id = get_connection_switch_id(hass)
entity_registry = er.async_get(hass)
# Check if the connection switch entity exists
entry = entity_registry.async_get(entity_id)
assert entry
assert entry.unique_id.endswith("_connection")
async def test_switch_initial_state(hass: HomeAssistant, init_integration) -> None:
"""Test switch initial state is on (connected)."""
entity_id = get_connection_switch_id(hass)
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_ON
async def test_switch_turn_off(hass: HomeAssistant, init_integration) -> None:
"""Test turning off the switch disconnects from bike."""
entity_id = get_connection_switch_id(hass)
coordinator = init_integration.runtime_data
# Mock the disconnect method
with patch.object(coordinator, "async_disconnect", new_callable=AsyncMock) as mock_disconnect:
# Turn off the switch
await hass.services.async_call(
SWITCH_DOMAIN,
"turn_off",
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
await hass.async_block_till_done()
# Verify disconnect was called
mock_disconnect.assert_called_once()
async def test_switch_turn_on(hass: HomeAssistant, init_integration) -> None:
"""Test turning on the switch reconnects to bike."""
entity_id = get_connection_switch_id(hass)
coordinator = init_integration.runtime_data
# Mock the reconnect method
with patch.object(coordinator, "async_reconnect", new_callable=AsyncMock) as mock_reconnect:
# Turn on the switch
await hass.services.async_call(
SWITCH_DOMAIN,
"turn_on",
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
await hass.async_block_till_done()
# Verify reconnect was called
mock_reconnect.assert_called_once()
async def test_switch_icon(hass: HomeAssistant, init_integration) -> None:
"""Test switch icon is correct for connected state."""
entity_id = get_connection_switch_id(hass)
state = hass.states.get(entity_id)
assert state is not None
assert state.attributes.get("icon") == "mdi:bluetooth-connect"
async def test_switch_turn_off_error_handling(
hass: HomeAssistant, init_integration
) -> None:
"""Test error handling when turning off switch fails."""
entity_id = get_connection_switch_id(hass)
coordinator = init_integration.runtime_data
# Mock disconnect to raise an exception
with patch.object(
coordinator, "async_disconnect", side_effect=Exception("Disconnect failed")
):
# Turn off should not raise, but log error
await hass.services.async_call(
SWITCH_DOMAIN,
"turn_off",
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
# Entity should still exist
state = hass.states.get(entity_id)
assert state is not None
async def test_switch_turn_on_error_handling(
hass: HomeAssistant, init_integration
) -> None:
"""Test error handling when turning on switch fails."""
entity_id = get_connection_switch_id(hass)
coordinator = init_integration.runtime_data
# Mock reconnect to raise an exception
with patch.object(
coordinator, "async_reconnect", side_effect=Exception("Reconnect failed")
):
# Turn on should not raise, but log error
await hass.services.async_call(
SWITCH_DOMAIN,
"turn_on",
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
# Entity should still exist
state = hass.states.get(entity_id)
assert state is not None
async def test_no_auto_reconnect_when_manually_disconnected(
hass: HomeAssistant, init_integration
) -> None:
"""Test that coordinator doesn't auto-reconnect after manual disconnect."""
entity_id = get_connection_switch_id(hass)
coordinator = init_integration.runtime_data
# Turn off the switch (manual disconnect)
with patch.object(coordinator, "async_disconnect", wraps=coordinator.async_disconnect) as mock_disconnect:
await hass.services.async_call(
SWITCH_DOMAIN,
"turn_off",
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
await hass.async_block_till_done()
mock_disconnect.assert_called_once()
# Verify manual disconnect flag is set
assert coordinator._manual_disconnect is True
# Trigger an update - should NOT attempt to reconnect
with patch.object(coordinator, "_connect", new_callable=AsyncMock) as mock_connect:
await coordinator.async_refresh()
await hass.async_block_till_done()
# _connect should NOT have been called
mock_connect.assert_not_called()
+10
View File
@@ -0,0 +1,10 @@
"""Global fixtures for MySmartBike BLE integration tests."""
import pytest
pytest_plugins = "pytest_homeassistant_custom_component"
@pytest.fixture(autouse=True)
def auto_enable_custom_integrations(enable_custom_integrations):
"""Enable custom integrations for all tests."""
yield