Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
34 changes: 34 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Release

on:
release:
types: [published]

permissions:
contents: write

jobs:
release:
name: Attach pid_departures.zip
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Check that the tag matches the manifest version
run: |
version=$(jq -r .version custom_components/pid_departures/manifest.json)
if [ "$GITHUB_REF_NAME" != "v$version" ]; then
echo "::error::Release tag $GITHUB_REF_NAME does not match the manifest version $version (expected v$version)"
exit 1
fi

# HACS installs the integration from this zip (zip_release in hacs.json).
# It holds the contents of the integration folder, not the folder itself.
- name: Build pid_departures.zip
working-directory: custom_components/pid_departures
run: zip -r "$GITHUB_WORKSPACE/pid_departures.zip" . -x "*__pycache__*" "*.pyc"

- name: Upload pid_departures.zip
run: gh release upload "$GITHUB_REF_NAME" pid_departures.zip --clobber
env:
GH_TOKEN: ${{ github.token }}
92 changes: 56 additions & 36 deletions custom_components/pid_departures/__init__.py
Original file line number Diff line number Diff line change
@@ -1,51 +1,71 @@
"""Prague Departure Board integration."""
from __future__ import annotations

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_ID
from homeassistant.const import CONF_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import entity_registry as er

from .const import DOMAIN, CONF_DEP_NUM, CONF_WALKING_OFFSET
from .errors import CannotConnect, StopNotFound, WrongApiKey
from .hub import DepartureBoard
from .const import DOMAIN
from .coordinator import PIDConfigEntry, PIDDepartureUpdateCoordinator

PLATFORMS: list[str] = ["sensor", "binary_sensor", "calendar"]


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Departure Board from a config entry flow."""
walking_offset = entry.data.get(CONF_WALKING_OFFSET, 0)
hub = DepartureBoard(
hass,
entry.data[CONF_API_KEY],
entry.data[CONF_ID],
entry.data[CONF_DEP_NUM],
walking_offset
) # type: ignore[Any]
try:
await hub.async_update()
except CannotConnect:
# try again later again
raise ConfigEntryNotReady from None
except StopNotFound:
return False
except WrongApiKey:
return False

hass.data.setdefault(DOMAIN, {})[entry.entry_id] = hub # type: ignore[Any]
async def async_setup_entry(hass: HomeAssistant, entry: PIDConfigEntry) -> bool:
"""Set up Departure Board from a config entry."""
_async_set_missing_unique_id(hass, entry)

coordinator = PIDDepartureUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()

entry.runtime_data = coordinator

_async_remove_surplus_departure_entities(hass, entry, coordinator.conn_num)

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: PIDConfigEntry) -> bool:
"""Unload a config entry."""
# This is called when an entry/configured device is to be removed. The class
# needs to unload itself, and remove callbacks. See the classes for further
# details
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id) # type: ignore[Any]

return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)


def _async_set_missing_unique_id(hass: HomeAssistant, entry: PIDConfigEntry) -> None:
"""Give entries created before unique IDs were introduced the stop ID as unique ID.

Without it, adding the same stop again would not be detected as a duplicate.
"""
if entry.unique_id is not None:
return
stop_id: str = entry.data[CONF_ID]
if any(
other.unique_id == stop_id
for other in hass.config_entries.async_entries(DOMAIN)
if other.entry_id != entry.entry_id
):
# The stop was already added twice before; keep the duplicate as it is.
return
hass.config_entries.async_update_entry(entry, unique_id=stop_id)


def _async_remove_surplus_departure_entities(
hass: HomeAssistant, entry: PIDConfigEntry, departures_number: int
) -> None:
"""Remove departure entities left over after the number of departures was lowered."""
registry = er.async_get(hass)
for entity in er.async_entries_for_config_entry(registry, entry.entry_id):
# Unique IDs of departure entities: {stop_id}_{route_name|departure_time}_{n}
prefix, _, num = entity.unique_id.rpartition("_")
if (
num.isdigit()
and int(num) > departures_number
and prefix.endswith(("_route_name", "_departure_time"))
):
registry.async_remove(entity.entity_id)


async def _async_update_listener(hass: HomeAssistant, entry: PIDConfigEntry) -> None:
"""Reload the entry when its options are updated."""
await hass.config_entries.async_reload(entry.entry_id)
37 changes: 12 additions & 25 deletions custom_components/pid_departures/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,23 @@
from typing import Any

from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback

from .const import ICON_INFO_ON, DOMAIN, ICON_INFO_OFF, ICON_WHEEL
from .const import ICON_INFO_ON, ICON_INFO_OFF, ICON_WHEEL
from .coordinator import PIDConfigEntry
from .entity import BaseEntity
from .hub import DepartureBoard


async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: PIDConfigEntry,
async_add_entities: AddEntitiesCallback
) -> None:
"""Add sensors for passed config_entry in HA."""
departure_board: DepartureBoard = hass.data[DOMAIN][config_entry.entry_id] # type: ignore[Any]
async_add_entities([WheelchairSensor(departure_board), InfotextBinarySensor(departure_board)])
coordinator = config_entry.runtime_data
async_add_entities([WheelchairSensor(coordinator), InfotextBinarySensor(coordinator)])


class InfotextBinarySensor(BaseEntity, BinarySensorEntity):
Expand All @@ -30,32 +30,20 @@ class InfotextBinarySensor(BaseEntity, BinarySensorEntity):
_attr_translation_key = "infotext"
_attr_device_class = BinarySensorDeviceClass.PROBLEM
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_should_poll = False

@property
def is_on(self) -> bool | None:
return self._departure_board.info_text[0]
return self.coordinator.info_text[0]

@property
def extra_state_attributes(self) -> Mapping[str, Any]:
return self._departure_board.info_text[1]
return self.coordinator.info_text[1]

@property
def icon(self) -> str:
if self._attr_state:
if self.is_on:
return ICON_INFO_ON
else:
return ICON_INFO_OFF

async def async_added_to_hass(self) -> None:
"""Run when this Entity has been added to HA."""
# Sensors should also register callbacks to HA when their state changes
self._departure_board.register_callback(self.async_write_ha_state)

async def async_will_remove_from_hass(self) -> None:
"""Entity being removed from hass."""
# The opposite of async_added_to_hass. Remove any registered call backs here.
self._departure_board.remove_callback(self.async_write_ha_state)
return ICON_INFO_OFF


class WheelchairSensor(BaseEntity, BinarySensorEntity):
Expand All @@ -64,13 +52,12 @@ class WheelchairSensor(BaseEntity, BinarySensorEntity):
_attr_translation_key = "wheelchair_accessible"
_attr_icon = ICON_WHEEL
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_should_poll = False

@property
def is_on(self) -> bool | None:
if self._departure_board.wheelchair_accessible == 1:
if self.coordinator.wheelchair_accessible == 1:
return True
elif self._departure_board.wheelchair_accessible == 2:
elif self.coordinator.wheelchair_accessible == 2:
return False
else:
return None
59 changes: 21 additions & 38 deletions custom_components/pid_departures/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,71 +4,58 @@
from collections.abc import Mapping
from datetime import datetime, timedelta
import logging
from typing import Any, cast
from typing import Any
from typing_extensions import override

from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, STATE_ON
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt

from .const import CAL_EVENT_MIN_DURATION_SEC, CONF_CAL_EVENTS_NUM, DOMAIN, ICON_STOP, ROUTE_TYPE_ICON, RouteType
from .const import CAL_EVENT_MIN_DURATION_SEC, ICON_STOP, ROUTE_TYPE_ICON, RouteType
from .coordinator import PIDConfigEntry, PIDDepartureUpdateCoordinator
from .dep_board_api import PIDDepartureBoardAPI
from .entity import BaseEntity
from .hub import DepartureBoard, DepartureData
from .hub import DepartureData

_LOGGER = logging.getLogger(__name__)


async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: PIDConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
departure_board: DepartureBoard = hass.data[DOMAIN][config_entry.entry_id] # type: ignore[Any]
events_count: int = config_entry.data[CONF_CAL_EVENTS_NUM] # type: ignore[Any]
coordinator = config_entry.runtime_data
async_add_entities([
DeparturesCalendarEntity(departure_board, events_count=events_count),
DeparturesCalendarEntity(coordinator, events_count=coordinator.cal_events_count),
])


class DeparturesCalendarEntity(BaseEntity, CalendarEntity):

_attr_should_poll = False
_attr_translation_key = "departures"

def __init__(self, departure_board: DepartureBoard, events_count: int) -> None:
super().__init__(departure_board)
def __init__(self, coordinator: PIDDepartureUpdateCoordinator, events_count: int) -> None:
super().__init__(coordinator)
self._events_count = events_count
self._event: CalendarEvent | None = None

@override
async def async_added_to_hass(self):
"""Run when this Entity has been added to HA."""
# Sensors should also register callbacks to HA when their state changes
self._departure_board.register_callback(self.async_write_ha_state)

@override
async def async_will_remove_from_hass(self):
"""Entity being removed from hass."""
# The opposite of async_added_to_hass. Remove any registered call backs here.
self._departure_board.remove_callback(self.async_write_ha_state)

@property
@override
def event(self) -> CalendarEvent | None:
"""Return the current or next upcoming event."""
return self._create_event(self._departure_board.departures[0])
departure = self.coordinator.departure(0)
return self._create_event(departure) if departure else None

@property
@override
def icon(self) -> str:
"""Return entity icon based on the type of route."""
if self.state == STATE_ON:
route_type = self._departure_board.departures[0].route_type
return ROUTE_TYPE_ICON.get(route_type, ROUTE_TYPE_ICON[RouteType.BUS])
departure = self.coordinator.departure(0)
if departure and self.state == STATE_ON:
return ROUTE_TYPE_ICON.get(departure.route_type, ROUTE_TYPE_ICON[RouteType.BUS])
else:
return ICON_STOP

Expand All @@ -77,10 +64,11 @@ def icon(self) -> str:
def extra_state_attributes(self) -> Mapping[str, Any]:
# NOTE: When CONF_LATITUDE and CONF_LONGITUDE is included, HASS shows
# the entity on the map.
departure = self.coordinator.departure(0)
return {
**self._departure_board.departures[0].as_dict(),
CONF_LATITUDE: self._departure_board.latitude,
CONF_LONGITUDE: self._departure_board.longitude,
**(departure.as_dict() if departure else {}),
CONF_LATITUDE: self.coordinator.latitude,
CONF_LONGITUDE: self.coordinator.longitude,
}

@override
Expand All @@ -97,17 +85,12 @@ async def async_get_events(
_LOGGER.debug(f"async_get_events: start_date={start_date} end_date={end_date} is out of range")
return []

data = await PIDDepartureBoardAPI.async_fetch_data(
self._departure_board.api_key,
self._departure_board.board_id,
departures = await self.coordinator.async_get_departures(
limit=self._events_count,
time_before=timedelta_clamp(time_before, *PIDDepartureBoardAPI.TIME_BEFORE_RANGE),
time_after=timedelta_clamp(time_after, *PIDDepartureBoardAPI.TIME_AFTER_RANGE))

events = (
self._create_event(DepartureData.from_api(dep))
for dep in cast(list[dict[str, Any]], data["departures"])
)
events = (self._create_event(dep) for dep in departures)
return [event for event in events if event]

def _create_event(self, departure: DepartureData) -> CalendarEvent | None:
Expand All @@ -132,7 +115,7 @@ def _create_event(self, departure: DepartureData) -> CalendarEvent | None:
start=start,
end=end,
summary=f"{route_type} {short_name}",
location=self._departure_board.name,
location=self.coordinator.board_name,
description=f"Trip to {departure.trip_headsign}",
)

Expand Down
Loading
Loading