inital commit
This commit is contained in:
@@ -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!"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user