inital commit
This commit is contained in:
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
@@ -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/
|
||||
@@ -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.
|
||||
@@ -0,0 +1,147 @@
|
||||
# MySmartBike BLE Integration for Home Assistant
|
||||
|
||||
[](https://github.com/hacs/integration)
|
||||
[](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),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "MySmartBike BLE",
|
||||
"render_readme": true,
|
||||
"domains": [
|
||||
"sensor"
|
||||
],
|
||||
"iot_class": "local_polling",
|
||||
"homeassistant": "2024.1.0",
|
||||
"zip_release": true
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
norecursedirs = .git
|
||||
asyncio_mode = auto
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for MySmartBike BLE integration."""
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user