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
+1
View File
@@ -0,0 +1 @@
"""Tests for MySmartBike BLE integration."""
+1
View File
@@ -0,0 +1 @@
"""Tests for components."""
@@ -0,0 +1 @@
"""Tests for MySmartBike BLE integration."""
@@ -0,0 +1,95 @@
"""Fixtures for MySmartBike BLE tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from homeassistant.const import CONF_ADDRESS
from custom_components.mysmartbike_ble.const import (
CONF_DEVICE_ADDRESS,
CONF_DEVICE_NAME,
DOMAIN,
)
from pytest_homeassistant_custom_component.common import MockConfigEntry
def _get_bluetooth_service_info() -> MagicMock:
"""Return a mock BluetoothServiceInfoBleak."""
service_info = MagicMock()
service_info.name = "iWoc1A36"
service_info.address = "AA:BB:CC:DD:EE:FF"
service_info.rssi = -60
service_info.manufacturer_data = {}
service_info.service_data = {}
service_info.service_uuids = []
service_info.source = "local"
return service_info
@pytest.fixture
def mock_bluetooth_service_info() -> MagicMock:
"""Return mock Bluetooth service info."""
return _get_bluetooth_service_info()
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return default mocked config entry."""
return MockConfigEntry(
domain=DOMAIN,
title="iWoc1A36",
data={
CONF_DEVICE_NAME: "iWoc1A36",
CONF_DEVICE_ADDRESS: "AA:BB:CC:DD:EE:FF",
},
unique_id="AA:BB:CC:DD:EE:FF",
)
@pytest.fixture
def mock_bleak_client() -> Generator[MagicMock]:
"""Return a mocked BleakClient."""
with patch(
"custom_components.mysmartbike_ble.coordinator.establish_connection",
autospec=True,
) as mock_client:
client = AsyncMock()
client.is_connected = True
client.write_gatt_char = AsyncMock()
client.start_notify = AsyncMock()
client.stop_notify = AsyncMock()
client.disconnect = AsyncMock()
mock_client.return_value = client
yield mock_client
@pytest.fixture
def mock_ble_device() -> Generator[MagicMock]:
"""Return a mocked BLE device."""
with patch(
"custom_components.mysmartbike_ble.config_flow.async_discovered_service_info",
autospec=True,
) as mock_devices:
mock_devices.return_value = [_get_bluetooth_service_info()]
yield mock_devices
@pytest.fixture
async def init_integration(
hass,
mock_config_entry: MockConfigEntry,
mock_bleak_client: MagicMock,
) -> MockConfigEntry:
"""Set up the MySmartBike BLE integration for testing."""
mock_config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.bluetooth.async_ble_device_from_address"
) as mock_ble_device_from_address:
mock_ble_device_from_address.return_value = _get_bluetooth_service_info()
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry
@@ -0,0 +1,141 @@
"""Test the MySmartBike BLE config flow."""
from unittest.mock import patch
import pytest
from homeassistant import config_entries
from homeassistant.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from custom_components.mysmartbike_ble.const import CONF_DEVICE_ADDRESS, CONF_DEVICE_NAME, DOMAIN
from .conftest import _get_bluetooth_service_info
async def test_bluetooth_discovery(hass: HomeAssistant) -> None:
"""Test discovery via Bluetooth."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=_get_bluetooth_service_info(),
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "bluetooth_confirm"
with patch(
"homeassistant.components.bluetooth.async_ble_device_from_address"
) as mock_ble_device:
mock_ble_device.return_value = _get_bluetooth_service_info()
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == "iWoc1A36"
assert result2["data"] == {
CONF_DEVICE_NAME: "iWoc1A36",
CONF_DEVICE_ADDRESS: "AA:BB:CC:DD:EE:FF",
}
async def test_bluetooth_discovery_already_configured(
hass: HomeAssistant, mock_config_entry
) -> None:
"""Test discovery aborts if already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=_get_bluetooth_service_info(),
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_user_flow_success(hass: HomeAssistant, mock_ble_device) -> None:
"""Test user flow - successful flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
with patch(
"homeassistant.components.bluetooth.async_ble_device_from_address"
) as mock_ble_device_from_address:
mock_ble_device_from_address.return_value = _get_bluetooth_service_info()
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"},
)
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == "iWoc1A36"
assert result2["data"] == {
CONF_DEVICE_NAME: "iWoc1A36",
CONF_DEVICE_ADDRESS: "AA:BB:CC:DD:EE:FF",
}
async def test_user_flow_no_devices_found(hass: HomeAssistant) -> None:
"""Test user flow - no devices found."""
with patch(
"custom_components.mysmartbike_ble.config_flow.async_discovered_service_info",
return_value=[],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
async def test_user_flow_already_configured(
hass: HomeAssistant, mock_config_entry, mock_ble_device
) -> None:
"""Test user flow - device already configured.
When the only discovered device is already configured,
it should abort immediately with no_devices_found.
"""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
async def test_user_flow_device_not_found_after_selection(
hass: HomeAssistant, mock_ble_device
) -> None:
"""Test user flow - device not found after selection."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
# Device disappears after selection (returns None)
with patch(
"homeassistant.components.bluetooth.async_ble_device_from_address",
return_value=None,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"},
)
# This should trigger ConfigEntryNotReady in the actual setup,
# but in config_flow it just proceeds to create the entry
assert result2["type"] == FlowResultType.CREATE_ENTRY
@@ -0,0 +1,316 @@
"""Test the MySmartBike BLE parsers with real message data."""
import pytest
from custom_components.mysmartbike_ble.parsers import (
BikeDataParser,
read16,
read24,
read32,
read_unsigned_byte,
)
class TestReadFunctions:
"""Test byte reading functions (big-endian)."""
def test_read16_big_endian(self):
"""Test 16-bit big-endian read."""
# Big-endian: first byte is most significant
data = bytes([0x12, 0x34])
assert read16(data, 0) == 0x1234
def test_read24_big_endian(self):
"""Test 24-bit big-endian read."""
data = bytes([0x12, 0x34, 0x56])
assert read24(data, 0) == 0x123456
def test_read32_big_endian(self):
"""Test 32-bit big-endian read."""
data = bytes([0x12, 0x34, 0x56, 0x78])
assert read32(data, 0) == 0x12345678
def test_read_unsigned_byte(self):
"""Test unsigned byte read."""
assert read_unsigned_byte(0xFF) == 255
assert read_unsigned_byte(0x00) == 0
assert read_unsigned_byte(0x7F) == 127
class TestEbmParser:
"""Test EBM message parsing with real data."""
# Real EBM message from log: 246a245a230056af68000bef6f00002340
# Expected: Odometer ~568.1 km, Range ~78.2 km
EBM_MESSAGE = bytes.fromhex("246a245a230056af68000bef6f00002340")
def test_ebm_message_recognition(self):
"""Test that EBM message type is recognized."""
parser = BikeDataParser()
msg_type = parser.recognize_message_type(self.EBM_MESSAGE)
assert msg_type == "ebm"
def test_ebm_odometry_parsing(self):
"""Test odometry value parsing from real EBM message."""
parser = BikeDataParser()
result = parser.parse_ebm_message(self.EBM_MESSAGE)
assert result is not None
# Odometry: 0x0056af68 = 5681000 / 10000 = 568.1 km
assert abs(result["odometry"] - 568.1) < 0.1
def test_ebm_autonomy_parsing(self):
"""Test autonomy/range value parsing from real EBM message."""
parser = BikeDataParser()
result = parser.parse_ebm_message(self.EBM_MESSAGE)
assert result is not None
# Autonomy: 0x000bef6f = 782191 / 10000 = 78.2 km
assert abs(result["autonomy"] - 78.2) < 0.1
def test_ebm_light_status(self):
"""Test light status parsing from real EBM message."""
parser = BikeDataParser()
result = parser.parse_ebm_message(self.EBM_MESSAGE)
assert result is not None
# Byte 13 = 0x00, so light is off
assert result["is_light_on"] is False
def test_ebm_state_update(self):
"""Test that parser state is updated after parsing."""
parser = BikeDataParser()
parser.parse_ebm_message(self.EBM_MESSAGE)
assert parser.state["ebm"] is not None
assert "odometry" in parser.state["ebm"]
assert "autonomy" in parser.state["ebm"]
class TestMotorParser:
"""Test motor message parsing with real data."""
# Real motor message from log: 246d245a230117000000000000004f642340
MOTOR_MESSAGE = bytes.fromhex("246d245a230117000000000000004f642340")
def test_motor_message_recognition(self):
"""Test that motor message type is recognized."""
parser = BikeDataParser()
msg_type = parser.recognize_message_type(self.MOTOR_MESSAGE)
assert msg_type == "motor"
def test_motor_parsing(self):
"""Test motor value parsing from real message."""
parser = BikeDataParser()
result = parser.parse_motor_message(self.MOTOR_MESSAGE)
assert result is not None
# Assist level at byte 5 = 0x01
assert result["assist_level"] == 1
# Temperature at byte 6 = 0x17 = 23°C
assert result["temperature_celsius"] == 23
class TestBatteryParser:
"""Test battery message parsing with real data."""
# Real battery message from log: 2462245a230193541700000877071a27342340
BATTERY_MESSAGE = bytes.fromhex("2462245a230193541700000877071a27342340")
def test_battery_message_recognition(self):
"""Test that battery message type is recognized."""
parser = BikeDataParser()
msg_type = parser.recognize_message_type(self.BATTERY_MESSAGE)
assert msg_type == "battery"
def test_battery_voltage(self):
"""Test battery voltage parsing."""
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
assert result is not None
# Voltage: 0x0193 = 403 / 10 = 40.3 V
assert abs(result["voltage"] - 40.3) < 0.1
def test_battery_soc(self):
"""Test battery state of charge parsing."""
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
assert result is not None
# SoC at byte 7 = 0x54 = 84%
assert result["soc"] == 84
def test_battery_temperature(self):
"""Test battery temperature parsing."""
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
assert result is not None
# Temperature at byte 8 = 0x17 = 23
assert result["temperature"] == 23
def test_battery_current(self):
"""Test battery current parsing."""
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
assert result is not None
# Current: 0x0000 = 0 / 10 = 0.0 A
assert result["current"] == 0.0
def test_battery_capacity(self):
"""Test battery nominal capacity parsing."""
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
assert result is not None
# Nominal capacity: 0x0877 = 2167 / 10 = 216.7 Wh
assert abs(result["nominal_capacity"] - 216.7) < 0.1
def test_battery_remaining(self):
"""Test battery remaining energy parsing."""
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
assert result is not None
# Remaining Wh: 0x071a = 1818 / 10 = 181.8 Wh
assert abs(result["remaining_wh"] - 181.8) < 0.1
def test_battery_cycles(self):
"""Test battery cycles parsing."""
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
assert result is not None
# Cycles: 0x2734 = 10036, cycles = 10036 % 10000 = 36
assert result["cycles"] == 36
def test_battery_number_detection(self):
"""Test primary/secondary battery detection."""
parser = BikeDataParser()
parser.parse_battery_message(self.BATTERY_MESSAGE)
# Battery number = 10036 / 10000 = 1 (primary)
assert parser.state["battery_primary"] is not None
assert parser.state["battery_primary"]["cycles"] == 36
class TestVinParser:
"""Test VIN/serial number message parsing."""
# Example VIN message: $s$V#SB000000002207203#@
# Hex: 24 73 24 56 23 + serial + 23 40
VIN_SERIAL = "SB000000002207203"
VIN_MESSAGE = f"$s$V#{VIN_SERIAL}#@".encode("utf-8")
def test_vin_message_recognition(self):
"""Test that VIN message type is recognized."""
parser = BikeDataParser()
msg_type = parser.recognize_message_type(self.VIN_MESSAGE)
assert msg_type == "vin"
def test_vin_parsing_standard_format(self):
"""Test VIN parsing from standard format $s$V#<serial>#@."""
parser = BikeDataParser()
result = parser.parse_vin_message(self.VIN_MESSAGE)
assert result == self.VIN_SERIAL
assert parser.vin == self.VIN_SERIAL
def test_vin_parsing_r0_format(self):
"""Test VIN parsing from R0 format (20 chars ending with @)."""
parser = BikeDataParser()
# R0 format: R0<17 char serial>@
serial = "AB123456789012345"
message = f"R0{serial}@".encode("utf-8")
result = parser.parse_vin_message(message)
assert result == serial
assert parser.vin == serial
def test_vin_handle_message_updates_state(self):
"""Test that handle_message updates VIN state."""
parser = BikeDataParser()
parser.handle_message(self.VIN_MESSAGE)
assert parser.vin == self.VIN_SERIAL
class TestAssistParser:
"""Test assist level message parsing with real data."""
# Real assist message from log: 246d2441233033312340
ASSIST_MESSAGE = bytes.fromhex("246d2441233033312340")
def test_assist_message_recognition(self):
"""Test that assist message type is recognized."""
parser = BikeDataParser()
msg_type = parser.recognize_message_type(self.ASSIST_MESSAGE)
assert msg_type == "assist"
def test_assist_parsing(self):
"""Test assist level parsing from real message."""
parser = BikeDataParser()
result = parser.parse_assist_level_message(self.ASSIST_MESSAGE)
assert result is not None
# Message is "031" which means min=0, max=3, current=1
assert result["min"] == 0
assert result["max"] == 3
assert result["current"] == 1
class TestProtocolParser:
"""Test protocol version message parsing."""
# Protocol message format: $s$P#<version>#@
PROTOCOL_MESSAGE_V102 = b"$s$P#1.02#@"
PROTOCOL_MESSAGE_V100 = b"$s$P#1.00#@"
PROTOCOL_MESSAGE_V300 = b"$s$P#3.00#@"
PROTOCOL_MESSAGE_ERROR = b"$s$P#ER#@"
def test_protocol_message_recognition(self):
"""Test that protocol message type is recognized."""
parser = BikeDataParser()
msg_type = parser.recognize_message_type(self.PROTOCOL_MESSAGE_V102)
assert msg_type == "protocol"
def test_protocol_parsing_v102(self):
"""Test protocol version 1.02 parsing."""
parser = BikeDataParser()
result = parser.parse_protocol_message(self.PROTOCOL_MESSAGE_V102)
assert result == "1.02"
assert parser.protocol_version == "1.02"
def test_protocol_parsing_v100(self):
"""Test protocol version 1.00 parsing."""
parser = BikeDataParser()
result = parser.parse_protocol_message(self.PROTOCOL_MESSAGE_V100)
assert result == "1.00"
assert parser.protocol_version == "1.00"
def test_protocol_parsing_v300(self):
"""Test protocol version 3.00 parsing."""
parser = BikeDataParser()
result = parser.parse_protocol_message(self.PROTOCOL_MESSAGE_V300)
assert result == "3.00"
assert parser.protocol_version == "3.00"
def test_protocol_parsing_error(self):
"""Test protocol error response handling."""
parser = BikeDataParser()
result = parser.parse_protocol_message(self.PROTOCOL_MESSAGE_ERROR)
assert result is None
assert parser.protocol_version is None
def test_protocol_handle_message_updates_state(self):
"""Test that handle_message updates protocol version."""
parser = BikeDataParser()
parser.handle_message(self.PROTOCOL_MESSAGE_V102)
assert parser.protocol_version == "1.02"
@@ -0,0 +1,162 @@
"""Test the MySmartBike BLE switch."""
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
def get_connection_switch_id(hass: HomeAssistant) -> str:
"""Get the connection switch entity ID."""
entity_registry = er.async_get(hass)
for entity in entity_registry.entities.values():
if entity.unique_id.endswith("_connection"):
return entity.entity_id
raise ValueError("Connection switch not found")
async def test_switch_setup(hass: HomeAssistant, init_integration) -> None:
"""Test switch setup."""
entity_id = get_connection_switch_id(hass)
entity_registry = er.async_get(hass)
# Check if the connection switch entity exists
entry = entity_registry.async_get(entity_id)
assert entry
assert entry.unique_id.endswith("_connection")
async def test_switch_initial_state(hass: HomeAssistant, init_integration) -> None:
"""Test switch initial state is on (connected)."""
entity_id = get_connection_switch_id(hass)
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_ON
async def test_switch_turn_off(hass: HomeAssistant, init_integration) -> None:
"""Test turning off the switch disconnects from bike."""
entity_id = get_connection_switch_id(hass)
coordinator = init_integration.runtime_data
# Mock the disconnect method
with patch.object(coordinator, "async_disconnect", new_callable=AsyncMock) as mock_disconnect:
# Turn off the switch
await hass.services.async_call(
SWITCH_DOMAIN,
"turn_off",
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
await hass.async_block_till_done()
# Verify disconnect was called
mock_disconnect.assert_called_once()
async def test_switch_turn_on(hass: HomeAssistant, init_integration) -> None:
"""Test turning on the switch reconnects to bike."""
entity_id = get_connection_switch_id(hass)
coordinator = init_integration.runtime_data
# Mock the reconnect method
with patch.object(coordinator, "async_reconnect", new_callable=AsyncMock) as mock_reconnect:
# Turn on the switch
await hass.services.async_call(
SWITCH_DOMAIN,
"turn_on",
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
await hass.async_block_till_done()
# Verify reconnect was called
mock_reconnect.assert_called_once()
async def test_switch_icon(hass: HomeAssistant, init_integration) -> None:
"""Test switch icon is correct for connected state."""
entity_id = get_connection_switch_id(hass)
state = hass.states.get(entity_id)
assert state is not None
assert state.attributes.get("icon") == "mdi:bluetooth-connect"
async def test_switch_turn_off_error_handling(
hass: HomeAssistant, init_integration
) -> None:
"""Test error handling when turning off switch fails."""
entity_id = get_connection_switch_id(hass)
coordinator = init_integration.runtime_data
# Mock disconnect to raise an exception
with patch.object(
coordinator, "async_disconnect", side_effect=Exception("Disconnect failed")
):
# Turn off should not raise, but log error
await hass.services.async_call(
SWITCH_DOMAIN,
"turn_off",
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
# Entity should still exist
state = hass.states.get(entity_id)
assert state is not None
async def test_switch_turn_on_error_handling(
hass: HomeAssistant, init_integration
) -> None:
"""Test error handling when turning on switch fails."""
entity_id = get_connection_switch_id(hass)
coordinator = init_integration.runtime_data
# Mock reconnect to raise an exception
with patch.object(
coordinator, "async_reconnect", side_effect=Exception("Reconnect failed")
):
# Turn on should not raise, but log error
await hass.services.async_call(
SWITCH_DOMAIN,
"turn_on",
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
# Entity should still exist
state = hass.states.get(entity_id)
assert state is not None
async def test_no_auto_reconnect_when_manually_disconnected(
hass: HomeAssistant, init_integration
) -> None:
"""Test that coordinator doesn't auto-reconnect after manual disconnect."""
entity_id = get_connection_switch_id(hass)
coordinator = init_integration.runtime_data
# Turn off the switch (manual disconnect)
with patch.object(coordinator, "async_disconnect", wraps=coordinator.async_disconnect) as mock_disconnect:
await hass.services.async_call(
SWITCH_DOMAIN,
"turn_off",
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
await hass.async_block_till_done()
mock_disconnect.assert_called_once()
# Verify manual disconnect flag is set
assert coordinator._manual_disconnect is True
# Trigger an update - should NOT attempt to reconnect
with patch.object(coordinator, "_connect", new_callable=AsyncMock) as mock_connect:
await coordinator.async_refresh()
await hass.async_block_till_done()
# _connect should NOT have been called
mock_connect.assert_not_called()
+10
View File
@@ -0,0 +1,10 @@
"""Global fixtures for MySmartBike BLE integration tests."""
import pytest
pytest_plugins = "pytest_homeassistant_custom_component"
@pytest.fixture(autouse=True)
def auto_enable_custom_integrations(enable_custom_integrations):
"""Enable custom integrations for all tests."""
yield