Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multi-bike support and small improvements #82

Merged
merged 28 commits into from
Aug 25, 2024
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
3b9679d
Redo approach
CoMPaTech Feb 4, 2024
e92c2d0
Code spaces
CoMPaTech Feb 4, 2024
491861e
Version bump
CoMPaTech Jun 3, 2024
f82146c
Cleanup after rebase
CoMPaTech Jul 24, 2024
5c3374a
Missing brackets
CoMPaTech Aug 8, 2024
6350ff8
Remove via_device to clean up view
CoMPaTech Aug 8, 2024
cd76a65
Rework multibike
CoMPaTech Aug 8, 2024
773b960
Follow up TODO
CoMPaTech Aug 8, 2024
2caa2e1
Handle migration
CoMPaTech Aug 8, 2024
e7e6d16
Add reload entry
CoMPaTech Aug 8, 2024
126bc39
Version bump
CoMPaTech Aug 8, 2024
59b8876
Cleanup via_device
CoMPaTech Aug 8, 2024
1306845
Bump version and update documentation
CoMPaTech Aug 8, 2024
3f5c47e
Add missing async await
CoMPaTech Aug 8, 2024
dacb5e2
First bike
CoMPaTech Aug 8, 2024
46c2cd6
Less is more?
CoMPaTech Aug 9, 2024
2991ce8
Correct naming and injection
CoMPaTech Aug 9, 2024
9818651
Fix log message
dbrgn Aug 18, 2024
a1a9e0c
Close aiohttp connection when probing API
dbrgn Aug 18, 2024
18b1198
Merge pull request #134 from dbrgn/morebikes-improvements
CoMPaTech Aug 21, 2024
349d934
Follow @coderabbitai suggestion without tripping G004
CoMPaTech Aug 21, 2024
cbba021
Fix timestamp timezone
dbrgn Aug 21, 2024
720ea96
Merge pull request #136 from dbrgn/fix-timezone
CoMPaTech Aug 21, 2024
93473f6
Commit coderabbitai suggestion
CoMPaTech Aug 25, 2024
a9bf91a
Drop py311
CoMPaTech Aug 25, 2024
2b12dbf
Bump versions and info for release
CoMPaTech Aug 25, 2024
d2192bc
Update return type
CoMPaTech Aug 25, 2024
b7c6244
Add COMMA as suggested by coderabbitai
CoMPaTech Aug 25, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@

## Changelog

### Ongoing
### AUG 2024 [0.4.0]

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

### JUL 2024 [0.3.3]

- Added Portuguese Language
- Added Portuguese Language [tnx @ViPeR5000]
- Other smaller fixes and contributes [tnx @L2v@p]

### JUN 2024 [0.3.2]

Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,14 @@ Additional the setup flow will ask you for your username (i.e. e-mail address) a

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

**BETA** There is an early implementation on toggling data on your bike, `light` and `lock` can be adjusted.
There is an early implementation on toggling data on your bike, `light` and `lock` can be adjusted.
Do note that the switches do not immediately reflect the status (i.e. they will when you toggle them, but switch back quickly).
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.
The light-switch is called 'Light mode' as depending on your bike type it will switch on/off or between 'dim and bright'.

**BETA** As with the `switch` implementation a `button` is added to reset your trip_data.
As with the `switch` implementation a `button` is added to reset your trip_data.

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.
CoMPaTech marked this conversation as resolved.
Show resolved Hide resolved

## If you want more frequent updates

Expand Down Expand Up @@ -132,7 +134,7 @@ icon: mdi:bike
show_state: false
```

## State: ALPHA
## State: BETA

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.

Expand Down
39 changes: 32 additions & 7 deletions custom_components/stromer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,25 +26,42 @@
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Stromer from a config entry."""
hass.data.setdefault(DOMAIN, {})
LOGGER.debug(f"Stromer entry: {entry}")

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

# Initialize connection to stromer
# Initialize module
stromer = Stromer(username, password, client_id, client_secret)

# Setup connection to stromer
try:
await stromer.stromer_connect()
except ApiError as ex:
raise ConfigEntryNotReady("Error while communicating to Stromer API") from ex

LOGGER.debug(f"Stromer entry: {entry}")
# Ensure migration from v3 single bike
if "bike_id" not in entry.data:
bikedata = await stromer.stromer_detect()
new_data = {
**entry.data,
"bike_id": bikedata[0]["bikeid"],
"nickname": bikedata[0]["nickname"],
"model": bikedata[0]["biketype"]
}
hass.config_entries.async_update_entry(entry, data=new_data)

# Set specific bike (instead of all bikes) introduced with morebikes PR
stromer.bike_id = entry.data["bike_id"]
stromer.bike_name = entry.data["nickname"]
stromer.bike_model = entry.data["model"]

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

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

# Add bike to the HA device registry
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, str(stromer.bike_id))},
manufacturer="Stromer",
name=f"{stromer.bike_name}",
model=f"{stromer.bike_model}",
name=stromer.bike_name,
model=stromer.bike_model,
)

# Remove non-existing via device
device_registry.async_update_device(
device.id,
name=stromer.bike_name,
model=stromer.bike_model,
via_device_id=None,
)

# Set up platforms (i.e. sensors, binary_sensors)
Expand Down
73 changes: 61 additions & 12 deletions custom_components/stromer/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from homeassistant.data_entry_flow import FlowResult
from homeassistant.exceptions import HomeAssistantError

from .const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, DOMAIN, LOGGER
from .const import BIKE_DETAILS, CONF_CLIENT_ID, CONF_CLIENT_SECRET, DOMAIN, LOGGER
from .stromer import Stromer

STEP_USER_DATA_SCHEMA = vol.Schema(
Expand All @@ -24,27 +24,59 @@
)


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

# Initialize connection to stromer
# Initialize connection to stromer to validate credentials
stromer = Stromer(username, password, client_id, client_secret)
if not await stromer.stromer_connect():
raise InvalidAuth
LOGGER.debug("Credentials validated successfully")
await stromer.stromer_disconnect()

# Return info that you want to store in the config entry.
return {"title": stromer.bike_name}
# All bikes information available
all_bikes = await stromer.stromer_detect()

return all_bikes


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

VERSION = 1

async def async_step_bike(
self, user_input: dict[str, Any] | None = None,
) -> FlowResult:
"""Handle selecting bike step."""
if user_input is None:
STEP_BIKE_DATA_SCHEMA = vol.Schema(
{ vol.Required(BIKE_DETAILS): vol.In(list(self.friendly_names)), }
)
return self.async_show_form(
step_id="bike", data_schema=STEP_BIKE_DATA_SCHEMA
)

# Rework user friendly name to actual bike id and details
selected_bike = user_input[BIKE_DETAILS]
bike_id = self.friendly_names[selected_bike]
nickname = self.all_bikes[bike_id]["nickname"]
self.user_input_data["bike_id"] = bike_id
self.user_input_data["nickname"] = nickname
self.user_input_data["model"] = self.all_bikes[bike_id]["biketype"]

LOGGER.info(f"Using {selected_bike} (i.e. bike ID {bike_id}) to talk to the Stromer API")

await self.async_set_unique_id(f"stromerbike-{bike_id}")
self._abort_if_unique_id_configured()

LOGGER.info(f"Creating entry using {nickname} as bike device name")
return self.async_create_entry(title=nickname, data=self.user_input_data)

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
Expand All @@ -56,20 +88,37 @@ async def async_step_user(

errors = {}

await self.async_set_unique_id("stromerbike")
self._abort_if_unique_id_configured()

try:
info = await validate_input(self.hass, user_input)
bikes_data = await validate_input(self.hass, user_input)
LOGGER.debug(f"bikes_data contains {bikes_data}")

# Retrieve any bikes available within account
# Modify output for better display of selection
self.friendly_names = {}
self.all_bikes = {}
LOGGER.debug("Checking available bikes:")
for bike in bikes_data:
LOGGER.debug(f"* this bike contains {bike}")
bike_id = bike["bikeid"]
nickname = bike["nickname"]
biketype = bike["biketype"]

friendly_name = f"{nickname} ({biketype}) #{bike_id}"

self.friendly_names[friendly_name] = bike_id
self.all_bikes[bike_id]= { "nickname": nickname, "biketype": biketype}

# Save account info
self.user_input_data = user_input
return await self.async_step_bike()

except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(title=info["title"], data=user_input)

return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
Expand Down
3 changes: 3 additions & 0 deletions custom_components/stromer/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@

CONF_CLIENT_ID = "client_id"
CONF_CLIENT_SECRET = "client_secret"

BIKE_DETAILS = "bike_details"

1 change: 1 addition & 0 deletions custom_components/stromer/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ async def _async_update_data(self) -> StromerData:
self.stromer.position["rcvts_pos"] = self.stromer.position.pop("rcvts")

bike_data = self.stromer.bike
bike_data.update({"bike_model": self.stromer.bike_model, "bike_name": self.stromer.bike_name})
bike_data.update(self.stromer.status)
bike_data.update(self.stromer.position)

Expand Down
16 changes: 4 additions & 12 deletions custom_components/stromer/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,24 +31,16 @@ def __init__(
configuration_url=configuration_url,
identifiers={(DOMAIN, str(coordinator.data.bike_id))},
manufacturer="Stromer",
model=data.get("bikemodel"),
name=data.get("nickname"),
model=data.get("bike_model"),
name=data.get("bike_name"),
sw_version=data.get("suiversion"),
hw_version=data.get("tntversion"),
# stromer_id=data.get("bike_id"),
# type=data.get("bikemodel"),
# frame_color=data.get("color"),
# frame_size=data.get("size"),
# hardware=data.get("hardware"),
)

self._attr_device_info.update(
{
ATTR_NAME: data.get("nickname"),
ATTR_VIA_DEVICE: (
DOMAIN,
str(self.coordinator.data.bike_id),
),
ATTR_NAME: data.get("bike_name"),
ATTR_VIA_DEVICE: None,
}
)

Expand Down
2 changes: 1 addition & 1 deletion custom_components/stromer/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/CoMPaTech/stromer/issues",
"requirements": [],
"version": "0.3.3"
"version": "0.4.0"
}
22 changes: 6 additions & 16 deletions custom_components/stromer/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,26 +210,16 @@ def __init__(
self.entity_description = description
self._attr_unique_id = f"{device_id}-{description.key}"

@staticmethod
def _ensure_timezone(timestamp: datetime | None) -> datetime | None:
"""Calculate days left until domain expires."""
if timestamp is None:
return None

# If timezone info isn't provided by the Whois, assume UTC.
if timestamp.tzinfo is None:
return timestamp.replace(tzinfo=UTC)

return timestamp

@property
def native_value(self) -> Any:
"""Return the state of the sensor."""
if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP:
return self._ensure_timezone(
datetime.fromtimestamp(
int(self._coordinator.data.bikedata.get(self._ent))
)
timestamp = self._coordinator.data.bikedata.get(self._ent)
if timestamp is None:
return None
return datetime.fromtimestamp(
int(timestamp),
tz=UTC,
)

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