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
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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**.
Expand All @@ -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.
Expand Down Expand Up @@ -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**
Expand Down
2 changes: 2 additions & 0 deletions custom_components/avinor_flight_data/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,13 +126,15 @@ 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}

coordinator = AvinorCoordinator(
hass,
api,
airlabs_api,
conf,
update_interval=timedelta(seconds=UPDATE_INTERVAL_SECONDS),
)
Expand Down
218 changes: 217 additions & 1 deletion custom_components/avinor_flight_data/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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())
16 changes: 15 additions & 1 deletion custom_components/avinor_flight_data/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)),
}
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))),
}
)
Expand Down
4 changes: 4 additions & 0 deletions custom_components/avinor_flight_data/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,6 +16,7 @@
DEFAULT_TIME_TO = 7

DEFAULT_FLIGHT_TYPE = "" # empty = all
DEFAULT_SCHEDULE_SOURCE = "avinor"

PLATFORMS = ["sensor"]

Expand All @@ -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
Loading
Loading