Skip to content

Commit 39d1f7c

Browse files
committed
sensors extended, calculation if restrictions are currently active or not, v0.5.0
1 parent 0336e40 commit 39d1f7c

File tree

7 files changed

+519
-227
lines changed

7 files changed

+519
-227
lines changed

README.md

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,17 @@ To detect exiting a car, an automation can be defined using sensor.smartphone_ha
4141
## Integration
4242

4343
### Sensors
44-
- <code>sensor.parking_[origin]</code>: sensor with public parking info at location of `origin`
44+
- <code>sensor.parking_[origin]_address</code>: sensor with adres linked to location of `origin`
45+
- <code>sensor.parking_[origin]_days_restrictions</code>: sensor with indication of days on which the city parking is restricted
46+
- <code>sensor.parking_[origin]_max_stay</code>: sensor with max amount of minutes is allowed to park during the days and time restricted schedule
47+
- <code>sensor.parking_[origin]_price</code>: sensor with price info for the city parking
48+
- <code>sensor.parking_[origin]_remarks</code>: sensor with remarks related to the the city parking
49+
- <code>sensor.parking_[origin]_restriction_active</code>: sensor with indication if the time and day restrictions are currenlty active. Eg if Sunday the city parking is not restricted, the restricted_active sensor will be False
50+
- <code>sensor.parking_[origin]_time_restrictions</code>: sensor with indication of time schedule duding which the city parking is restricted
51+
- <code>sensor.parking_[origin]_type</code>: sensor with type of city parking zone (eg disk, paid, free, etc)
52+
- <code>sensor.parking_[origin]_zone_</code>: sensor with zone name of the city parking (eg green, yellow, orange, red, blue, etc)
4553
- sensor data will be updated every 5min, unless the coordinates of the origin didn't change
46-
- <details><summary>Sensor attributes</summary>
54+
- <details><summary>For now all sensors have the same set of attributes</summary>
4755

4856
| Attribute | Description |
4957
| --------- | ----------- |
@@ -52,13 +60,26 @@ To detect exiting a car, an automation can be defined using sensor.smartphone_ha
5260
| `latitude` | Latitude of the origin |
5361
| `longitude` | Longitude of the origin |
5462
| `type` | Type of the public parking, eg paid |
55-
| `time restrictions` | Time restrictions for the public parking |
56-
| `days restrictions` | Days at which the public parking is limited |
57-
| `prices` | Price for 1 or 2 hours parking |
63+
| `time_restrictions` | Time restrictions for the public parking |
64+
| `days_restrictions` | Days at which the public parking is limited |
65+
| `prices` | Price indication for 1 or 2 hours parking and amount of free parking allowed |
5866
| `remarkds` | Extra info related to the public parking |
59-
| `maxStay` | Max time you can stay at the public parking |
67+
| `max_stay` | Max time you can stay at the public parking |
6068
| `zone` | Name of the zone, blue/orange/red zone indication of public parking |
6169
| `address` | Address for which the parking information is shown |
70+
| `time_restriction_active_now` | Time restrictions are currently active |
71+
| `day_restriction_active_now` | Day restrictions are currently active |
72+
| `maxstay_passed_now` | Max stay has passed |
73+
| `maxstay_elapsed` | Time (min) elapsed since max stay started |
74+
| `maxstay_remaining` | Time (min) remaining until max stay is reached |
75+
| `maxstay_start_time` | Time at which max stay started |
76+
| `restriction_active` | Time and day restrictions are currently active |
77+
| `last_update` | Last time data was updated |
78+
| `last_restriction_check` | Last time restrictions were checked |
79+
| `attribution` | Attribution for the data |
80+
| `device_class` | Device class |
81+
| `icon` | Icon to display |
82+
| `friendly_name` | Friendly name for the sensor |
6283

6384
</details>
6485

custom_components/cityparking/__init__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
7373
coordinator: CityParkingUserDataUpdateCoordinator
7474
httpx_client = get_async_client(hass)
7575
routeCalculatorClient = WazeRouteCalculator(region="EU", client=httpx_client)
76-
coordinator = CityParkingUserDataUpdateCoordinator(
77-
hass, seetyApi, entry, routeCalculatorClient)
76+
coordinator = CityParkingUserDataUpdateCoordinator(hass, seetyApi, entry, routeCalculatorClient)
7877

7978
hass.data[DOMAIN][entry.entry_id] = coordinator
8079
await coordinator.async_config_entry_first_refresh()

custom_components/cityparking/coordinator.py

Lines changed: 53 additions & 207 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
"""Shell Recharge data update coordinators."""
22

3+
from copy import deepcopy
34
import logging
45
import asyncio
56
from asyncio.exceptions import CancelledError
6-
import re
77

88
from aiohttp.client_exceptions import ClientError
99
from homeassistant.core import HomeAssistant
1010
from homeassistant.config_entries import ConfigEntry
1111
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
1212
from homeassistant.helpers.location import find_coordinates
13+
from homeassistant.util import dt as dt_util
1314
from .seetyApi import SeetyApi, EmptyResponseError
14-
from .seetyApi.models import Coords, CityParkingModel, ParkingSensorType, SeetyLocationResponse, SeetyUser
15+
from .seetyApi.models import *
16+
from .seetyApi.extract_info import *
1517
# from .location import LocationSession
1618
from pywaze.route_calculator import CalcRoutesResponse, WazeRouteCalculator
17-
from typing import List, Tuple
18-
1919

2020
from .const import DOMAIN, UPDATE_INTERVAL,CONF_ORIGIN
2121
_LOGGER = logging.getLogger(__name__)
@@ -45,210 +45,31 @@ async def async_find_city_parking_info(
4545

4646
return cityParkingInfo.model_dump()
4747

48-
def extract_readable_info(cityParkingInfo: CityParkingModel):
49-
rules = cityParkingInfo.rules.model_dump() if cityParkingInfo.rules else {}
50-
streetComplete = cityParkingInfo.streetComplete.model_dump() if cityParkingInfo.streetComplete else {}
51-
locationResults = cityParkingInfo.location.model_dump().get('results', [{}])[0] if cityParkingInfo.location else {}
52-
_LOGGER.debug(f"Sensor _read_coordinator_data rules: {rules}")
53-
type = rules.get('rules', {}).get('type', 'unknown')
54-
zone_type = rules.get('properties', {}).get('type', 'unknown')
55-
display, emoji = name_and_emoji(zone_type)
56-
rules_complete_zone = streetComplete.get('rules', {}).get(zone_type, {})
57-
address = f"{locationResults.get('formatted_address', '')}, {locationResults.get('countryCode', '')}" if locationResults else ''
58-
origin_coordinates = cityParkingInfo.origin_coordinates.model_dump() if cityParkingInfo.origin_coordinates else {}
59-
extra_data = {
60-
"origin": cityParkingInfo.origin,
61-
"latitude": origin_coordinates.get('lat', ''),
62-
"longitude": origin_coordinates.get('lon', ''),
63-
ParkingSensorType.TYPE.value: type,
64-
ParkingSensorType.TIME.value: hours_array_to_string(rules.get('rules', {}).get('hours', [])),
65-
ParkingSensorType.DAYS.value: days_to_string(rules.get('rules', {}).get('days', [])),
66-
ParkingSensorType.PRICE.value: prices_to_string(rules.get('rules', {}).get('prices', {})),
67-
ParkingSensorType.REMARKS.value: " - ".join(rules_complete_zone.get('remarks', "")),
68-
ParkingSensorType.MAXSTAY.value: minutes_to_string(rules_complete_zone.get('maxStay', "")),
69-
ParkingSensorType.ZONE.value: f"{display} {emoji}",
70-
ParkingSensorType.ADDRESS.value: address,
71-
}
72-
cityParkingInfo.extra_data = extra_data
73-
74-
75-
def days_to_string(days):
76-
names = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
77-
78-
# normalize & sort
79-
sorted_days = sorted(set(days))
80-
81-
# 7d/7
82-
if len(sorted_days) == 7:
83-
return '7d/7'
84-
85-
# weekend (sat + sun)
86-
if len(sorted_days) == 2 and 0 in sorted_days and 6 in sorted_days:
87-
return 'Sat-Sun'
88-
89-
# check consecutive
90-
if len(sorted_days) < 2:
91-
return ",".join(names[d] for d in sorted_days)
92-
93-
consecutive = all(
94-
sorted_days[i] == sorted_days[i - 1] + 1
95-
for i in range(1, len(sorted_days))
96-
)
97-
98-
if consecutive:
99-
return f"{names[sorted_days[0]]}-{names[sorted_days[-1]]}"
100-
101-
# fallback: comma-separated list
102-
return ",".join(names[d] for d in sorted_days)
103-
104-
105-
def hours_array_to_string(hours: List[str]) -> str:
106-
if not hours or len(hours) != 2:
107-
return ""
108-
109-
start, end = hours
110-
111-
# Full-day special case
112-
if start == "00:00" and end == "24:00":
113-
return "24h/24"
114-
115-
return f"{start} - {end}"
116-
117-
118-
def prices_to_string(prices: dict) -> str:
119-
if not prices:
120-
return ""
121-
122-
parts = []
123-
124-
for hours, price in sorted(prices.items(), key=lambda x: int(x[0])):
125-
h = int(hours)
126-
if h == 0 and price != 0:
127-
parts.append(f"Free: {int(price)}min")
128-
if h > 0:
129-
parts.append(f"{price}€ ({h}h)")
13048

131-
return " - ".join(parts)
13249

133-
def minutes_to_string(minutes_str: str) -> str:
134-
try:
135-
minutes = int(minutes_str)
136-
except (ValueError, TypeError):
137-
return "0m" # fallback for invalid input
138-
139-
if minutes <= 0:
140-
return "0m"
141-
142-
hours = minutes // 60
143-
mins = minutes % 60
144-
145-
if hours == 0:
146-
return f"{mins}m"
147-
if mins == 0:
148-
return f"{hours}h"
149-
150-
return f"{hours}h {mins}m"
151-
152-
153-
# Canonical display name + emoji
154-
_CANONICAL = {
155-
"blue": ("Blue", "🔵"),
156-
"orange": ("Orange", "🟠"),
157-
"orange-dark": ("Orange (dark)", "🟠"),
158-
"orange-2": ("Orange (variant)", "🟠"),
159-
"pedestrian": ("Pedestrian", "🚶"),
160-
"pink": ("Pink", "🩷"),
161-
"red": ("Red", "🔴"),
162-
"resident": ("Resident", "🏠"),
163-
"yellow": ("Yellow", "🟡"),
164-
"yellow-dark": ("Yellow (dark)", "🟡"),
165-
"yellow-dotted": ("Yellow (dotted)", "🟡"),
166-
"yellow-dark-dotted": ("Yellow (dark, dotted)", "🟡"),
167-
"no-parking": ("No parking", "🚫"),
168-
"freeinv": ("Free", "🆓"), # best-effort interpretation
169-
"disabled": ("Disabled", "♿"),
170-
}
171-
172-
# Aliases mapped to canonical keys (add more aliases here as needed)
173-
_ALIASES = {
174-
"blue": ["blue"],
175-
"freeinv": ["freeinv", "free-inv", "free_inv", "free", "inv"],
176-
"no-parking": ["noparking", "no-parking", "no_parking", "no parking"],
177-
"orange": ["orange", "oranged", "orange1"],
178-
"orange-dark": ["orangedark", "orange-dark", "orange_dark"],
179-
"orange-2": ["orange-2", "orange2", "orange variant"],
180-
"pedestrian": ["pedestrian", "pedestrain"], # common misspelling included
181-
"pink": ["pink"],
182-
"red": ["red"],
183-
"resident": ["resident", "residents", "residentship"],
184-
"yellow": ["yellow"],
185-
"yellow-dark": ["yellowdark", "yellow-dark", "yellow_dark"],
186-
"yellow-dotted": ["yellowdotted", "yellow-dotted", "yellow_dotted"],
187-
"yellow-dark-dotted": [
188-
"yellowdarkdotted",
189-
"yellow-dark-dotted",
190-
"yellow_dark_dotted",
191-
],
192-
"disabled": ["disabled", "disability", "wheelchair", "wheel-chair"],
193-
}
194-
195-
# Build quick lookup dict from alias -> canonical
196-
_ALIAS_LOOKUP = {}
197-
for canonical_key, aliases in _ALIASES.items():
198-
for a in aliases:
199-
# store several normalized variants for each alias
200-
norm = a.lower()
201-
_ALIAS_LOOKUP[norm] = canonical_key
202-
_ALIAS_LOOKUP[re.sub(r"[^a-z0-9]", "", norm)] = canonical_key # compact form
203-
_ALIAS_LOOKUP[norm.replace("-", " ")] = canonical_key
204-
205-
206-
def _normalize_key(raw: str) -> str:
207-
"""Normalize a raw input key into a canonical lookup form."""
208-
if raw is None:
209-
return ""
210-
s = str(raw).strip().lower()
211-
# remove surrounding quotes if any
212-
s = s.strip("\"'` ")
213-
# collapse whitespace and common separators to single hyphen
214-
s = re.sub(r"[ _]+", "-", s)
215-
s = re.sub(r"[^a-z0-9\-]", "", s)
216-
# special-case trailing 's' (plural) -> try singular
217-
if s.endswith("s") and (s[:-1] in _ALIAS_LOOKUP or s[:-1] in _CANONICAL):
218-
s = s[:-1]
219-
return s
220-
221-
222-
def name_and_emoji(raw_name: str) -> Tuple[str, str]:
223-
"""
224-
Return a tuple (clean_display_name, emoji) for a raw key like "blue" or "orange-2".
225-
Falls back to capitalized raw_name and a default emoji if unknown.
226-
"""
227-
norm = _normalize_key(raw_name)
228-
# direct canonical match
229-
if norm in _CANONICAL:
230-
return _CANONICAL[norm]
231-
232-
# alias lookup
233-
if norm in _ALIAS_LOOKUP:
234-
canon = _ALIAS_LOOKUP[norm]
235-
return _CANONICAL.get(canon, (canon.capitalize(), "🔖"))
236-
237-
# try compact form (remove hyphens)
238-
compact = re.sub(r"[^a-z0-9]", "", norm)
239-
if compact in _ALIAS_LOOKUP:
240-
canon = _ALIAS_LOOKUP[compact]
241-
return _CANONICAL.get(canon, (canon.capitalize(), "🔖"))
242-
243-
# try removing trailing digits (e.g., "orange2" -> "orange")
244-
no_digits = re.sub(r"\d+$", "", compact)
245-
if no_digits in _ALIAS_LOOKUP:
246-
canon = _ALIAS_LOOKUP[no_digits]
247-
return _CANONICAL.get(canon, (canon.capitalize(), "🔖"))
50+
def update_restriction_status(cityParkingInfo: CityParkingModel, update_time: dt_util.dt.datetime = None):
51+
"""Update restriction status based on current time."""
52+
time_restriction = cityParkingInfo.extra_data.get(ParkingSensorType.TIME.value + "_src", None)
53+
days_restriction = cityParkingInfo.extra_data.get(ParkingSensorType.DAYS.value + "_src", None)
54+
max_stay = cityParkingInfo.extra_data.get(ParkingSensorType.MAXSTAY.value + "_src", None)
55+
is_active_now = is_hours_active_now(time_restriction)
56+
is_active_today = is_days_active_today(days_restriction)
57+
is_restriction_active = is_active_now and is_active_today
58+
is_max_stay_passed_value, max_stay_elapsed, max_stay_remaining = is_max_stay_passed(start_dt=update_time, max_minutes=max_stay)
59+
extra_data = cityParkingInfo.extra_data if cityParkingInfo.extra_data else {}
60+
extra_data.update({
61+
TIME_RESTRICTION_ACTIVE_NOW: is_active_now,
62+
DAY_RESTRICTION_ACTIVE_NOW: is_active_today,
63+
MAXSTAY_PASSED_NOW: is_max_stay_passed_value,
64+
MAXSTAY_ELAPSED: max_stay_elapsed,
65+
MAXSTAY_REMAINING: max_stay_remaining,
66+
MAXSTAY_START_TIME: update_time,
67+
RESTRICTION_ACTIVE: is_restriction_active,
68+
LAST_UPDATE: update_time,
69+
LAST_RESTRICTION_CHECK: dt_util.now(),
70+
})
71+
cityParkingInfo.extra_data = extra_data
24872

249-
# final fallback: pretty-print the raw string and use a neutral emoji
250-
pretty = raw_name.strip().replace("_", " ").replace("-", " ").title()
251-
return pretty, "🔖"
25273

25374
class CityParkingUserDataUpdateCoordinator(DataUpdateCoordinator):
25475
"""Handles data updates for public chargers."""
@@ -272,8 +93,26 @@ def __init__(
27293
self._previousResults : CityParkingModel = None
27394
self._previousCoordinates : Coords = None
27495
self._previousResultAge = 0
275-
276-
96+
self._previousUpdate = None
97+
98+
# async def async_set_stay_start(self, unique_id: str, start_iso: str) -> None:
99+
# """Set stay_start for a specific parking entry and notify listeners."""
100+
# new_data = deepcopy(self.data)
101+
102+
# self._previousUpdate = start_iso
103+
# self._previousResultAge = 0
104+
# self._previousResults = new_data.get(unique_id, {})
105+
106+
# # Example for dict-based data:
107+
# item = dict(new_data.get(unique_id, {}))
108+
# extra = dict(item.get("extra_data", {}))
109+
# extra["stay_start"] = start_iso
110+
# item["extra_data"] = extra
111+
# new_data[unique_id] = item
112+
113+
# # Atomically set new coordinator data which triggers updates
114+
# await self.async_set_updated_data(new_data)
115+
277116
async def _async_update_data(self):
278117
"""Fetch data from API endpoint.
279118
@@ -286,9 +125,13 @@ async def _async_update_data(self):
286125
origin_coordinates_json = await self._routeCalculatorClient._ensure_coords(resolved_origin)
287126
origin_coordinates = Coords.model_validate(origin_coordinates_json)
288127
_LOGGER.info(f"coordinator origin_coordinates: {origin_coordinates}, resolved_origin: {resolved_origin}, origin: {self._origin}, previousCoordinates: {self._previousCoordinates}")
128+
129+
289130
self._previousResultAge += 1
290131
if self._previousResults is not None and self._previousCoordinates == origin_coordinates and (self._previousResultAge < MAX_RESULT_AGE):
291132
_LOGGER.debug("Coordinator _async_update_data using cached previousResults, no coordinate change detected.")
133+
# extend / update seety info with actual restriction status
134+
update_restriction_status(self._previousResults, self._previousUpdate)
292135
return self._previousResults
293136
try:
294137
data = await self._seetyApi.getAddressSeetyInfo(origin_coordinates)
@@ -299,6 +142,9 @@ async def _async_update_data(self):
299142
self._previousResults = data
300143
self._previousCoordinates = origin_coordinates
301144
self._previousResultAge = 0
145+
self._previousUpdate = dt_util.now()
146+
# extend / update seety info with actual restriction status
147+
update_restriction_status(data, self._previousUpdate)
302148
except EmptyResponseError as exc:
303149
_LOGGER.error(
304150
"EmptyResponseError occurred while fetching data for %s (%s): %s",

custom_components/cityparking/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,5 @@
1212
"issue_tracker": "https://github.com/myTselection/CityParking/issues",
1313
"loggers": ["pywaze", "homeassistant.helpers.location"],
1414
"requirements": ["pywaze==1.1.1"],
15-
"version": "0.4.0"
15+
"version": "0.5.0"
1616
}

0 commit comments

Comments
 (0)