inital commit

This commit is contained in:
Rene Nulsch
2025-11-21 22:38:55 +01:00
commit 4e66d2563b
28 changed files with 2694 additions and 0 deletions
@@ -0,0 +1,105 @@
"""The MySmartBike BLE integration."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components import bluetooth
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN, CONF_DEVICE_ADDRESS
from .coordinator import MySmartBikeCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up MySmartBike BLE from a config entry."""
_LOGGER.debug("Setting up MySmartBike BLE integration for entry_id: %s", entry.entry_id)
address = entry.data[CONF_DEVICE_ADDRESS]
_LOGGER.debug("Device address from config: %s", address)
# Get BLE device
_LOGGER.debug("Looking up BLE device with address: %s", address)
ble_device = bluetooth.async_ble_device_from_address(hass, address, connectable=True)
if not ble_device:
# Log warning only once per config entry
# Use hass.data for warning flag as it's separate from coordinator runtime_data
hass.data.setdefault(DOMAIN, {})
warning_key = f"warned_{entry.entry_id}"
if not hass.data[DOMAIN].get(warning_key):
_LOGGER.warning(
"MySmartBike device with address %s not found. "
"Make sure the bike is powered on and in range. "
"Home Assistant will retry automatically",
address
)
hass.data[DOMAIN][warning_key] = True
raise ConfigEntryNotReady(f"Could not find MySmartBike device with address {address}")
# Clear warning flag when device is found
if DOMAIN in hass.data:
hass.data[DOMAIN].pop(f"warned_{entry.entry_id}", None)
_LOGGER.debug("Found BLE device: %s", ble_device)
# Create coordinator
_LOGGER.debug("Creating coordinator for device %s", address)
coordinator = MySmartBikeCoordinator(hass, ble_device, entry)
# Perform first refresh
_LOGGER.debug("Performing first coordinator refresh")
await coordinator.async_config_entry_first_refresh()
_LOGGER.debug(
"First refresh completed - coordinator state: is_connected=%s, manual_disconnect=%s",
coordinator.is_connected,
coordinator._manual_disconnect,
)
# Store coordinator in runtime_data
entry.runtime_data = coordinator
_LOGGER.debug("Coordinator stored in entry.runtime_data")
# Forward entry setup to platforms
_LOGGER.debug("Forwarding entry setup to platforms: %s", PLATFORMS)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
_LOGGER.debug("Platform setup completed")
_LOGGER.debug("MySmartBike BLE integration setup completed successfully for entry_id: %s", entry.entry_id)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
_LOGGER.debug("Unloading MySmartBike BLE integration for entry_id: %s", entry.entry_id)
# Unload platforms
_LOGGER.debug("Unloading platforms: %s", PLATFORMS)
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
_LOGGER.debug("Platform unload result: %s", unload_ok)
if unload_ok:
coordinator: MySmartBikeCoordinator = entry.runtime_data
_LOGGER.debug(
"Coordinator retrieved from runtime_data - state: is_connected=%s, manual_disconnect=%s",
coordinator.is_connected,
coordinator._manual_disconnect,
)
await coordinator.async_shutdown()
_LOGGER.debug("Coordinator shutdown completed")
# Clean up warning flag from hass.data
if DOMAIN in hass.data:
hass.data[DOMAIN].pop(f"warned_{entry.entry_id}", None)
else:
_LOGGER.warning("Platform unload was not successful")
_LOGGER.debug("MySmartBike BLE integration unload completed for entry_id: %s (result: %s)", entry.entry_id, unload_ok)
return unload_ok
@@ -0,0 +1,68 @@
"""Binary sensor platform for MySmartBike BLE integration."""
from __future__ import annotations
import logging
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import MySmartBikeCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up MySmartBike BLE binary sensor entities."""
coordinator: MySmartBikeCoordinator = entry.runtime_data
async_add_entities([MySmartBikeConnectionSensor(coordinator, entry)])
class MySmartBikeConnectionSensor(CoordinatorEntity[MySmartBikeCoordinator], BinarySensorEntity):
"""Binary sensor showing BLE connection status to the bike."""
_attr_has_entity_name = True
_attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
def __init__(
self,
coordinator: MySmartBikeCoordinator,
entry: ConfigEntry,
) -> None:
"""Initialize the binary sensor."""
super().__init__(coordinator)
self._attr_unique_id = f"{entry.entry_id}_connected"
self._attr_device_info = {
"identifiers": {(DOMAIN, entry.entry_id)},
}
if coordinator.vin:
self._attr_device_info["serial_number"] = coordinator.vin
if coordinator.protocol_version:
self._attr_device_info["sw_version"] = coordinator.protocol_version
self._attr_translation_key = "connected"
@property
def is_on(self) -> bool:
"""Return True if connected to the bike."""
return self.coordinator.is_connected
@property
def name(self) -> str:
"""Return the name of the sensor."""
return "Connected"
@property
def icon(self) -> str:
"""Return the icon."""
return "mdi:bluetooth-connect" if self.is_on else "mdi:bluetooth-off"
@@ -0,0 +1,145 @@
"""Config flow for MySmartBike BLE integration."""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
)
from homeassistant.const import CONF_ADDRESS
from homeassistant.data_entry_flow import FlowResult
from .const import DOMAIN, CONF_DEVICE_ADDRESS, CONF_DEVICE_NAME, CONF_LOG_BLE_MESSAGES
_LOGGER = logging.getLogger(__name__)
class MySmartBikeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for MySmartBike BLE."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovery_info: BluetoothServiceInfoBleak | None = None
self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {}
@staticmethod
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> config_entries.OptionsFlow:
"""Get the options flow for this handler."""
return MySmartBikeOptionsFlowHandler()
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> FlowResult:
"""Handle the bluetooth discovery step."""
_LOGGER.debug("Discovered BLE device: %s", discovery_info)
await self.async_set_unique_id(discovery_info.address)
self._abort_if_unique_id_configured()
self._discovery_info = discovery_info
return await self.async_step_bluetooth_confirm()
async def async_step_bluetooth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Confirm discovery."""
assert self._discovery_info is not None
if user_input is not None:
return self.async_create_entry(
title=self._discovery_info.name,
data={
CONF_DEVICE_NAME: self._discovery_info.name,
CONF_DEVICE_ADDRESS: self._discovery_info.address,
},
)
self._set_confirm_only()
return self.async_show_form(
step_id="bluetooth_confirm",
description_placeholders={
"name": self._discovery_info.name,
},
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the user step to pick discovered device."""
if user_input is not None:
address = user_input[CONF_ADDRESS]
await self.async_set_unique_id(address, raise_on_progress=False)
self._abort_if_unique_id_configured()
discovery_info = self._discovered_devices[address]
return self.async_create_entry(
title=discovery_info.name,
data={
CONF_DEVICE_NAME: discovery_info.name,
CONF_DEVICE_ADDRESS: discovery_info.address,
},
)
current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass, False):
if (
discovery_info.address in current_addresses
or discovery_info.address in self._discovered_devices
):
continue
# Check if device name starts with "iWoc"
if discovery_info.name and discovery_info.name.startswith("iWoc"):
self._discovered_devices[discovery_info.address] = discovery_info
if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_ADDRESS): vol.In(
{
address: f"{info.name} ({info.address})"
for address, info in self._discovered_devices.items()
}
)
}
),
)
class MySmartBikeOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle options flow for MySmartBike BLE."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Manage the options."""
if user_input is not None:
_LOGGER.debug("Options flow: User input received: %s", user_input)
return self.async_create_entry(title="", data=user_input)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Optional(
CONF_LOG_BLE_MESSAGES,
default=self.config_entry.options.get(CONF_LOG_BLE_MESSAGES, False),
): bool,
}
),
)
@@ -0,0 +1,38 @@
"""Constants for the MySmartBike BLE integration."""
from typing import Final
DOMAIN: Final = "mysmartbike_ble"
# BLE UUIDs
WRITE_UUID: Final = "0000FFE2-0000-1000-8000-00805F9B34FB"
NOTIFY_UUID: Final = "0000FFD1-0000-1000-8000-00805F9B34FB"
# BLE Messages
VIN_REQUEST_MESSAGE: Final = bytearray([0x24, 0x53, 0x24, 0x56, 0x23, 0x40]) # $S$V#@
PROTOCOL_REQUEST_MESSAGE: Final = bytearray([0x24, 0x53, 0x24, 0x50, 0x23, 0x40]) # $S$P#@
CLOSE_MESSAGE: Final = bytearray([0x24, 0x44, 0x24, 0x49, 0x23, 0x40]) # $D$I#@
# Legacy alias
WAKEUP_MESSAGE: Final = VIN_REQUEST_MESSAGE
# Message lengths
BATTERY_MESSAGE_LENGTH: Final = 17
MOTOR_MESSAGE_LENGTH: Final = 18
EBM_MESSAGE_LENGTH: Final = 17
# Connection settings
MAX_CONNECT_ATTEMPTS: Final = 3
BLACKLIST_DURATION: Final = 300 # 5 minutes in seconds
CONNECTION_TIMEOUT: Final = 120 # seconds
SCAN_INTERVAL: Final = 30 # seconds
# Device info
MANUFACTURER: Final = "Mahle"
MODEL: Final = "iWoc BLE"
# Config entry keys
CONF_DEVICE_NAME: Final = "device_name"
CONF_DEVICE_ADDRESS: Final = "device_address"
# Options
CONF_LOG_BLE_MESSAGES: Final = "log_ble_messages"
@@ -0,0 +1,463 @@
"""Coordinator for MySmartBike BLE integration."""
from __future__ import annotations
import asyncio
import logging
import os
from datetime import datetime, timedelta
from typing import Any
from bleak import BleakClient
from bleak.exc import BleakError
from bleak_retry_connector import (
BleakClientWithServiceCache,
establish_connection,
)
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import BluetoothServiceInfoBleak
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
DOMAIN,
WRITE_UUID,
NOTIFY_UUID,
VIN_REQUEST_MESSAGE,
PROTOCOL_REQUEST_MESSAGE,
CLOSE_MESSAGE,
SCAN_INTERVAL,
CONF_LOG_BLE_MESSAGES,
CONF_DEVICE_NAME,
)
from .parsers import BikeDataParser
_LOGGER = logging.getLogger(__name__)
class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to manage fetching MySmartBike data."""
def __init__(
self,
hass: HomeAssistant,
ble_device: BluetoothServiceInfoBleak,
entry: ConfigEntry,
) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=SCAN_INTERVAL),
)
self._ble_device = ble_device
self._entry = entry
self._client: BleakClient | None = None
self._parser = BikeDataParser()
self._is_connected = False
self._notify_task: asyncio.Task | None = None
self._manual_disconnect = False # Track if user manually disconnected
_LOGGER.debug(
"Coordinator initialized: address=%s, is_connected=%s, manual_disconnect=%s, scan_interval=%s",
ble_device.address,
self._is_connected,
self._manual_disconnect,
SCAN_INTERVAL,
)
@property
def address(self) -> str:
"""Return the address of the device."""
return self._ble_device.address
@property
def is_connected(self) -> bool:
"""Return connection status."""
_LOGGER.debug(
"Coordinator.is_connected property called: returning %s (manual_disconnect: %s)",
self._is_connected,
self._manual_disconnect,
)
return self._is_connected
@property
def vin(self) -> str | None:
"""Return the VIN/serial number if available."""
return self._parser.vin
@property
def protocol_version(self) -> str | None:
"""Return the protocol version if available."""
return self._parser.protocol_version
async def async_disconnect(self) -> None:
"""Disconnect from the device (user initiated)."""
_LOGGER.debug(
"Coordinator.async_disconnect called for %s (user initiated) - current state: is_connected=%s, manual_disconnect=%s, client=%s",
self._ble_device.address,
self._is_connected,
self._manual_disconnect,
self._client is not None,
)
# Mark as manually disconnected to prevent auto-reconnect
self._manual_disconnect = True
_LOGGER.debug("Coordinator.async_disconnect: Set manual_disconnect=True")
if self._client:
_LOGGER.debug("Coordinator.async_disconnect: Client exists, cleaning up connection")
client_to_cleanup = self._client
self._client = None # Clear reference immediately
self._is_connected = False
try:
# Only send close message if still connected
if client_to_cleanup.is_connected:
# Send close message to bike before disconnecting
_LOGGER.debug("Coordinator.async_disconnect: Sending close message ($D$I#@)")
try:
await client_to_cleanup.write_gatt_char(WRITE_UUID, CLOSE_MESSAGE)
_LOGGER.debug("Coordinator.async_disconnect: Close message sent")
await asyncio.sleep(0.5)
except Exception as ex:
_LOGGER.debug("Coordinator.async_disconnect: Error sending close message: %s", ex)
# Stop notifications
try:
await client_to_cleanup.stop_notify(NOTIFY_UUID)
_LOGGER.debug("Coordinator.async_disconnect: Stopped notifications")
except Exception as ex:
_LOGGER.debug("Coordinator.async_disconnect: Error stopping notifications: %s", ex)
# Disconnect from device
try:
await client_to_cleanup.disconnect()
_LOGGER.debug("Coordinator.async_disconnect: Disconnected from device")
except Exception as ex:
_LOGGER.debug("Coordinator.async_disconnect: Error during disconnect: %s", ex)
else:
_LOGGER.debug("Coordinator.async_disconnect: Client exists but not connected, skipping disconnect")
except Exception as ex:
_LOGGER.debug("Coordinator.async_disconnect: Unexpected error during disconnect: %s", ex, exc_info=True)
finally:
# Force delete the client object to help garbage collection
del client_to_cleanup
# Give BLE adapter significant time to release connection slot
_LOGGER.debug("Coordinator.async_disconnect: Waiting for connection slot release (3 seconds)")
await asyncio.sleep(3.0)
_LOGGER.debug("Coordinator.async_disconnect: Cleaned up client (is_connected=%s)", self._is_connected)
else:
_LOGGER.debug("Coordinator.async_disconnect: No client to disconnect")
self._is_connected = False
_LOGGER.debug(
"Coordinator.async_disconnect completed - final state: is_connected=%s, manual_disconnect=%s",
self._is_connected,
self._manual_disconnect,
)
async def async_reconnect(self) -> None:
"""Reconnect to the device (user initiated)."""
_LOGGER.debug(
"Coordinator.async_reconnect called for %s (user initiated) - current state: is_connected=%s, manual_disconnect=%s, client=%s",
self._ble_device.address,
self._is_connected,
self._manual_disconnect,
self._client is not None,
)
# Clean up any existing client first
if self._client:
_LOGGER.debug("Coordinator.async_reconnect: Found existing client, cleaning up first")
old_client = self._client
self._client = None
self._is_connected = False
try:
if old_client.is_connected:
await old_client.disconnect()
_LOGGER.debug("Coordinator.async_reconnect: Disconnected existing client")
except Exception as ex:
_LOGGER.debug("Coordinator.async_reconnect: Error disconnecting old client: %s", ex)
finally:
del old_client
# Wait longer for connection slot to be released
_LOGGER.debug("Coordinator.async_reconnect: Waiting for connection slot release (3 seconds)")
await asyncio.sleep(3.0)
_LOGGER.debug("Coordinator.async_reconnect: Cleaned up old client and waited for slot release")
# Clear manual disconnect flag to allow auto-reconnect
self._manual_disconnect = False
_LOGGER.debug("Coordinator.async_reconnect: Set manual_disconnect=False")
try:
await self._connect()
_LOGGER.debug(
"Coordinator.async_reconnect completed - final state: is_connected=%s, manual_disconnect=%s",
self._is_connected,
self._manual_disconnect,
)
except Exception as ex:
# Only log as error if it's not a "device not reachable" issue
error_str = str(ex).lower()
if "not reachable" in error_str or "turn on the bike" in error_str:
_LOGGER.debug("Coordinator.async_reconnect: Device not reachable, will retry later")
else:
_LOGGER.error("Coordinator.async_reconnect failed: %s", ex, exc_info=True)
raise
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch data from the device."""
_LOGGER.debug(
"Coordinator._async_update_data called - current state: is_connected=%s, manual_disconnect=%s",
self._is_connected,
self._manual_disconnect,
)
# Don't auto-reconnect if user manually disconnected
if not self._is_connected and not self._manual_disconnect:
_LOGGER.debug("Coordinator._async_update_data: Not connected and not manual disconnect, attempting auto-reconnect")
try:
await self._connect()
_LOGGER.debug("Coordinator._async_update_data: Auto-reconnect successful (is_connected=%s)", self._is_connected)
except Exception as ex:
# Only log as warning if device is not reachable, otherwise debug
error_str = str(ex).lower()
if "not reachable" in error_str or "turn on the bike" in error_str:
_LOGGER.debug("Coordinator._async_update_data: Auto-reconnect skipped - device not reachable")
else:
_LOGGER.debug("Coordinator._async_update_data: Auto-reconnect failed: %s", ex)
elif not self._is_connected and self._manual_disconnect:
_LOGGER.debug("Coordinator._async_update_data: Not connected but manual_disconnect=True, skipping auto-reconnect")
else:
_LOGGER.debug("Coordinator._async_update_data: Already connected, no action needed")
# Return current state from parser, ensure it's never None
state = self._parser.state or {
"battery_primary": None,
"battery_secondary": None,
"motor": None,
"assist": None,
"ebm": None,
}
# Add RSSI (signal strength) to state
# Get latest service info which contains current RSSI
try:
service_info = bluetooth.async_last_service_info(
self.hass, self._ble_device.address, connectable=True
)
state["rssi"] = service_info.rssi if service_info else None
except Exception as ex:
_LOGGER.debug("Could not get RSSI: %s", ex)
state["rssi"] = None
_LOGGER.debug("Coordinator._async_update_data: Returning state (has_data=%s, rssi=%s)", self._parser.state is not None, state.get("rssi"))
return state
async def _connect(self) -> None:
"""Connect to the device and start notifications."""
_LOGGER.debug(
"Coordinator._connect: Attempting to connect to %s (current is_connected=%s, manual_disconnect=%s, client=%s)",
self._ble_device.address,
self._is_connected,
self._manual_disconnect,
self._client is not None,
)
# Clean up any existing client before connecting
if self._client:
_LOGGER.warning("Coordinator._connect: Client already exists, cleaning up before new connection")
old_client = self._client
self._client = None
try:
if old_client.is_connected:
await old_client.disconnect()
except Exception as ex:
_LOGGER.debug("Coordinator._connect: Error cleaning up old client: %s", ex)
finally:
del old_client
_LOGGER.debug("Coordinator._connect: Waiting for connection slot release (3 seconds)")
await asyncio.sleep(3.0)
try:
_LOGGER.debug("Coordinator._connect: Calling establish_connection for %s", self._ble_device.address)
self._client = await establish_connection(
BleakClientWithServiceCache,
self._ble_device,
self._ble_device.address,
)
_LOGGER.debug("Coordinator._connect: Successfully connected to %s, client=%s", self._ble_device.address, self._client)
# Start notifications first
_LOGGER.debug("Coordinator._connect: Starting notifications on UUID %s", NOTIFY_UUID)
await self._client.start_notify(NOTIFY_UUID, self._notification_handler)
_LOGGER.debug("Coordinator._connect: Started notifications successfully")
# Request VIN/serial number ($S$V#@)
_LOGGER.debug("Coordinator._connect: Requesting VIN/serial number")
await self._client.write_gatt_char(WRITE_UUID, VIN_REQUEST_MESSAGE)
_LOGGER.debug("Coordinator._connect: VIN request sent")
# Small delay between requests
await asyncio.sleep(0.2)
# Request protocol version ($S$P#@)
_LOGGER.debug("Coordinator._connect: Requesting protocol version")
await self._client.write_gatt_char(WRITE_UUID, PROTOCOL_REQUEST_MESSAGE)
_LOGGER.debug("Coordinator._connect: Protocol request sent")
self._is_connected = True
_LOGGER.debug("Coordinator._connect: Set is_connected=True")
except (BleakError, asyncio.TimeoutError) as ex:
self._is_connected = False
# Check if error is due to device not being reachable (turned off)
error_str = str(ex).lower()
if "no longer reachable" in error_str or "out of connection slots" in error_str:
_LOGGER.warning(
"Coordinator._connect: Device %s is not reachable or powered off. "
"Turn on the bike to connect.",
self._ble_device.address
)
raise UpdateFailed(
f"Device {self._ble_device.address} is not reachable. "
"Please turn on the bike."
) from ex
else:
_LOGGER.error(
"Coordinator._connect: Failed to connect to device %s: %s (is_connected set to False)",
self._ble_device.address,
ex,
exc_info=True,
)
raise UpdateFailed(f"Failed to connect to device: {ex}") from ex
def _notification_handler(self, sender: int, data: bytearray) -> None:
"""Handle notification data."""
_LOGGER.debug("Received notification from %s: %s", sender, data.hex())
# Recognize message type before saving
message_type = self._parser.recognize_message_type(bytes(data))
# Save BLE message to file if option is enabled (run in executor to avoid blocking)
if self._entry.options.get(CONF_LOG_BLE_MESSAGES, False):
self.hass.async_add_executor_job(self._save_ble_message, data, message_type)
# Parse the message
self._parser.handle_message(bytes(data))
# Update coordinator data
self.async_set_updated_data(self._parser.state)
def _save_ble_message(self, data: bytearray, message_type: str = "unknown") -> None:
"""Save BLE message to a file."""
try:
# Get device name from config
device_name = self._entry.data.get(CONF_DEVICE_NAME, "unknown_device")
# Sanitize device name for use in filename
safe_device_name = "".join(c if c.isalnum() or c in ("-", "_") else "_" for c in device_name)
# Create date string for filename (one file per day): YYYYMMDD
date_str = datetime.now().strftime("%Y%m%d")
# Create filename with device name and date
filename = f"{safe_device_name}_{date_str}_ble_messages.log"
# Get component directory path
component_dir = os.path.dirname(os.path.abspath(__file__))
log_dir = os.path.join(component_dir, "messages")
# Create directory if it doesn't exist
os.makedirs(log_dir, exist_ok=True)
# Full file path
filepath = os.path.join(log_dir, filename)
# Try to decode data as string (handle non-UTF8 data gracefully)
try:
data_str = data.decode("utf-8", errors="replace")
# Replace control characters and non-printable chars with their hex representation
data_str_clean = "".join(
c if c.isprintable() else f"\\x{ord(c):02x}" for c in data_str
)
except Exception:
data_str_clean = "<decode error>"
# Format message with timestamp (human-readable with milliseconds)
timestamp_readable = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
# Format the hex part with message type, then pad to column 75 for the string value
hex_part = f"[{timestamp_readable}] Type: {message_type:20} Hex: {data.hex()}"
# Pad to column 75 (or at least add separator if hex is already longer)
padding = max(75 - len(hex_part), 2)
message_line = f"{hex_part}{' ' * padding}String: {data_str_clean}\n"
# Append to file
with open(filepath, "a", encoding="utf-8") as f:
f.write(message_line)
_LOGGER.debug("Saved BLE message to: %s", filepath)
except Exception as ex:
_LOGGER.error("Failed to save BLE message to file: %s", ex, exc_info=True)
async def async_shutdown(self) -> None:
"""Shutdown the coordinator."""
_LOGGER.debug(
"Coordinator.async_shutdown called - current state: is_connected=%s, manual_disconnect=%s, client=%s",
self._is_connected,
self._manual_disconnect,
self._client is not None,
)
if self._client:
_LOGGER.debug("Coordinator.async_shutdown: Client exists, cleaning up connection")
client_to_cleanup = self._client
self._client = None
self._is_connected = False
try:
# Only send close message if still connected
if client_to_cleanup.is_connected:
# Send close message to bike before disconnecting
try:
_LOGGER.debug("Coordinator.async_shutdown: Sending close message ($D$I#@)")
await client_to_cleanup.write_gatt_char(WRITE_UUID, CLOSE_MESSAGE)
_LOGGER.debug("Coordinator.async_shutdown: Close message sent")
await asyncio.sleep(0.5)
except Exception as ex:
_LOGGER.debug("Coordinator.async_shutdown: Error sending close message: %s", ex)
# Stop notifications
try:
await client_to_cleanup.stop_notify(NOTIFY_UUID)
_LOGGER.debug("Coordinator.async_shutdown: Stopped notifications")
except Exception as ex:
_LOGGER.debug("Coordinator.async_shutdown: Error stopping notifications: %s", ex)
# Disconnect from device
try:
await client_to_cleanup.disconnect()
_LOGGER.debug("Coordinator.async_shutdown: Disconnected from device")
except Exception as ex:
_LOGGER.debug("Coordinator.async_shutdown: Error during disconnect: %s", ex)
else:
_LOGGER.debug("Coordinator.async_shutdown: Client exists but not connected, skipping disconnect")
except Exception as ex:
_LOGGER.debug("Coordinator.async_shutdown: Unexpected error during shutdown: %s", ex, exc_info=True)
finally:
del client_to_cleanup
_LOGGER.debug("Coordinator.async_shutdown: Cleaned up client")
else:
_LOGGER.debug("Coordinator.async_shutdown: No client to clean up")
self._is_connected = False
_LOGGER.debug("Coordinator.async_shutdown completed")
@@ -0,0 +1,17 @@
{
"domain": "mysmartbike_ble",
"name": "MySmartBike BLE",
"codeowners": ["@renenulschde"],
"config_flow": true,
"documentation": "https://github.com/renenulschde/ha-mysmartbike-ble",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["bleak>=0.21.0", "bleak-retry-connector>=3.1.0"],
"version": "0.0.0",
"homeassistant": "2024.6.0",
"bluetooth": [
{
"local_name": "iWoc*"
}
]
}
@@ -0,0 +1,343 @@
"""Message parsers for MySmartBike BLE integration."""
import logging
from typing import Dict, Optional, Any
from .const import (
BATTERY_MESSAGE_LENGTH,
MOTOR_MESSAGE_LENGTH,
EBM_MESSAGE_LENGTH,
)
_LOGGER = logging.getLogger(__name__)
def read16(data: bytes, offset: int) -> int:
"""Read 16-bit value from data at offset (big-endian, as per Mahle protocol)."""
return ((data[offset] & 0xFF) << 8) | (data[offset + 1] & 0xFF)
def read24(data: bytes, offset: int) -> int:
"""Read 24-bit value from data at offset (big-endian, as per Mahle protocol)."""
return (
((data[offset] & 0xFF) << 16)
| ((data[offset + 1] & 0xFF) << 8)
| (data[offset + 2] & 0xFF)
)
def read32(data: bytes, offset: int) -> int:
"""Read 32-bit value from data at offset (big-endian, as per Mahle protocol)."""
return (
((data[offset] & 0xFF) << 24)
| ((data[offset + 1] & 0xFF) << 16)
| ((data[offset + 2] & 0xFF) << 8)
| (data[offset + 3] & 0xFF)
)
def read_unsigned_byte(byte_val: int) -> int:
"""Read unsigned byte value."""
return byte_val & 0xFF
class BikeDataParser:
"""Parser for bike BLE messages."""
def __init__(self):
"""Initialize parser."""
self.state: Dict[str, Optional[Dict[str, Any]]] = {
"battery_primary": None,
"battery_secondary": None,
"motor": None,
"assist": None,
"ebm": None,
}
self.battery_packet_counter = 0
self.vin: Optional[str] = None
self.protocol_version: Optional[str] = None
def parse_battery_message(self, message: bytes) -> Optional[Dict[str, Any]]:
"""Parse battery message and update state."""
if len(message) < BATTERY_MESSAGE_LENGTH:
return None
# Read values
voltage = read16(message, 5) / 10.0
soc = read_unsigned_byte(message[7])
temp_status = message[8]
current = read16(message, 9) / 10.0
nominal_capacity = read16(message, 11) / 10.0
remaining_wh = read16(message, 13) / 10.0
# Get battery number and cycles from combined field at offset 15
# Format: value = (battery_number * 10000) + cycles
# e.g., 10036 means battery 1, 36 cycles
combined_raw = read16(message, 15) if len(message) >= 19 else None
battery_number = (combined_raw // 10000) if combined_raw else 1
cycles = (combined_raw % 10000) if combined_raw else None
# Construct battery data dictionary
data = {
"voltage": voltage,
"soc": soc,
"temperature": temp_status,
"current": current,
"nominal_capacity": nominal_capacity,
"remaining_wh": remaining_wh,
"cycles": cycles,
}
# Handle secondary vs primary battery
if battery_number == 2:
# Secondary battery detected
self.battery_packet_counter = 0
self.state["battery_secondary"] = data
elif battery_number == 1:
# Primary battery
self.battery_packet_counter += 1
self.state["battery_primary"] = data
# After 4 consecutive primary battery packets, reset secondary battery
if self.battery_packet_counter >= 4:
self.state["battery_secondary"] = {
"voltage": 0.0,
"soc": 0.0,
"temperature": 0,
"current": 0.0,
"nominal_capacity": 0.0,
"remaining_wh": 0.0,
"cycles": None,
}
return data
def parse_motor_message(self, message: bytes) -> Optional[Dict[str, Any]]:
"""Parse motor message and update state."""
if len(message) < MOTOR_MESSAGE_LENGTH:
return None
# Extract values from message
assist_level = message[5]
temperature_celsius = message[6]
power_amp = float(read16(message, 7)) / 10.0
speed_kmh = float(read16(message, 9)) / 10.0
# Additional values
wheel_speed = read_unsigned_byte(message[11])
torque_pct = message[12]
power_max = float(read16(message, 13)) / 10.0
max_torque_pct = message[15]
# Update state with motor data
data = {
"assist_level": assist_level,
"temperature_celsius": temperature_celsius,
"power_amp": power_amp,
"speed_kmh": speed_kmh,
"wheel_speed_rpm": wheel_speed,
"torque_motor_pct": torque_pct,
"power_max_amp": power_max,
"max_torque_motor_pct": max_torque_pct,
}
self.state["motor"] = data
return data
def parse_assist_level_message(self, message: bytes) -> Optional[Dict[str, Any]]:
"""Parse assist level message and update state."""
if len(message) == 10:
data = {
"min": int(chr(message[5])),
"max": int(chr(message[6])),
"current": int(chr(message[7])),
}
self.state["assist"] = data
return data
elif len(message) == 9:
result = message.decode("utf-8", errors="ignore")[5:7]
data = {
"sync_result": result,
"success": result == "OK",
}
self.state["assist"] = data
return data
return None
def parse_vin_message(self, message: bytes) -> Optional[str]:
"""Parse VIN/serial number message.
Formats:
- $s$V#<serial>#@ - standard format with 17 char serial
- R0<serial>@ - alternative format (20 chars total)
"""
text = message.decode("utf-8", errors="ignore")
# Standard format: $s$V#<serial>#@
if text.startswith("$s$V#") and text.endswith("#@"):
vin = text[5:-2] # Extract between $s$V# and #@
if len(vin) == 17:
self.vin = vin
_LOGGER.info("Parsed VIN/serial number: %s", vin)
return vin
# Alternative format: R0<serial>@ (20 chars total)
if len(text) == 20 and text.endswith("@") and text.startswith("R0"):
vin = text[2:-1] # Extract between R0 and @
if len(vin) == 17:
self.vin = vin
_LOGGER.info("Parsed VIN/serial number (R0 format): %s", vin)
return vin
_LOGGER.debug("Could not parse VIN from message: %s", text)
return None
def parse_protocol_message(self, message: bytes) -> Optional[str]:
"""Parse protocol version message.
Format: $s$P#<version>#@ - e.g., $s$P#1.02#@
Error: $s$P#ER#@ indicates error
"""
text = message.decode("utf-8", errors="ignore")
# Standard format: $s$P#<version>#@
if text.startswith("$s$P#") and text.endswith("#@"):
version = text[5:-2] # Extract between $s$P# and #@
if version and version != "ER":
self.protocol_version = version
_LOGGER.info("Parsed protocol version: %s", version)
return version
elif version == "ER":
_LOGGER.warning("Protocol version request returned error")
return None
_LOGGER.debug("Could not parse protocol from message: %s", text)
return None
def parse_ebm_message(self, message: bytes) -> Optional[Dict[str, Any]]:
"""Parse EBM (E-Bike Management) message."""
if len(message) < EBM_MESSAGE_LENGTH:
return None
# EbmParserEbm format: 32-bit reads directly from message (big-endian)
# Raw values are in decimeters, divide by 10000 to get km
# (Mahle code divides by 10 to get meters, then displays as km by /1000)
if len(message) < 15:
return None
odometry_km = read32(message, 5) / 10000.0
autonomy_km = read32(message, 9) / 10000.0
is_light_on = message[13] == 1
status = read_unsigned_byte(message[14])
# EbmParserEbm only parses bytes 5-14, bytes 15-16 are suffix #@
data = {
"odometry": odometry_km,
"autonomy": autonomy_km,
"is_light_on": is_light_on,
"status": status,
}
self.state["ebm"] = data
return data
def recognize_message_type(self, message: bytes) -> str:
"""Recognize message type from message content."""
text = message.decode("ascii", errors="ignore")
# Handle standard format messages ($..#@)
if text.startswith("$") and text.endswith("#@"):
main_type = text[1]
sub_type = text[3] if len(text) > 3 else None
if main_type == "b":
return "battery"
elif main_type == "d":
if sub_type == "I":
return "diagnosis_init"
elif sub_type == "R":
return "diagnosis_read"
elif sub_type == "E":
return "diagnosis_end"
elif sub_type == "Z":
return "security_session"
elif sub_type == "C":
return "coding_device"
elif sub_type == "V":
return "write_vin"
elif sub_type == "T":
return "status"
elif main_type == "j" and sub_type == "Z":
return "ebm"
elif main_type == "m":
if sub_type == "A":
return "assist"
elif sub_type == "Z":
return "motor"
elif sub_type == "M":
return "engine_maps"
elif sub_type == "R":
return "reset_trip"
elif main_type == "s":
if sub_type == "V":
return "vin"
elif sub_type == "P":
return "protocol"
elif main_type == "M" and sub_type == "M":
return "engine_maps"
elif main_type == "i" and sub_type == "C":
return "calibrate"
# Handle special format messages (ending with @)
elif text.endswith("@"):
main_type = text[0]
if main_type == "T":
return "status"
elif main_type == "C":
return "coding_device"
elif main_type == "R":
return "vin"
elif main_type == "Z":
return "security_challenge"
return "unknown"
def handle_message(self, data: bytes) -> None:
"""Handle received message data and update state."""
msg_type = self.recognize_message_type(data)
# Handle different message types
if msg_type == "battery":
self.parse_battery_message(data)
elif msg_type == "motor":
self.parse_motor_message(data)
elif msg_type == "assist":
self.parse_assist_level_message(data)
elif msg_type == "ebm":
self.parse_ebm_message(data)
elif msg_type == "vin":
self.parse_vin_message(data)
elif msg_type == "protocol":
self.parse_protocol_message(data)
elif msg_type in [
"diagnosis_init",
"diagnosis_read",
"diagnosis_end",
"security_session",
"coding_device",
"write_vin",
"status",
"engine_maps",
"reset_trip",
"calibrate",
"security_challenge",
]:
_LOGGER.debug("Received message of type: %s", msg_type)
else:
# Enhanced logging for unknown messages
prefix = " ".join([f"{b:02x}" for b in data[:5]])
_LOGGER.debug(
"Unknown message: type=%s, prefix=[%s], length=%d",
msg_type,
prefix,
len(data),
)
+201
View File
@@ -0,0 +1,201 @@
"""Sensor platform for MySmartBike BLE integration."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
UnitOfEnergy,
UnitOfLength,
UnitOfSpeed,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER, MODEL, CONF_DEVICE_NAME
from .coordinator import MySmartBikeCoordinator
def safe_get(data: dict[str, Any] | None, *keys: str) -> Any:
"""Safely get nested dictionary values."""
if data is None:
return None
result = data
for key in keys:
if result is None or not isinstance(result, dict):
return None
result = result.get(key)
return result
@dataclass
class MySmartBikeSensorEntityDescription(SensorEntityDescription):
"""Describes MySmartBike sensor entity."""
value_fn: callable[[dict[str, Any]], Any] | None = None
SENSORS: tuple[MySmartBikeSensorEntityDescription, ...] = (
# Battery Primary Sensors
MySmartBikeSensorEntityDescription(
key="battery_primary_soc",
name="Battery SoC",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: safe_get(data, "battery_primary", "soc"),
),
MySmartBikeSensorEntityDescription(
key="battery_primary_temperature",
name="Battery Temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: safe_get(data, "battery_primary", "temperature"),
),
MySmartBikeSensorEntityDescription(
key="battery_primary_remaining_wh",
name="Battery Remaining Energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY_STORAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: safe_get(data, "battery_primary", "remaining_wh"),
),
# Motor Sensors
MySmartBikeSensorEntityDescription(
key="motor_assist_level",
name="Assist Level",
icon="mdi:speedometer",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: safe_get(data, "motor", "assist_level"),
),
MySmartBikeSensorEntityDescription(
key="motor_temperature",
name="Motor Temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: safe_get(data, "motor", "temperature_celsius"),
),
MySmartBikeSensorEntityDescription(
key="motor_speed",
name="Speed",
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
device_class=SensorDeviceClass.SPEED,
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:speedometer",
value_fn=lambda data: safe_get(data, "motor", "speed_kmh"),
),
# EBM Sensors
MySmartBikeSensorEntityDescription(
key="odometer",
name="Odometer",
native_unit_of_measurement=UnitOfLength.KILOMETERS,
device_class=SensorDeviceClass.DISTANCE,
state_class=SensorStateClass.TOTAL_INCREASING,
icon="mdi:counter",
value_fn=lambda data: safe_get(data, "ebm", "odometry"),
),
MySmartBikeSensorEntityDescription(
key="range",
name="Range",
native_unit_of_measurement=UnitOfLength.KILOMETERS,
device_class=SensorDeviceClass.DISTANCE,
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:map-marker-distance",
value_fn=lambda data: safe_get(data, "ebm", "autonomy"),
),
MySmartBikeSensorEntityDescription(
key="light",
name="Light",
icon="mdi:lightbulb",
value_fn=lambda data: "On" if safe_get(data, "ebm", "is_light_on") else ("Off" if safe_get(data, "ebm") else None),
),
MySmartBikeSensorEntityDescription(
key="ebm_status",
name="EBM Status",
icon="mdi:information",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: safe_get(data, "ebm", "status"),
),
# Connection Diagnostic Sensors
MySmartBikeSensorEntityDescription(
key="rssi",
name="Signal Strength",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
icon="mdi:wifi",
value_fn=lambda data: safe_get(data, "rssi"),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up MySmartBike BLE sensors."""
coordinator: MySmartBikeCoordinator = entry.runtime_data
async_add_entities(
MySmartBikeSensor(coordinator, entry, description)
for description in SENSORS
)
class MySmartBikeSensor(CoordinatorEntity[MySmartBikeCoordinator], SensorEntity):
"""Representation of a MySmartBike sensor."""
entity_description: MySmartBikeSensorEntityDescription
_attr_has_entity_name = True
def __init__(
self,
coordinator: MySmartBikeCoordinator,
entry: ConfigEntry,
description: MySmartBikeSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
device_name = entry.data[CONF_DEVICE_NAME]
self._attr_unique_id = f"{entry.entry_id}_{description.key}"
# Build device info with optional serial number (VIN)
self._attr_device_info = {
"identifiers": {(DOMAIN, entry.entry_id)},
"name": device_name,
"manufacturer": MANUFACTURER,
"model": MODEL,
}
# Add serial number if available
if coordinator.vin:
self._attr_device_info["serial_number"] = coordinator.vin
# Add protocol version as software version
if coordinator.protocol_version:
self._attr_device_info["sw_version"] = coordinator.protocol_version
@property
def native_value(self) -> Any:
"""Return the state of the sensor."""
if self.entity_description.value_fn:
return self.entity_description.value_fn(self.coordinator.data)
return None
+129
View File
@@ -0,0 +1,129 @@
"""Switch platform for MySmartBike BLE integration."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import MySmartBikeCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up MySmartBike BLE switch entities."""
_LOGGER.debug("Setting up switch platform for entry_id: %s", entry.entry_id)
coordinator: MySmartBikeCoordinator = entry.runtime_data
_LOGGER.debug("Adding connection switch entity (coordinator.is_connected: %s)", coordinator.is_connected)
async_add_entities([MySmartBikeConnectionSwitch(coordinator, entry)])
class MySmartBikeConnectionSwitch(CoordinatorEntity[MySmartBikeCoordinator], SwitchEntity):
"""Switch to control BLE connection to the bike."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: MySmartBikeCoordinator,
entry: ConfigEntry,
) -> None:
"""Initialize the switch."""
super().__init__(coordinator)
self._attr_unique_id = f"{entry.entry_id}_connection"
# Build device info with optional serial number (VIN)
self._attr_device_info = {
"identifiers": {(DOMAIN, entry.entry_id)},
}
if coordinator.vin:
self._attr_device_info["serial_number"] = coordinator.vin
if coordinator.protocol_version:
self._attr_device_info["sw_version"] = coordinator.protocol_version
self._attr_translation_key = "connection"
_LOGGER.debug(
"Switch initialized: unique_id=%s, coordinator.is_connected=%s",
self._attr_unique_id,
coordinator.is_connected,
)
@property
def is_on(self) -> bool:
"""Return True if connection is desired (not manually disconnected)."""
# Switch represents the desired state, not the actual connection status
# If manual_disconnect is False, user wants to be connected
state = not self.coordinator._manual_disconnect
_LOGGER.debug(
"Switch is_on property called: returning %s (manual_disconnect=%s, is_connected=%s)",
state,
self.coordinator._manual_disconnect,
self.coordinator.is_connected
)
return state
@property
def icon(self) -> str:
"""Return the icon."""
return "mdi:bluetooth-connect" if self.is_on else "mdi:bluetooth-off"
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the switch - request connection to the bike."""
_LOGGER.debug(
"Switch.async_turn_on called (current coordinator.is_connected: %s, manual_disconnect: %s)",
self.coordinator.is_connected,
self.coordinator._manual_disconnect,
)
# Update state immediately - switch is now ON (connection desired)
self.async_write_ha_state()
try:
await self.coordinator.async_reconnect()
_LOGGER.debug(
"Switch.async_turn_on: reconnect completed (coordinator.is_connected: %s)",
self.coordinator.is_connected,
)
except Exception as ex:
# Provide user-friendly error message
error_msg = str(ex)
if "not reachable" in error_msg.lower():
_LOGGER.warning(
"Switch.async_turn_on: Cannot connect now - bike is not reachable. "
"Will auto-connect when bike is powered on."
)
else:
_LOGGER.error("Switch.async_turn_on: Failed to connect to bike: %s", ex, exc_info=True)
# Switch stays ON - coordinator will auto-reconnect when bike becomes available
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the switch - disconnect from the bike.
WARNING: This will turn off the bike after ~5 minutes! It must be manually
turned on again or connected to power.
"""
_LOGGER.warning(
"Switch.async_turn_off called - Disconnecting from bike (current coordinator.is_connected: %s). "
"Bike will turn off after approximately 5 minutes and must be manually turned on again or connected to power",
self.coordinator.is_connected,
)
try:
await self.coordinator.async_disconnect()
_LOGGER.debug(
"Switch.async_turn_off: disconnect completed (coordinator.is_connected: %s, manual_disconnect: %s)",
self.coordinator.is_connected,
self.coordinator._manual_disconnect,
)
self.async_write_ha_state()
_LOGGER.debug("Switch.async_turn_off: state written to HA")
except Exception as ex:
_LOGGER.error("Switch.async_turn_off: Failed to disconnect from bike: %s", ex, exc_info=True)
@@ -0,0 +1,39 @@
{
"config": {
"step": {
"bluetooth_confirm": {
"description": "Möchten Sie das MySmartBike-Gerät {name} zu Home Assistant hinzufügen?"
},
"user": {
"data": {
"address": "Gerät"
},
"description": "Wählen Sie Ihr MySmartBike-Gerät aus"
}
},
"abort": {
"already_configured": "Gerät ist bereits konfiguriert",
"no_devices_found": "Keine MySmartBike-Geräte gefunden. Stellen Sie sicher, dass Ihr Gerät eingeschaltet und in Reichweite ist."
}
},
"options": {
"step": {
"init": {
"data": {
"log_ble_messages": "BLE-Nachrichten in Datei speichern"
},
"description": "Erweiterte Einstellungen für MySmartBike BLE"
}
}
},
"entity": {
"switch": {
"connection": {
"name": "Verbindung",
"state_attributes": {
"description": "Bluetooth-Verbindung zum E-Bike. WARNUNG: Beim Ausschalten schaltet sich das Rad nach ca. 5 Minuten ab und muss manuell wieder eingeschaltet oder an den Strom angeschlossen werden!"
}
}
}
}
}
@@ -0,0 +1,39 @@
{
"config": {
"step": {
"bluetooth_confirm": {
"description": "Do you want to add the MySmartBike device {name} to Home Assistant?"
},
"user": {
"data": {
"address": "Device"
},
"description": "Select your MySmartBike device"
}
},
"abort": {
"already_configured": "Device is already configured",
"no_devices_found": "No MySmartBike devices found. Make sure your device is turned on and in range."
}
},
"options": {
"step": {
"init": {
"data": {
"log_ble_messages": "Save BLE messages to file"
},
"description": "Advanced settings for MySmartBike BLE"
}
}
},
"entity": {
"switch": {
"connection": {
"name": "Connection",
"state_attributes": {
"description": "Bluetooth connection to the e-bike. WARNING: Turning off will shut down the bike after approximately 5 minutes and it must be manually turned on again or connected to power!"
}
}
}
}
}