From dc97eca011b5cdcbd8458de2a3aba6122964b2fe Mon Sep 17 00:00:00 2001 From: Kenneth Selmer Lokshall Date: Fri, 22 May 2026 02:20:29 +0200 Subject: [PATCH] Add Airlabs integration for flight schedules and details; update version to 1.1.1 --- README.md | 14 +- .../avinor_flight_data/__init__.py | 2 + custom_components/avinor_flight_data/api.py | 218 +++++++++++++++++- .../avinor_flight_data/config_flow.py | 16 +- custom_components/avinor_flight_data/const.py | 4 + .../avinor_flight_data/coordinator.py | 27 ++- .../avinor_flight_data/manifest.json | 2 +- .../avinor_flight_data/sensor.py | 8 +- .../avinor_flight_data/translations/en.json | 16 +- .../avinor_flight_data/translations/nb.json | 16 +- tests/test_api_parsing.py | 132 +++++++++++ 11 files changed, 435 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 3323a4a..057c828 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![HACS Custom](https://img.shields.io/badge/HACS-Custom-blue.svg)](https://hacs.xyz/) [![Hassfest](https://github.com/WickedGhost/avinor_flight_data/actions/workflows/hassfest.yml/badge.svg)](https://github.com/WickedGhost/avinor_flight_data/actions/workflows/hassfest.yml) [![HACS Validation](https://github.com/WickedGhost/avinor_flight_data/actions/workflows/hacs.yml/badge.svg)](https://github.com/WickedGhost/avinor_flight_data/actions/workflows/hacs.yml) -[![Version 1.0.9](https://img.shields.io/badge/Version-1.0.9-orange.svg)](custom_components/avinor_flight_data/manifest.json) +[![Version 1.1.1](https://img.shields.io/badge/Version-1.1.1-orange.svg)](custom_components/avinor_flight_data/manifest.json) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) Custom Home Assistant integration that keeps your dashboards up to date with arrivals and departures from the official Avinor data feed. @@ -65,10 +65,13 @@ Custom Home Assistant integration that keeps your dashboards up to date with arr | Direction | `A` (arrivals) or `D` (departures). | `D` | | Time from | Hours back from now to include in results. | `1` | | Time to | Hours forward from now to include in results. | `7` | +| Schedule source | `avinor` or `airlabs` schedules. | `avinor`| | Airlabs API key | Optional API key used for flight details. | none | Each configured sensor reports the flight count as its state and exposes detailed flight data through the `flights` attribute. +When `Schedule source` is set to `airlabs`, the integration uses Airlabs airport schedules for arrivals/departures, dedupes codeshares, and normalizes the result to the same sensor attributes. This is useful for airports that are missing or incomplete in Avinor's public feed. + ## Known Limitations - This integration mirrors the public Avinor flight feed and does not supplement missing airport data from other sources. @@ -82,6 +85,8 @@ https://airlabs.co/docs/flight ### Add your Airlabs API key +You can obtain an API key by creating an account at https://airlabs.co/ (free for personal use). + 1. In Home Assistant, go to Settings → Devices & Services. 2. Find **Avinor Flight Data**. 3. Open **Configure** / **Options**. @@ -90,6 +95,8 @@ https://airlabs.co/docs/flight The key is optional. If you don’t set it, you can still call the service by passing `api_key` in the service data. +The same key can also be used for the optional `Schedule source = Airlabs schedules` mode. + ### Service: `avinor_flight_data.get_flight_details` You can call this service from Developer Tools → Services, or from an automation. @@ -152,6 +159,11 @@ title: Ankomster OSL ## Release Notes +- **1.1.1** + - Refreshed config wizard translations for the Airlabs API key field so the explanatory text is picked up correctly. +- **1.1.0** + - Added an opt-in Airlabs schedules source for airports that are incomplete in Avinor's public feed. + - Dedupes codeshare schedule rows and normalizes Airlabs data to the existing sensor format. - **1.0.9** - Documented the upstream Avinor limitation for private airports such as TRF/Torp. - **0.2.1** diff --git a/custom_components/avinor_flight_data/__init__.py b/custom_components/avinor_flight_data/__init__.py index ff28715..c712c1a 100644 --- a/custom_components/avinor_flight_data/__init__.py +++ b/custom_components/avinor_flight_data/__init__.py @@ -126,6 +126,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AvinorConfigEntry) -> bo """Set up Avinor Flight Data from a config entry.""" session = async_get_clientsession(hass) api = AvinorApiClient(session) + airlabs_api = AirlabsApiClient(session) # Merge options over data so updated options take effect on reloads conf = {**entry.data, **entry.options} @@ -133,6 +134,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AvinorConfigEntry) -> bo coordinator = AvinorCoordinator( hass, api, + airlabs_api, conf, update_interval=timedelta(seconds=UPDATE_INTERVAL_SECONDS), ) diff --git a/custom_components/avinor_flight_data/api.py b/custom_components/avinor_flight_data/api.py index 42adb7c..e6877c1 100644 --- a/custom_components/avinor_flight_data/api.py +++ b/custom_components/avinor_flight_data/api.py @@ -2,16 +2,31 @@ import asyncio import logging +from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional import aiohttp import async_timeout import xmltodict -from .const import API_BASE, API_FLIGHTS, API_AIRPORTS, AIRLABS_API_BASE, AIRLABS_API_FLIGHT_DETAILS +from .const import ( + API_BASE, + API_FLIGHTS, + API_AIRPORTS, + AIRLABS_API_BASE, + AIRLABS_API_AIRPORTS, + AIRLABS_API_FLIGHT_DETAILS, + AIRLABS_API_SCHEDULES, +) _LOGGER = logging.getLogger(__name__) +NORWAY_COUNTRY_CODE = "NO" +SCHENGEN_COUNTRY_CODES = { + "AT", "BE", "CH", "CZ", "DE", "DK", "EE", "ES", "FI", "FR", "GR", "HR", + "HU", "IS", "IT", "LT", "LU", "LV", "MT", "NL", "PL", "PT", "SE", "SI", "SK", +} + class AvinorApiClient: """Simple async client for Avinor XML feeds.""" @@ -173,6 +188,7 @@ class AirlabsApiClient: def __init__(self, session: aiohttp.ClientSession) -> None: self._session = session + self._airport_cache: dict[str, dict[str, Any]] = {} async def _get_json(self, url: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: try: @@ -245,3 +261,203 @@ async def async_get_flight_details( return response if isinstance(response, dict) else {"response": response} return payload if isinstance(payload, dict) else {"response": payload} + + async def async_get_airport(self, *, api_key: str, iata_code: str) -> Dict[str, Any]: + """Fetch airport metadata for one IATA code using Airlabs.""" + + code = (iata_code or "").strip().upper() + if not code: + return {} + if code in self._airport_cache: + return self._airport_cache[code] + + payload = await self._get_json( + f"{AIRLABS_API_BASE}{AIRLABS_API_AIRPORTS}", + params={"api_key": api_key, "iata_code": code}, + ) + response = payload.get("response") if isinstance(payload, dict) else None + if isinstance(response, list): + airport = response[0] if response else {} + elif isinstance(response, dict): + airport = response + else: + airport = {} + self._airport_cache[code] = airport + return airport + + async def async_get_schedules( + self, + *, + api_key: str, + airport: str, + direction: Optional[str] = None, + time_from: Optional[int] = None, + time_to: Optional[int] = None, + ) -> Dict[str, Any]: + """Fetch airport schedules from Airlabs and normalize them to integration flight records.""" + + if not api_key or not str(api_key).strip(): + raise ValueError("api_key is required") + + airport = (airport or "").strip().upper() + direction = (direction or "A").strip().upper() or "A" + + params: Dict[str, Any] = { + "api_key": api_key, + "limit": 50, + } + if direction == "D": + params["dep_iata"] = airport + else: + params["arr_iata"] = airport + + payload = await self._get_json(f"{AIRLABS_API_BASE}{AIRLABS_API_SCHEDULES}", params=params) + if isinstance(payload, dict) and payload.get("error"): + message = payload.get("message") or payload.get("error") + raise RuntimeError(f"Airlabs API error: {message}") + + response = payload.get("response") if isinstance(payload, dict) else None + rows = response if isinstance(response, list) else [] + rows = self._filter_schedule_rows(rows, direction=direction, time_from=time_from, time_to=time_to) + flights = await self._normalize_schedule_rows(api_key=api_key, rows=rows, direction=direction, airport=airport) + return { + "lastUpdate": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "flights": flights, + } + + def _filter_schedule_rows( + self, + rows: List[Dict[str, Any]], + *, + direction: str, + time_from: Optional[int], + time_to: Optional[int], + ) -> List[Dict[str, Any]]: + now = datetime.now(timezone.utc) + window_start = now - timedelta(hours=max(int(time_from or 0), 0)) + window_end = now + timedelta(hours=max(int(time_to or 0), 0)) + filtered: List[Dict[str, Any]] = [] + for row in rows: + schedule_time = self._parse_schedule_datetime(row, direction) + if schedule_time is None: + continue + if schedule_time < window_start or schedule_time > window_end: + continue + filtered.append(row) + return filtered + + async def _normalize_schedule_rows( + self, + *, + api_key: str, + rows: List[Dict[str, Any]], + direction: str, + airport: str, + ) -> List[Dict[str, Any]]: + deduped = self._dedupe_schedule_rows(rows) + opposite_codes = { + self._get_counterparty_airport(row, direction) + for row in deduped + if self._get_counterparty_airport(row, direction) + } + airport_meta: dict[str, dict[str, Any]] = {} + for code in opposite_codes: + airport_meta[code] = await self.async_get_airport(api_key=api_key, iata_code=code) + + flights: List[Dict[str, Any]] = [] + for row in deduped: + other_airport = self._get_counterparty_airport(row, direction) + meta = airport_meta.get(other_airport or "", {}) + flights.append( + { + "uniqueId": self._schedule_identity(row), + "airline": row.get("airline_iata") or row.get("airline_icao"), + "flightId": row.get("flight_iata") or row.get("flight_icao") or row.get("flight_number") or "", + "dom_int": self._classify_airlabs_flight(country_code=str(meta.get("country_code") or "").upper(), airport_code=other_airport), + "schedule_time": self._schedule_time_utc(row, direction), + "arr_dep": direction, + "airport": other_airport, + "check_in": row.get("dep_gate") if direction == "D" else None, + "gate": row.get("arr_gate") if direction == "A" else row.get("dep_gate"), + "status_code": self._map_airlabs_status(row.get("status")), + "status_time": row.get("arr_actual_utc") if direction == "A" else row.get("dep_actual_utc"), + } + ) + return flights + + def _dedupe_schedule_rows(self, rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + grouped: dict[str, Dict[str, Any]] = {} + for row in rows: + key = self._schedule_identity(row) + current = grouped.get(key) + if current is None or self._schedule_preference(row) > self._schedule_preference(current): + grouped[key] = row + return list(grouped.values()) + + def _schedule_identity(self, row: Dict[str, Any]) -> str: + return "|".join( + [ + str(row.get("cs_flight_iata") or row.get("cs_flight_icao") or row.get("flight_iata") or row.get("flight_icao") or row.get("flight_number") or ""), + str(row.get("dep_iata") or row.get("dep_icao") or ""), + str(row.get("arr_iata") or row.get("arr_icao") or ""), + str(row.get("dep_time_utc") or ""), + str(row.get("arr_time_utc") or ""), + ] + ) + + def _schedule_preference(self, row: Dict[str, Any]) -> int: + score = 0 + if not row.get("cs_flight_iata") and not row.get("cs_flight_icao"): + score += 10 + if row.get("flight_iata"): + score += 2 + if row.get("arr_estimated_utc") or row.get("dep_estimated_utc"): + score += 1 + return score + + def _get_counterparty_airport(self, row: Dict[str, Any], direction: str) -> str: + if direction == "D": + return str(row.get("arr_iata") or row.get("arr_icao") or "").strip().upper() + return str(row.get("dep_iata") or row.get("dep_icao") or "").strip().upper() + + def _schedule_time_utc(self, row: Dict[str, Any], direction: str) -> str: + if direction == "D": + return str(row.get("dep_time_utc") or row.get("dep_estimated_utc") or row.get("dep_time") or "") + return str(row.get("arr_time_utc") or row.get("arr_estimated_utc") or row.get("arr_time") or "") + + def _parse_schedule_datetime(self, row: Dict[str, Any], direction: str) -> datetime | None: + raw = self._schedule_time_utc(row, direction) + if not raw: + return None + text = raw.strip().replace(" ", "T") + if text.endswith("Z"): + parsed = text + elif "T" in text: + parsed = f"{text}Z" + else: + parsed = text + try: + return datetime.fromisoformat(parsed.replace("Z", "+00:00")) + except ValueError: + return None + + def _classify_airlabs_flight(self, *, country_code: str, airport_code: str | None) -> str: + if not airport_code: + return "" + if country_code == NORWAY_COUNTRY_CODE: + return "D" + if country_code in SCHENGEN_COUNTRY_CODES: + return "S" + if country_code: + return "I" + return "" + + def _map_airlabs_status(self, status: Any) -> str: + normalized = str(status or "").strip().lower() + return { + "scheduled": "E", + "active": "EXP", + "en-route": "EXP", + "landed": "A", + "cancelled": "C", + }.get(normalized, str(status or "").strip().upper()) diff --git a/custom_components/avinor_flight_data/config_flow.py b/custom_components/avinor_flight_data/config_flow.py index 84d5f11..429ba64 100644 --- a/custom_components/avinor_flight_data/config_flow.py +++ b/custom_components/avinor_flight_data/config_flow.py @@ -19,9 +19,11 @@ CONF_TIME_TO, CONF_FLIGHT_TYPE, CONF_AIRLABS_API_KEY, + CONF_SCHEDULE_SOURCE, DEFAULT_TIME_FROM, DEFAULT_TIME_TO, DEFAULT_FLIGHT_TYPE, + DEFAULT_SCHEDULE_SOURCE, ) from .api import AvinorApiClient @@ -55,11 +57,14 @@ async def async_step_user(self, user_input: Dict[str, Any] | None = None) -> Flo if user_input is not None: # Unique key: airport + direction + flight type (allows multiple entries per airport/direction) flight_type = (user_input.get(CONF_FLIGHT_TYPE) or "").strip().upper() or "ALL" + schedule_source = (user_input.get(CONF_SCHEDULE_SOURCE) or DEFAULT_SCHEDULE_SOURCE).strip().lower() await self.async_set_unique_id( - f"{user_input[CONF_AIRPORT]}_{user_input[CONF_DIRECTION]}_{flight_type}" + f"{user_input[CONF_AIRPORT]}_{user_input[CONF_DIRECTION]}_{flight_type}_{schedule_source}" ) self._abort_if_unique_id_configured() title_suffix = "All" if flight_type == "ALL" else flight_type + if schedule_source == "airlabs": + title_suffix = f"{title_suffix} Airlabs" return self.async_create_entry( title=f"{user_input[CONF_AIRPORT]} {user_input[CONF_DIRECTION]} {title_suffix}", data=user_input, @@ -95,6 +100,10 @@ async def async_step_user(self, user_input: Dict[str, Any] | None = None) -> Flo "I": "International", "S": "Schengen", }), + vol.Optional(CONF_SCHEDULE_SOURCE, default=DEFAULT_SCHEDULE_SOURCE): vol.In({ + "avinor": "Avinor", + "airlabs": "Airlabs schedules", + }), vol.Optional(CONF_AIRLABS_API_KEY): vol.All(str, vol.Length(min=1)), } ) @@ -132,6 +141,7 @@ async def async_step_init(self, user_input: Dict[str, Any] | None = None) -> Flo time_from_default = current.get(CONF_TIME_FROM) time_to_default = current.get(CONF_TIME_TO) flight_type_default = current.get(CONF_FLIGHT_TYPE, DEFAULT_FLIGHT_TYPE) + schedule_source_default = current.get(CONF_SCHEDULE_SOURCE, DEFAULT_SCHEDULE_SOURCE) airlabs_key_default = current.get(CONF_AIRLABS_API_KEY) # Build airport field - use simple vol.In for reliability @@ -160,6 +170,10 @@ async def async_step_init(self, user_input: Dict[str, Any] | None = None) -> Flo "I": "International", "S": "Schengen", }), + vol.Optional(CONF_SCHEDULE_SOURCE, default=schedule_source_default): vol.In({ + "avinor": "Avinor", + "airlabs": "Airlabs schedules", + }), vol.Optional(CONF_AIRLABS_API_KEY, default=airlabs_key_default): vol.Any(None, vol.All(str, vol.Length(min=1))), } ) diff --git a/custom_components/avinor_flight_data/const.py b/custom_components/avinor_flight_data/const.py index 1ba11d2..9696d0e 100644 --- a/custom_components/avinor_flight_data/const.py +++ b/custom_components/avinor_flight_data/const.py @@ -7,6 +7,7 @@ # Optional Airlabs integration (flight details) CONF_AIRLABS_API_KEY = "airlabs_api_key" +CONF_SCHEDULE_SOURCE = "schedule_source" # Client-side filtering options CONF_FLIGHT_TYPE = "flight_type" # Avinor dom_int field @@ -15,6 +16,7 @@ DEFAULT_TIME_TO = 7 DEFAULT_FLIGHT_TYPE = "" # empty = all +DEFAULT_SCHEDULE_SOURCE = "avinor" PLATFORMS = ["sensor"] @@ -27,6 +29,8 @@ AIRLABS_API_BASE = "https://airlabs.co/api/v9" AIRLABS_API_FLIGHT_DETAILS = "/flight" +AIRLABS_API_SCHEDULES = "/schedules" +AIRLABS_API_AIRPORTS = "/airports" # Update every 3 minutes as suggested by Avinor docs UPDATE_INTERVAL_SECONDS = 180 diff --git a/custom_components/avinor_flight_data/coordinator.py b/custom_components/avinor_flight_data/coordinator.py index 6fd8b35..460d973 100644 --- a/custom_components/avinor_flight_data/coordinator.py +++ b/custom_components/avinor_flight_data/coordinator.py @@ -7,10 +7,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .api import AvinorApiClient +from .api import AirlabsApiClient, AvinorApiClient from .const import ( CONF_AIRPORT, + CONF_AIRLABS_API_KEY, CONF_DIRECTION, + CONF_SCHEDULE_SOURCE, CONF_TIME_FROM, CONF_TIME_TO, ) @@ -25,6 +27,7 @@ def __init__( self, hass: HomeAssistant, api: AvinorApiClient, + airlabs_api: AirlabsApiClient, conf: Dict[str, Any], *, update_interval: timedelta, @@ -36,17 +39,27 @@ def __init__( update_interval=update_interval, ) self._api = api + self._airlabs_api = airlabs_api self._conf = conf self._last_data: Optional[Dict[str, Any]] = None async def _async_update_data(self) -> Dict[str, Any]: try: - flights = await self._api.async_get_flights( - airport=self._conf[CONF_AIRPORT], - direction=self._conf.get(CONF_DIRECTION), - time_from=self._conf.get(CONF_TIME_FROM), - time_to=self._conf.get(CONF_TIME_TO), - ) + if self._conf.get(CONF_SCHEDULE_SOURCE) == "airlabs": + flights = await self._airlabs_api.async_get_schedules( + api_key=self._conf.get(CONF_AIRLABS_API_KEY, ""), + airport=self._conf[CONF_AIRPORT], + direction=self._conf.get(CONF_DIRECTION), + time_from=self._conf.get(CONF_TIME_FROM), + time_to=self._conf.get(CONF_TIME_TO), + ) + else: + flights = await self._api.async_get_flights( + airport=self._conf[CONF_AIRPORT], + direction=self._conf.get(CONF_DIRECTION), + time_from=self._conf.get(CONF_TIME_FROM), + time_to=self._conf.get(CONF_TIME_TO), + ) # Keep a copy as last known good data self._last_data = flights diff --git a/custom_components/avinor_flight_data/manifest.json b/custom_components/avinor_flight_data/manifest.json index ba10df5..944537f 100644 --- a/custom_components/avinor_flight_data/manifest.json +++ b/custom_components/avinor_flight_data/manifest.json @@ -8,5 +8,5 @@ "issue_tracker": "https://github.com/WickedGhost/avinor_flight_data/issues", "loggers": ["custom_components.avinor_flight_data"], "requirements": ["xmltodict==0.13.0"], - "version": "1.0.9" + "version": "1.1.1" } diff --git a/custom_components/avinor_flight_data/sensor.py b/custom_components/avinor_flight_data/sensor.py index 7f7e981..278da13 100644 --- a/custom_components/avinor_flight_data/sensor.py +++ b/custom_components/avinor_flight_data/sensor.py @@ -17,6 +17,8 @@ CONF_TIME_FROM, CONF_TIME_TO, CONF_FLIGHT_TYPE, + CONF_SCHEDULE_SOURCE, + DEFAULT_SCHEDULE_SOURCE, ) @@ -74,10 +76,13 @@ def __init__(self, entry: ConfigEntry, coordinator) -> None: airport = conf[CONF_AIRPORT] direction = conf[CONF_DIRECTION] flight_type = (conf.get(CONF_FLIGHT_TYPE) or "").strip().upper() or "ALL" + schedule_source = (conf.get(CONF_SCHEDULE_SOURCE) or DEFAULT_SCHEDULE_SOURCE).strip().lower() # Include flight_type so multiple entities can exist for same airport/direction. - self._attr_unique_id = f"avinor_{airport}_{direction}_{flight_type}" + self._attr_unique_id = f"avinor_{airport}_{direction}_{flight_type}_{schedule_source}" name_suffix = "All" if flight_type == "ALL" else flight_type + if schedule_source == "airlabs": + name_suffix = f"{name_suffix} Airlabs" self._attr_name = f"Avinor {airport} {direction} {name_suffix}" @property @@ -109,6 +114,7 @@ def extra_state_attributes(self) -> dict[str, Any]: "airport": conf.get(CONF_AIRPORT), "direction": conf.get(CONF_DIRECTION), "flight_type": conf.get(CONF_FLIGHT_TYPE), + "schedule_source": conf.get(CONF_SCHEDULE_SOURCE, DEFAULT_SCHEDULE_SOURCE), "time_from": conf.get(CONF_TIME_FROM), "time_to": conf.get(CONF_TIME_TO), "last_update": data.get("lastUpdate"), diff --git a/custom_components/avinor_flight_data/translations/en.json b/custom_components/avinor_flight_data/translations/en.json index 379617f..1bc8c6c 100644 --- a/custom_components/avinor_flight_data/translations/en.json +++ b/custom_components/avinor_flight_data/translations/en.json @@ -9,14 +9,18 @@ "direction": "Flight Direction", "time_from": "Hours Back", "time_to": "Hours Forward", - "flight_type": "Flight type" + "flight_type": "Flight type", + "schedule_source": "Schedule source", + "airlabs_api_key": "Airlabs API key" }, "data_description": { "airport": "Select the airport to monitor (IATA code). Search by typing the airport name or code.", "direction": "Choose whether to show arriving or departing flights.", "time_from": "Include flights from this many hours ago (0-72 hours).", "time_to": "Include flights up to this many hours ahead (0-72 hours).", - "flight_type": "Filter by flight type. All = no filtering." + "flight_type": "Filter by flight type. All = no filtering.", + "schedule_source": "Choose Avinor for the default feed, or Airlabs schedules when you need broader airport coverage. Airlabs requires an API key. The same key is also used when you want to click a flight for details.", + "airlabs_api_key": "Required for Airlabs schedules and for opening flight details when clicking a flight in supported cards." } } } @@ -30,14 +34,18 @@ "direction": "Flight Direction", "time_from": "Hours Back", "time_to": "Hours Forward", - "flight_type": "Flight type" + "flight_type": "Flight type", + "schedule_source": "Schedule source", + "airlabs_api_key": "Airlabs API key" }, "data_description": { "airport": "Change the airport to monitor a different location.", "direction": "Switch between arrivals and departures.", "time_from": "Adjust the time window for past flights (0-72 hours).", "time_to": "Adjust the time window for future flights (0-72 hours).", - "flight_type": "Filter by flight type. All = no filtering." + "flight_type": "Filter by flight type. All = no filtering.", + "schedule_source": "Choose Avinor for the default feed, or Airlabs schedules when you need broader airport coverage. Airlabs requires an API key. The same key is also used when you want to click a flight for details.", + "airlabs_api_key": "Required for Airlabs schedules and for opening flight details when clicking a flight in supported cards." } } } diff --git a/custom_components/avinor_flight_data/translations/nb.json b/custom_components/avinor_flight_data/translations/nb.json index 7badf74..73d28ac 100644 --- a/custom_components/avinor_flight_data/translations/nb.json +++ b/custom_components/avinor_flight_data/translations/nb.json @@ -9,14 +9,18 @@ "direction": "Flyretning", "time_from": "Timer tilbake", "time_to": "Timer frem", - "flight_type": "Flytype" + "flight_type": "Flytype", + "schedule_source": "Datakilde", + "airlabs_api_key": "Airlabs API-nøkkel" }, "data_description": { "airport": "Velg flyplassen du vil overvåke (IATA-kode). Søk ved å skrive flyplassnavn eller kode.", "direction": "Velg om du vil vise ankommende eller avgående fly.", "time_from": "Inkluder fly fra dette antall timer tilbake (0-72 timer).", "time_to": "Inkluder fly opptil dette antall timer frem (0-72 timer).", - "flight_type": "Filtrer på flytype. Alle = ingen filtrering." + "flight_type": "Filtrer på flytype. Alle = ingen filtrering.", + "schedule_source": "Velg Avinor for standardstrømmen, eller Airlabs schedules når du trenger bredere flyplassdekning. Airlabs krever API-nøkkel. Den samme nøkkelen brukes også hvis du vil kunne klikke på et fly for detaljer.", + "airlabs_api_key": "Påkrevd for Airlabs schedules og for å åpne flydetaljer når du klikker på et fly i kort som støtter dette." } } } @@ -30,14 +34,18 @@ "direction": "Flyretning", "time_from": "Timer tilbake", "time_to": "Timer frem", - "flight_type": "Flytype" + "flight_type": "Flytype", + "schedule_source": "Datakilde", + "airlabs_api_key": "Airlabs API-nøkkel" }, "data_description": { "airport": "Bytt flyplass for å overvåke en annen lokasjon.", "direction": "Bytt mellom ankomster og avganger.", "time_from": "Juster tidsvinduet for tidligere fly (0-72 timer).", "time_to": "Juster tidsvinduet for fremtidige fly (0-72 timer).", - "flight_type": "Filtrer på flytype. Alle = ingen filtrering." + "flight_type": "Filtrer på flytype. Alle = ingen filtrering.", + "schedule_source": "Velg Avinor for standardstrømmen, eller Airlabs schedules når du trenger bredere flyplassdekning. Airlabs krever API-nøkkel. Den samme nøkkelen brukes også hvis du vil kunne klikke på et fly for detaljer.", + "airlabs_api_key": "Påkrevd for Airlabs schedules og for å åpne flydetaljer når du klikker på et fly i kort som støtter dette." } } } diff --git a/tests/test_api_parsing.py b/tests/test_api_parsing.py index 6ed97fb..e2e21e8 100644 --- a/tests/test_api_parsing.py +++ b/tests/test_api_parsing.py @@ -1,4 +1,5 @@ import asyncio +from datetime import datetime, timedelta, timezone from types import SimpleNamespace import pytest @@ -25,6 +26,7 @@ async def _get_xml(self, url: str, params=None): class StubAirlabsClient(AirlabsApiClient): def __init__(self, payload): + super().__init__(session=None) self._payload = payload self.last_url = None self.last_params = None @@ -32,6 +34,8 @@ def __init__(self, payload): async def _get_json(self, url: str, params=None): self.last_url = url self.last_params = params + if callable(self._payload): + return self._payload(url, params) return self._payload @@ -212,3 +216,131 @@ async def test_airlabs_get_flight_details_raises_on_error_payload(): client = StubAirlabsClient({"error": "some_error", "message": "Bad request"}) with pytest.raises(RuntimeError): await client.async_get_flight_details(api_key="k", flight_iata="DY123") + + +@pytest.mark.asyncio +async def test_airlabs_get_schedules_dedupes_and_classifies_routes(): + now = datetime.now(timezone.utc) + + def utc_in(hours: int) -> str: + return (now + timedelta(hours=hours)).strftime("%Y-%m-%d %H:%M") + + schedules_payload = { + "response": [ + { + "flight_iata": "KL8476", + "cs_flight_iata": "SK1398", + "airline_iata": "KL", + "dep_iata": "CPH", + "arr_iata": "TRF", + "arr_time_utc": utc_in(1), + "status": "landed", + }, + { + "flight_iata": "SK1398", + "airline_iata": "SK", + "dep_iata": "CPH", + "arr_iata": "TRF", + "arr_time_utc": utc_in(1), + "status": "landed", + }, + { + "flight_iata": "WF481", + "airline_iata": "WF", + "dep_iata": "TRD", + "arr_iata": "TRF", + "arr_time_utc": utc_in(2), + "status": "scheduled", + }, + { + "flight_iata": "FR6216", + "airline_iata": "FR", + "dep_iata": "KRK", + "arr_iata": "TRF", + "arr_time_utc": utc_in(3), + "status": "scheduled", + }, + { + "flight_iata": "U28635", + "airline_iata": "U2", + "dep_iata": "LTN", + "arr_iata": "TRF", + "arr_time_utc": utc_in(4), + "status": "active", + }, + { + "flight_iata": "TOO_OLD", + "airline_iata": "XX", + "dep_iata": "BGO", + "arr_iata": "TRF", + "arr_time_utc": utc_in(-20), + "status": "landed", + }, + ] + } + airport_payloads = { + "CPH": {"response": [{"iata_code": "CPH", "country_code": "DK"}]}, + "TRD": {"response": [{"iata_code": "TRD", "country_code": "NO"}]}, + "KRK": {"response": [{"iata_code": "KRK", "country_code": "PL"}]}, + "LTN": {"response": [{"iata_code": "LTN", "country_code": "GB"}]}, + } + + def payload(url, params): + if url.endswith("/schedules"): + return schedules_payload + if url.endswith("/airports"): + return airport_payloads[params["iata_code"]] + raise AssertionError(f"Unexpected URL: {url}") + + client = StubAirlabsClient(payload) + result = await client.async_get_schedules( + api_key="k", + airport="TRF", + direction="A", + time_from=2, + time_to=6, + ) + + assert len(result["flights"]) == 4 + assert [flight["flightId"] for flight in result["flights"]] == ["SK1398", "WF481", "FR6216", "U28635"] + assert [flight["dom_int"] for flight in result["flights"]] == ["S", "D", "S", "I"] + assert [flight["status_code"] for flight in result["flights"]] == ["A", "E", "E", "EXP"] + assert result["flights"][0]["airport"] == "CPH" + + +@pytest.mark.asyncio +async def test_airlabs_get_schedules_for_departures_uses_arrival_airport_for_type(): + now = datetime.now(timezone.utc) + dep_time = (now + timedelta(hours=1)).strftime("%Y-%m-%d %H:%M") + + def payload(url, params): + if url.endswith("/schedules"): + return { + "response": [ + { + "flight_iata": "DY123", + "airline_iata": "DY", + "dep_iata": "TRF", + "arr_iata": "LGW", + "dep_time_utc": dep_time, + "status": "scheduled", + } + ] + } + if url.endswith("/airports"): + return {"response": [{"iata_code": "LGW", "country_code": "GB"}]} + raise AssertionError(f"Unexpected URL: {url}") + + client = StubAirlabsClient(payload) + result = await client.async_get_schedules( + api_key="k", + airport="TRF", + direction="D", + time_from=0, + time_to=2, + ) + + assert len(result["flights"]) == 1 + assert result["flights"][0]["airport"] == "LGW" + assert result["flights"][0]["dom_int"] == "I" + assert result["flights"][0]["arr_dep"] == "D"