Skip to content

Commit c163231

Browse files
authored
Merge pull request #82 from CoMPaTech/morebikes
Attempt at multi-bike support
2 parents 23a8425 + b7c6244 commit c163231

File tree

15 files changed

+171
-90
lines changed

15 files changed

+171
-90
lines changed

CHANGELOG.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,20 @@
22

33
## Changelog
44

5-
### Ongoing
5+
### AUG 2024 [0.4.0]
66

7-
- Add multibike support (see 0.4 beta progress)
7+
- Add multibike support
8+
- Single and Multi-bike (proper) selection using config_flow setup
9+
- Improve support for migration from <= v0.3.x (single bike in multi-bike)
10+
- Remove stale VIA_DEVICE (i.e. the 'Connected to' when viewing the device page)
11+
- Below-the-surface each bike is now created with a unique id `stromerbike-xxxxx`
12+
- Improve timeout and aiohttp connection handling [tnx @dbrgn]
13+
- Timestamp/TZ improvements [tnx @dbrgn]
814

915
### JUL 2024 [0.3.3]
1016

11-
- Added Portuguese Language
17+
- Added Portuguese Language [tnx @ViPeR5000]
18+
- Other smaller fixes and contributes [tnx @L2v@p]
1219

1320
### JUN 2024 [0.3.2]
1421

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,14 @@ Additional the setup flow will ask you for your username (i.e. e-mail address) a
3535

3636
In the current state it retrieves `bike`, `status` and `position` from the API every 10 minutes.
3737

38-
**BETA** There is an early implementation on toggling data on your bike, `light` and `lock` can be adjusted.
38+
There is an early implementation on toggling data on your bike, `light` and `lock` can be adjusted.
3939
Do note that the switches do not immediately reflect the status (i.e. they will when you toggle them, but switch back quickly).
4040
After your 'switch' command we do request an API update to check on the status, pending that update the switch might toggle back-n-forth some time.
4141
The light-switch is called 'Light mode' as depending on your bike type it will switch on/off or between 'dim and bright'.
4242

43-
**BETA** As with the `switch` implementation a `button` is added to reset your trip_data.
43+
As with the `switch` implementation a `button` is added to reset your trip_data.
44+
45+
Multi-bike support (see #81 / #82 for details and progress). The config-flow will now detect if you have one or multiple bikes. If you have one, you can only select it (obviously). When multiple bikes are in the same account, repeat the 'add integration' for each bike, selecting the other bike(s) on each iteration.
4446

4547
## If you want more frequent updates
4648

@@ -132,7 +134,7 @@ icon: mdi:bike
132134
show_state: false
133135
```
134136

135-
## State: ALPHA
137+
## State: BETA
136138

137139
Even though available does not mean it's stable yet, the HA part is solid but the class used to interact with the API is in need of improvement (e.g. better overall handling). This might also warrant having the class available as a module from pypi.
138140

custom_components/stromer/__init__.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,25 +26,42 @@
2626
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
2727
"""Set up Stromer from a config entry."""
2828
hass.data.setdefault(DOMAIN, {})
29+
LOGGER.debug(f"Stromer entry: {entry}")
2930

3031
# Fetch configuration data from config_flow
3132
username = entry.data[CONF_USERNAME]
3233
password = entry.data[CONF_PASSWORD]
3334
client_id = entry.data[CONF_CLIENT_ID]
3435
client_secret = entry.data.get(CONF_CLIENT_SECRET, None)
3536

36-
# Initialize connection to stromer
37+
# Initialize module
3738
stromer = Stromer(username, password, client_id, client_secret)
39+
40+
# Setup connection to stromer
3841
try:
3942
await stromer.stromer_connect()
4043
except ApiError as ex:
4144
raise ConfigEntryNotReady("Error while communicating to Stromer API") from ex
4245

43-
LOGGER.debug(f"Stromer entry: {entry}")
46+
# Ensure migration from v3 single bike
47+
if "bike_id" not in entry.data:
48+
bikedata = await stromer.stromer_detect()
49+
new_data = {
50+
**entry.data,
51+
"bike_id": bikedata[0]["bikeid"],
52+
"nickname": bikedata[0]["nickname"],
53+
"model": bikedata[0]["biketype"]
54+
}
55+
hass.config_entries.async_update_entry(entry, data=new_data)
56+
57+
# Set specific bike (instead of all bikes) introduced with morebikes PR
58+
stromer.bike_id = entry.data["bike_id"]
59+
stromer.bike_name = entry.data["nickname"]
60+
stromer.bike_model = entry.data["model"]
4461

4562
# Use Bike ID as unique id
46-
if entry.unique_id is None:
47-
hass.config_entries.async_update_entry(entry, unique_id=stromer.bike_id)
63+
if entry.unique_id is None or entry.unique_id == "stromerbike":
64+
hass.config_entries.async_update_entry(entry, unique_id=f"stromerbike-{stromer.bike_id}")
4865

4966
# Set up coordinator for fetching data
5067
coordinator = StromerDataUpdateCoordinator(hass, stromer, SCAN_INTERVAL) # type: ignore[arg-type]
@@ -55,12 +72,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
5572

5673
# Add bike to the HA device registry
5774
device_registry = dr.async_get(hass)
58-
device_registry.async_get_or_create(
75+
device = device_registry.async_get_or_create(
5976
config_entry_id=entry.entry_id,
6077
identifiers={(DOMAIN, str(stromer.bike_id))},
6178
manufacturer="Stromer",
62-
name=f"{stromer.bike_name}",
63-
model=f"{stromer.bike_model}",
79+
name=stromer.bike_name,
80+
model=stromer.bike_model,
81+
)
82+
83+
# Remove non-existing via device
84+
device_registry.async_update_device(
85+
device.id,
86+
name=stromer.bike_name,
87+
model=stromer.bike_model,
88+
via_device_id=None,
6489
)
6590

6691
# Set up platforms (i.e. sensors, binary_sensors)

custom_components/stromer/config_flow.py

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from homeassistant.data_entry_flow import FlowResult
1212
from homeassistant.exceptions import HomeAssistantError
1313

14-
from .const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, DOMAIN, LOGGER
14+
from .const import BIKE_DETAILS, CONF_CLIENT_ID, CONF_CLIENT_SECRET, DOMAIN, LOGGER
1515
from .stromer import Stromer
1616

1717
STEP_USER_DATA_SCHEMA = vol.Schema(
@@ -24,27 +24,59 @@
2424
)
2525

2626

27-
async def validate_input(_: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
28-
"""Validate the user input allows us to connect."""
27+
async def validate_input(_: HomeAssistant, data: dict[str, Any]) -> dict:
28+
"""Validate the user input allows us to connect by returning a dictionary with all bikes under the account."""
2929
username = data[CONF_USERNAME]
3030
password = data[CONF_PASSWORD]
3131
client_id = data[CONF_CLIENT_ID]
3232
client_secret = data.get(CONF_CLIENT_SECRET, None)
3333

34-
# Initialize connection to stromer
34+
# Initialize connection to stromer to validate credentials
3535
stromer = Stromer(username, password, client_id, client_secret)
3636
if not await stromer.stromer_connect():
3737
raise InvalidAuth
38+
LOGGER.debug("Credentials validated successfully")
39+
await stromer.stromer_disconnect()
3840

39-
# Return info that you want to store in the config entry.
40-
return {"title": stromer.bike_name}
41+
# All bikes information available
42+
all_bikes = await stromer.stromer_detect()
43+
44+
return all_bikes
4145

4246

4347
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # type: ignore[call-arg, misc]
4448
"""Handle a config flow for Stromer."""
4549

4650
VERSION = 1
4751

52+
async def async_step_bike(
53+
self, user_input: dict[str, Any] | None = None,
54+
) -> FlowResult:
55+
"""Handle selecting bike step."""
56+
if user_input is None:
57+
STEP_BIKE_DATA_SCHEMA = vol.Schema(
58+
{ vol.Required(BIKE_DETAILS): vol.In(list(self.friendly_names)), }
59+
)
60+
return self.async_show_form(
61+
step_id="bike", data_schema=STEP_BIKE_DATA_SCHEMA
62+
)
63+
64+
# Rework user friendly name to actual bike id and details
65+
selected_bike = user_input[BIKE_DETAILS]
66+
bike_id = self.friendly_names[selected_bike]
67+
nickname = self.all_bikes[bike_id]["nickname"]
68+
self.user_input_data["bike_id"] = bike_id
69+
self.user_input_data["nickname"] = nickname
70+
self.user_input_data["model"] = self.all_bikes[bike_id]["biketype"]
71+
72+
LOGGER.info(f"Using {selected_bike} (i.e. bike ID {bike_id}) to talk to the Stromer API")
73+
74+
await self.async_set_unique_id(f"stromerbike-{bike_id}")
75+
self._abort_if_unique_id_configured()
76+
77+
LOGGER.info(f"Creating entry using {nickname} as bike device name")
78+
return self.async_create_entry(title=nickname, data=self.user_input_data)
79+
4880
async def async_step_user(
4981
self, user_input: dict[str, Any] | None = None
5082
) -> FlowResult:
@@ -56,20 +88,37 @@ async def async_step_user(
5688

5789
errors = {}
5890

59-
await self.async_set_unique_id("stromerbike")
60-
self._abort_if_unique_id_configured()
61-
6291
try:
63-
info = await validate_input(self.hass, user_input)
92+
bikes_data = await validate_input(self.hass, user_input)
93+
LOGGER.debug(f"bikes_data contains {bikes_data}")
94+
95+
# Retrieve any bikes available within account
96+
# Modify output for better display of selection
97+
self.friendly_names = {}
98+
self.all_bikes = {}
99+
LOGGER.debug("Checking available bikes:")
100+
for bike in bikes_data:
101+
LOGGER.debug(f"* this bike contains {bike}")
102+
bike_id = bike["bikeid"]
103+
nickname = bike["nickname"]
104+
biketype = bike["biketype"]
105+
106+
friendly_name = f"{nickname} ({biketype}) #{bike_id}"
107+
108+
self.friendly_names[friendly_name] = bike_id
109+
self.all_bikes[bike_id]= { "nickname": nickname, "biketype": biketype}
110+
111+
# Save account info
112+
self.user_input_data = user_input
113+
return await self.async_step_bike()
114+
64115
except CannotConnect:
65116
errors["base"] = "cannot_connect"
66117
except InvalidAuth:
67118
errors["base"] = "invalid_auth"
68119
except Exception: # pylint: disable=broad-except
69120
LOGGER.exception("Unexpected exception")
70121
errors["base"] = "unknown"
71-
else:
72-
return self.async_create_entry(title=info["title"], data=user_input)
73122

74123
return self.async_show_form(
75124
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors

custom_components/stromer/const.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,6 @@
99

1010
CONF_CLIENT_ID = "client_id"
1111
CONF_CLIENT_SECRET = "client_secret"
12+
13+
BIKE_DETAILS = "bike_details"
14+

custom_components/stromer/coordinator.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ async def _async_update_data(self) -> StromerData:
3535
self.stromer.position["rcvts_pos"] = self.stromer.position.pop("rcvts")
3636

3737
bike_data = self.stromer.bike
38+
bike_data.update({"bike_model": self.stromer.bike_model, "bike_name": self.stromer.bike_name})
3839
bike_data.update(self.stromer.status)
3940
bike_data.update(self.stromer.position)
4041

custom_components/stromer/entity.py

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,24 +31,16 @@ def __init__(
3131
configuration_url=configuration_url,
3232
identifiers={(DOMAIN, str(coordinator.data.bike_id))},
3333
manufacturer="Stromer",
34-
model=data.get("bikemodel"),
35-
name=data.get("nickname"),
34+
model=data.get("bike_model"),
35+
name=data.get("bike_name"),
3636
sw_version=data.get("suiversion"),
3737
hw_version=data.get("tntversion"),
38-
# stromer_id=data.get("bike_id"),
39-
# type=data.get("bikemodel"),
40-
# frame_color=data.get("color"),
41-
# frame_size=data.get("size"),
42-
# hardware=data.get("hardware"),
4338
)
4439

4540
self._attr_device_info.update(
4641
{
47-
ATTR_NAME: data.get("nickname"),
48-
ATTR_VIA_DEVICE: (
49-
DOMAIN,
50-
str(self.coordinator.data.bike_id),
51-
),
42+
ATTR_NAME: data.get("bike_name"),
43+
ATTR_VIA_DEVICE: None,
5244
}
5345
)
5446

custom_components/stromer/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,5 @@
88
"iot_class": "cloud_polling",
99
"issue_tracker": "https://github.com/CoMPaTech/stromer/issues",
1010
"requirements": [],
11-
"version": "0.3.3"
11+
"version": "0.4.0"
1212
}

custom_components/stromer/sensor.py

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -210,26 +210,16 @@ def __init__(
210210
self.entity_description = description
211211
self._attr_unique_id = f"{device_id}-{description.key}"
212212

213-
@staticmethod
214-
def _ensure_timezone(timestamp: datetime | None) -> datetime | None:
215-
"""Calculate days left until domain expires."""
216-
if timestamp is None:
217-
return None
218-
219-
# If timezone info isn't provided by the Whois, assume UTC.
220-
if timestamp.tzinfo is None:
221-
return timestamp.replace(tzinfo=UTC)
222-
223-
return timestamp
224-
225213
@property
226214
def native_value(self) -> Any:
227215
"""Return the state of the sensor."""
228216
if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP:
229-
return self._ensure_timezone(
230-
datetime.fromtimestamp(
231-
int(self._coordinator.data.bikedata.get(self._ent))
232-
)
217+
timestamp = self._coordinator.data.bikedata.get(self._ent)
218+
if timestamp is None:
219+
return None
220+
return datetime.fromtimestamp(
221+
int(timestamp),
222+
tz=UTC,
233223
)
234224

235225
return self._coordinator.data.bikedata.get(self._ent)

0 commit comments

Comments
 (0)