772 lines
32 KiB
Python
772 lines
32 KiB
Python
# FlightMonitor/data/opensky_live_adapter.py
|
|
"""
|
|
Adapter for fetching live flight data from the OpenSky Network API.
|
|
Supports both anonymous access (via opensky-api library) and
|
|
authenticated access using OAuth2 Client Credentials Flow (via direct requests).
|
|
"""
|
|
import requests
|
|
import time
|
|
import threading
|
|
from queue import Queue, Empty as QueueEmpty, Full as QueueFull
|
|
import random
|
|
import os
|
|
import json # MODIFICATO: Aggiunto import per la gestione del JSON nei dati mock
|
|
from typing import Dict, Any, List, Optional, Tuple
|
|
import warnings
|
|
|
|
# Import OpenSkyApi for anonymous access (fallback)
|
|
from flightmonitor.data.opensky_api import OpenSkyApi # type: ignore
|
|
|
|
from flightmonitor.data import config as app_config
|
|
from flightmonitor.utils.logger import get_logger
|
|
from flightmonitor.data.common_models import CanonicalFlightState
|
|
from flightmonitor.data.base_live_data_adapter import (
|
|
BaseLiveDataAdapter,
|
|
AdapterMessage,
|
|
)
|
|
from urllib3.exceptions import InsecureRequestWarning
|
|
|
|
warnings.simplefilter("ignore", InsecureRequestWarning)
|
|
|
|
module_logger = get_logger(__name__)
|
|
|
|
# Constants from opensky_live_adapter or a shared constants module
|
|
MSG_TYPE_FLIGHT_DATA = "flight_data"
|
|
MSG_TYPE_ADAPTER_STATUS = "adapter_status"
|
|
STATUS_STARTING = "STARTING"
|
|
STATUS_FETCHING = "FETCHING"
|
|
STATUS_RECOVERED = "RECOVERED"
|
|
STATUS_RATE_LIMITED = (
|
|
"RATE_LIMITED" # Can still occur with anonymous or if token is invalid/revoked
|
|
)
|
|
STATUS_API_ERROR_TEMPORARY = "API_ERROR_TEMPORARY"
|
|
STATUS_PERMANENT_FAILURE = "PERMANENT_FAILURE"
|
|
STATUS_STOPPING = "STOPPING"
|
|
STATUS_STOPPED = "STOPPED"
|
|
|
|
PROVIDER_NAME = "OpenSkyNetwork"
|
|
INITIAL_BACKOFF_DELAY_SECONDS = 20.0
|
|
MAX_BACKOFF_DELAY_SECONDS = 300.0
|
|
BACKOFF_FACTOR = 1.8
|
|
MAX_CONSECUTIVE_ERRORS_THRESHOLD = 5
|
|
|
|
|
|
class OpenSkyLiveAdapter(BaseLiveDataAdapter):
|
|
"""
|
|
Polls the OpenSky Network API. Uses OAuth2 Client Credentials if configured,
|
|
otherwise falls back to anonymous access via the opensky-api library.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
output_queue: Queue[AdapterMessage],
|
|
bounding_box: Dict[str, float],
|
|
polling_interval: int = app_config.LIVE_POLLING_INTERVAL_SECONDS,
|
|
daemon: bool = True,
|
|
):
|
|
thread_name = f"OpenSkyAdapter-bbox-{bounding_box.get('lat_min',0):.1f}"
|
|
super().__init__(
|
|
output_queue,
|
|
bounding_box,
|
|
float(polling_interval),
|
|
daemon,
|
|
name=thread_name,
|
|
)
|
|
|
|
self._consecutive_api_errors: int = 0
|
|
self._current_backoff_delay: float = 0.0
|
|
self._in_backoff_mode: bool = False
|
|
|
|
self.use_oauth: bool = False
|
|
self.client_id: Optional[str] = None
|
|
self.client_secret: Optional[str] = None
|
|
self.token_url: Optional[str] = None
|
|
self.access_token: Optional[str] = None
|
|
self.token_expires_at: float = 0.0
|
|
|
|
self.api_client_anonymous: Optional[OpenSkyApi] = None
|
|
self.api_timeout = getattr(
|
|
app_config, "DEFAULT_API_TIMEOUT_SECONDS", 15
|
|
) # This timeout is used for direct OAuth calls
|
|
|
|
self.use_oauth = getattr(app_config, "USE_OPENSKY_CREDENTIALS", False)
|
|
if self.use_oauth:
|
|
self.client_id = getattr(app_config, "OPENSKY_CLIENT_ID", None)
|
|
self.client_secret = getattr(app_config, "OPENSKY_CLIENT_SECRET", None)
|
|
self.token_url = getattr(app_config, "OPENSKY_TOKEN_URL", None)
|
|
|
|
if not (self.client_id and self.client_secret and self.token_url):
|
|
module_logger.warning(
|
|
f"{self.name}: OAuth2 credentials/token URL missing in config, "
|
|
f"but USE_OPENSKY_CREDENTIALS is True. Falling back to anonymous access."
|
|
)
|
|
self.use_oauth = False
|
|
else:
|
|
module_logger.info(
|
|
f"{self.name}: Configured to use OpenSky OAuth2 Client Credentials."
|
|
)
|
|
|
|
if not self.use_oauth:
|
|
try:
|
|
# The opensky-api library does not accept a 'timeout' argument in its constructor.
|
|
self.api_client_anonymous = (
|
|
OpenSkyApi()
|
|
) # Timeout is not a valid argument here
|
|
module_logger.info(
|
|
f"{self.name}: OpenSkyApi client (anonymous access) initialized (uses internal timeout)."
|
|
)
|
|
except Exception as e:
|
|
module_logger.critical(
|
|
f"{self.name}: Failed to initialize anonymous OpenSkyApi client: {e}",
|
|
exc_info=True,
|
|
)
|
|
raise RuntimeError(
|
|
f"Failed to initialize OpenSkyApi client (anonymous): {e}"
|
|
) from e
|
|
|
|
module_logger.debug(
|
|
f"{self.name} initialized. OAuth Mode: {self.use_oauth}. "
|
|
f"BBox: {self.bounding_box}, Interval: {self.polling_interval}s"
|
|
)
|
|
|
|
def _get_oauth_token(self) -> bool:
|
|
"""
|
|
Requests an OAuth2 access token from OpenSky Network.
|
|
Returns True if successful, False otherwise.
|
|
"""
|
|
if not (self.client_id and self.client_secret and self.token_url):
|
|
module_logger.error(
|
|
f"{self.name}: Cannot get OAuth token, client ID/secret/URL missing."
|
|
)
|
|
return False
|
|
|
|
# Manually URL-encode the payload to ensure correct formatting
|
|
from urllib.parse import urlencode
|
|
|
|
payload_dict = {
|
|
"grant_type": "client_credentials",
|
|
"client_id": self.client_id,
|
|
"client_secret": self.client_secret,
|
|
}
|
|
# The urlencode function will handle converting this to the correct string format
|
|
encoded_payload = urlencode(payload_dict)
|
|
|
|
headers = {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0",
|
|
}
|
|
|
|
module_logger.info(
|
|
f"{self.name}: Requesting OAuth2 access token from {self.token_url}..."
|
|
)
|
|
try:
|
|
# Pass the manually encoded string to the 'data' parameter
|
|
response = requests.post(
|
|
self.token_url,
|
|
data=encoded_payload,
|
|
headers=headers,
|
|
timeout=self.api_timeout,
|
|
verify=False,
|
|
)
|
|
|
|
response.raise_for_status()
|
|
|
|
token_data = response.json()
|
|
self.access_token = token_data.get("access_token")
|
|
expires_in = token_data.get("expires_in", 300)
|
|
self.token_expires_at = time.time() + expires_in - 60
|
|
|
|
if self.access_token:
|
|
module_logger.info(
|
|
f"{self.name}: Successfully obtained OAuth2 access token. Expires in ~{expires_in // 60} minutes."
|
|
)
|
|
return True
|
|
else:
|
|
module_logger.error(
|
|
f"{self.name}: Failed to obtain access token, 'access_token' field missing in response: {token_data}"
|
|
)
|
|
return False
|
|
except requests.exceptions.HTTPError as http_err:
|
|
status_code = http_err.response.status_code if http_err.response else "N/A"
|
|
try:
|
|
error_details = http_err.response.json()
|
|
error_text = f"JSON Response: {error_details}"
|
|
except requests.exceptions.JSONDecodeError:
|
|
error_text = f"Non-JSON Response: {http_err.response.text.strip()}"
|
|
|
|
module_logger.error(
|
|
f"{self.name}: HTTP error {status_code} obtaining OAuth token: {error_text}",
|
|
exc_info=True,
|
|
)
|
|
except Exception as e:
|
|
module_logger.error(
|
|
f"{self.name}: Unexpected error obtaining OAuth token: {e}",
|
|
exc_info=True,
|
|
)
|
|
|
|
self.access_token = None
|
|
return False
|
|
|
|
def _is_token_valid(self) -> bool:
|
|
"""Checks if the current access token is present and not expired (with buffer)."""
|
|
return self.access_token is not None and time.time() < self.token_expires_at
|
|
|
|
def _send_status_to_queue(
|
|
self, status_code: str, message: str, details: Optional[Dict] = None
|
|
):
|
|
status_payload = {
|
|
"type": MSG_TYPE_ADAPTER_STATUS,
|
|
"status_code": status_code,
|
|
"message": f"{self.name}: {message}",
|
|
}
|
|
if details:
|
|
status_payload["details"] = details
|
|
try:
|
|
self.output_queue.put_nowait(status_payload)
|
|
except QueueFull:
|
|
module_logger.warning(
|
|
f"{self.name}: Output queue full. Could not send status: {status_code}"
|
|
)
|
|
except Exception as e:
|
|
module_logger.error(
|
|
f"{self.name}: Error sending status '{status_code}' to queue: {e}",
|
|
exc_info=True,
|
|
)
|
|
|
|
def _convert_opensky_api_state_to_canonical(
|
|
self, sv_opensky_lib: Any
|
|
) -> Optional[CanonicalFlightState]:
|
|
"""Converts an OpenSkyApi library StateVector object to a CanonicalFlightState object."""
|
|
try:
|
|
if sv_opensky_lib.icao24 is None:
|
|
return None
|
|
primary_ts = (
|
|
sv_opensky_lib.time_position
|
|
if sv_opensky_lib.time_position is not None
|
|
else sv_opensky_lib.last_contact
|
|
)
|
|
last_contact_ts = sv_opensky_lib.last_contact
|
|
if primary_ts is None:
|
|
primary_ts = time.time()
|
|
if last_contact_ts is None:
|
|
last_contact_ts = primary_ts
|
|
return CanonicalFlightState(
|
|
icao24=sv_opensky_lib.icao24,
|
|
callsign=(
|
|
sv_opensky_lib.callsign.strip() if sv_opensky_lib.callsign else None
|
|
),
|
|
origin_country=sv_opensky_lib.origin_country,
|
|
timestamp=float(primary_ts),
|
|
last_contact_timestamp=float(last_contact_ts),
|
|
latitude=sv_opensky_lib.latitude,
|
|
longitude=sv_opensky_lib.longitude,
|
|
baro_altitude_m=sv_opensky_lib.baro_altitude,
|
|
on_ground=sv_opensky_lib.on_ground,
|
|
velocity_mps=sv_opensky_lib.velocity,
|
|
true_track_deg=sv_opensky_lib.true_track,
|
|
vertical_rate_mps=sv_opensky_lib.vertical_rate,
|
|
geo_altitude_m=sv_opensky_lib.geo_altitude,
|
|
squawk=sv_opensky_lib.squawk,
|
|
spi=sv_opensky_lib.spi,
|
|
position_source=(
|
|
str(sv_opensky_lib.position_source)
|
|
if sv_opensky_lib.position_source is not None
|
|
else None
|
|
),
|
|
raw_data_provider=PROVIDER_NAME,
|
|
)
|
|
except Exception as e:
|
|
module_logger.error(
|
|
f"{self.name}: Error converting OpenSkyApi StateVector: {e}",
|
|
exc_info=False,
|
|
)
|
|
return None
|
|
|
|
def _convert_direct_api_state_to_canonical(
|
|
self, raw_state_list: List[Any]
|
|
) -> Optional[CanonicalFlightState]:
|
|
"""
|
|
Converts a raw state vector list (from direct API call JSON response) to CanonicalFlightState.
|
|
OpenSky direct API returns states as a list of values.
|
|
Order: icao24, callsign, origin_country, time_position, last_contact, longitude, latitude, baro_altitude,
|
|
on_ground, velocity, true_track, vertical_rate, sensors, geo_altitude, squawk, spi, position_source
|
|
"""
|
|
try:
|
|
if not raw_state_list or len(raw_state_list) < 17:
|
|
module_logger.warning(
|
|
f"{self.name}: Received incomplete state vector list: {raw_state_list}"
|
|
)
|
|
return None
|
|
|
|
icao24 = raw_state_list[0]
|
|
if not icao24:
|
|
return None
|
|
|
|
primary_ts = (
|
|
raw_state_list[3]
|
|
if raw_state_list[3] is not None
|
|
else raw_state_list[4]
|
|
)
|
|
last_contact_ts = raw_state_list[4]
|
|
if primary_ts is None:
|
|
primary_ts = time.time()
|
|
if last_contact_ts is None:
|
|
last_contact_ts = primary_ts
|
|
|
|
return CanonicalFlightState(
|
|
icao24=str(icao24).lower().strip(),
|
|
callsign=str(raw_state_list[1]).strip() if raw_state_list[1] else None,
|
|
origin_country=str(raw_state_list[2]),
|
|
timestamp=float(primary_ts),
|
|
last_contact_timestamp=float(last_contact_ts),
|
|
longitude=(
|
|
float(raw_state_list[5]) if raw_state_list[5] is not None else None
|
|
),
|
|
latitude=(
|
|
float(raw_state_list[6]) if raw_state_list[6] is not None else None
|
|
),
|
|
baro_altitude_m=(
|
|
float(raw_state_list[7]) if raw_state_list[7] is not None else None
|
|
),
|
|
on_ground=bool(raw_state_list[8]),
|
|
velocity_mps=(
|
|
float(raw_state_list[9]) if raw_state_list[9] is not None else None
|
|
),
|
|
true_track_deg=(
|
|
float(raw_state_list[10])
|
|
if raw_state_list[10] is not None
|
|
else None
|
|
),
|
|
vertical_rate_mps=(
|
|
float(raw_state_list[11])
|
|
if raw_state_list[11] is not None
|
|
else None
|
|
),
|
|
# sensors = raw_state_list[12] # Typically not used directly in canonical model
|
|
geo_altitude_m=(
|
|
float(raw_state_list[13])
|
|
if raw_state_list[13] is not None
|
|
else None
|
|
),
|
|
squawk=str(raw_state_list[14]) if raw_state_list[14] else None,
|
|
spi=bool(raw_state_list[15]),
|
|
position_source=str(
|
|
raw_state_list[16]
|
|
), # Assuming position_source is an int/string
|
|
raw_data_provider=PROVIDER_NAME,
|
|
)
|
|
except (IndexError, TypeError, ValueError) as e:
|
|
module_logger.error(
|
|
f"{self.name}: Error converting direct API state list to Canonical: {e}. List: {raw_state_list}",
|
|
exc_info=False,
|
|
)
|
|
return None
|
|
except Exception as e_conv:
|
|
module_logger.error(
|
|
f"{self.name}: Unexpected error converting direct API state: {e_conv}. List: {raw_state_list}",
|
|
exc_info=True,
|
|
)
|
|
return None
|
|
|
|
def _perform_api_request(self) -> Dict[str, Any]:
|
|
# --- MOCK API ---
|
|
if app_config.USE_MOCK_OPENSKY_API:
|
|
module_logger.info(
|
|
f"{self.name}: Using MOCK API data as per configuration."
|
|
)
|
|
self._send_status_to_queue(
|
|
STATUS_FETCHING, "Generating mock flight data..."
|
|
)
|
|
|
|
# Error simulation logic remains unchanged
|
|
if app_config.MOCK_API_ERROR_SIMULATION == "RATE_LIMITED":
|
|
self._consecutive_api_errors += 1
|
|
self._in_backoff_mode = True
|
|
mock_retry_after = str(
|
|
getattr(app_config, "MOCK_RETRY_AFTER_SECONDS", 60)
|
|
)
|
|
delay = self._calculate_next_backoff_delay(
|
|
provided_retry_after=mock_retry_after
|
|
)
|
|
err_msg = f"MOCK: Rate limited. Errors: {self._consecutive_api_errors}. Retrying in {delay:.1f}s."
|
|
return {
|
|
"error_type": STATUS_RATE_LIMITED,
|
|
"delay": delay,
|
|
"message": err_msg,
|
|
"consecutive_errors": self._consecutive_api_errors,
|
|
"status_code": 429,
|
|
}
|
|
if app_config.MOCK_API_ERROR_SIMULATION == "HTTP_ERROR":
|
|
self._consecutive_api_errors += 1
|
|
self._in_backoff_mode = True
|
|
delay = self._calculate_next_backoff_delay()
|
|
mock_status_code = getattr(app_config, "MOCK_HTTP_ERROR_STATUS", 500)
|
|
err_msg = f"MOCK: HTTP error {mock_status_code}. Errors: {self._consecutive_api_errors}. Retrying in {delay:.1f}s."
|
|
return {
|
|
"error_type": STATUS_API_ERROR_TEMPORARY,
|
|
"status_code": mock_status_code,
|
|
"message": err_msg,
|
|
"delay": delay,
|
|
"consecutive_errors": self._consecutive_api_errors,
|
|
}
|
|
|
|
# Generate canonical data
|
|
mock_canonical_states: List[CanonicalFlightState] = []
|
|
mock_count = getattr(app_config, "MOCK_API_FLIGHT_COUNT", 5)
|
|
for i in range(mock_count):
|
|
mock_canonical_states.append(self._generate_mock_flight_state(i + 1))
|
|
|
|
# MODIFICATO: Genera un payload JSON grezzo fittizio basato sui dati canonici
|
|
mock_raw_states_list = []
|
|
for state in mock_canonical_states:
|
|
mock_raw_states_list.append(
|
|
[
|
|
state.icao24,
|
|
state.callsign,
|
|
state.origin_country,
|
|
state.timestamp,
|
|
state.last_contact_timestamp,
|
|
state.longitude,
|
|
state.latitude,
|
|
state.baro_altitude_m,
|
|
state.on_ground,
|
|
state.velocity_mps,
|
|
state.true_track_deg,
|
|
state.vertical_rate_mps,
|
|
None,
|
|
state.geo_altitude_m,
|
|
state.squawk,
|
|
state.spi,
|
|
state.position_source,
|
|
]
|
|
)
|
|
mock_raw_json_payload = json.dumps(
|
|
{"time": int(time.time()), "states": mock_raw_states_list}
|
|
)
|
|
|
|
module_logger.info(
|
|
f"{self.name}: Generated {len(mock_canonical_states)} mock flight states."
|
|
)
|
|
self._reset_error_state()
|
|
return {
|
|
"canonical_data": mock_canonical_states,
|
|
"raw_json_data": mock_raw_json_payload,
|
|
}
|
|
|
|
# --- REAL API ---
|
|
if not self.bounding_box:
|
|
return {
|
|
"error_type": STATUS_PERMANENT_FAILURE,
|
|
"message": "Bounding box not set.",
|
|
"status_code": "NO_BBOX",
|
|
"delay": 0.0,
|
|
"consecutive_errors": self._consecutive_api_errors,
|
|
}
|
|
|
|
params = {
|
|
"lamin": self.bounding_box["lat_min"],
|
|
"lomin": self.bounding_box["lon_min"],
|
|
"lamax": self.bounding_box["lat_max"],
|
|
"lomax": self.bounding_box["lon_max"],
|
|
}
|
|
self._send_status_to_queue(
|
|
STATUS_FETCHING, f"Requesting REAL data for bbox: {params}"
|
|
)
|
|
|
|
try:
|
|
canonical_states: List[CanonicalFlightState] = []
|
|
raw_json_str: str = "{}" # Default empty JSON string
|
|
|
|
if self.use_oauth:
|
|
if not self._is_token_valid():
|
|
if not self._get_oauth_token():
|
|
err_msg = "Failed to obtain/refresh OAuth2 token. Trying anonymous fallback if configured, or erroring."
|
|
module_logger.error(f"{self.name}: {err_msg}")
|
|
if not self.api_client_anonymous:
|
|
self._consecutive_api_errors += 1
|
|
return {
|
|
"error_type": STATUS_API_ERROR_TEMPORARY,
|
|
"message": err_msg,
|
|
"status_code": "OAUTH_TOKEN_FAILURE",
|
|
"delay": self._calculate_next_backoff_delay(),
|
|
"consecutive_errors": self._consecutive_api_errors,
|
|
}
|
|
else:
|
|
module_logger.warning(
|
|
f"{self.name}: Attempting anonymous access due to OAuth token failure."
|
|
)
|
|
self.use_oauth = False
|
|
|
|
if self.use_oauth and self.access_token:
|
|
headers = {"Authorization": f"Bearer {self.access_token}"}
|
|
response = requests.get(
|
|
app_config.OPENSKY_API_URL,
|
|
params=params,
|
|
headers=headers,
|
|
timeout=self.api_timeout,
|
|
)
|
|
response.raise_for_status()
|
|
raw_json_str = response.text # Capture raw text
|
|
json_response = response.json()
|
|
raw_states_list = json_response.get("states")
|
|
if raw_states_list is not None:
|
|
for raw_state_vector in raw_states_list:
|
|
cs = self._convert_direct_api_state_to_canonical(
|
|
raw_state_vector
|
|
)
|
|
if cs:
|
|
canonical_states.append(cs)
|
|
module_logger.info(
|
|
f"{self.name}: Fetched {len(canonical_states)} states via OAuth2."
|
|
)
|
|
|
|
else:
|
|
if not self.api_client_anonymous:
|
|
module_logger.critical(
|
|
f"{self.name}: Anonymous API client not initialized! Cannot fetch data."
|
|
)
|
|
return {
|
|
"error_type": STATUS_PERMANENT_FAILURE,
|
|
"message": "Anonymous client not initialized.",
|
|
"status_code": "CLIENT_INIT_ERROR",
|
|
"delay": 0.0,
|
|
"consecutive_errors": self._consecutive_api_errors,
|
|
}
|
|
|
|
api_bbox_tuple = (
|
|
self.bounding_box["lat_min"],
|
|
self.bounding_box["lat_max"],
|
|
self.bounding_box["lon_min"],
|
|
self.bounding_box["lon_max"],
|
|
)
|
|
states_response_lib = self.api_client_anonymous.get_states(
|
|
bbox=api_bbox_tuple
|
|
)
|
|
if states_response_lib:
|
|
if states_response_lib.states_response and hasattr(
|
|
states_response_lib.states_response, "json_data"
|
|
):
|
|
raw_json_str = json.dumps(
|
|
states_response_lib.states_response.json_data
|
|
) # Convert dict to string
|
|
if states_response_lib.states:
|
|
for sv_lib in states_response_lib.states:
|
|
cs = self._convert_opensky_api_state_to_canonical(sv_lib)
|
|
if cs:
|
|
canonical_states.append(cs)
|
|
module_logger.info(
|
|
f"{self.name}: Fetched {len(canonical_states)} states via anonymous access (opensky-api lib)."
|
|
)
|
|
|
|
self._reset_error_state()
|
|
return {"canonical_data": canonical_states, "raw_json_data": raw_json_str}
|
|
|
|
except requests.exceptions.HTTPError as http_err:
|
|
self._consecutive_api_errors += 1
|
|
self._in_backoff_mode = True
|
|
status_code = http_err.response.status_code if http_err.response else "N/A"
|
|
err_msg = f"HTTP error {status_code}: {http_err}. Errors: {self._consecutive_api_errors}."
|
|
delay = self._calculate_next_backoff_delay()
|
|
if status_code in [401, 403]:
|
|
err_msg += " Check OAuth credentials/token or API key if used."
|
|
if self.use_oauth:
|
|
self.access_token = None
|
|
module_logger.warning(
|
|
f"{self.name}: OAuth token might be invalid/expired (HTTP {status_code}). Forcing refresh."
|
|
)
|
|
module_logger.error(
|
|
f"{self.name}: {err_msg} Retrying in {delay:.1f}s.", exc_info=False
|
|
)
|
|
return {
|
|
"error_type": STATUS_API_ERROR_TEMPORARY,
|
|
"status_code": status_code,
|
|
"message": err_msg,
|
|
"delay": delay,
|
|
"consecutive_errors": self._consecutive_api_errors,
|
|
}
|
|
except requests.exceptions.Timeout:
|
|
self._consecutive_api_errors += 1
|
|
self._in_backoff_mode = True
|
|
delay = self._calculate_next_backoff_delay()
|
|
err_msg = f"API request timed out. Errors: {self._consecutive_api_errors}. Retrying in {delay:.1f}s."
|
|
module_logger.error(f"{self.name}: {err_msg}", exc_info=False)
|
|
return {
|
|
"error_type": STATUS_API_ERROR_TEMPORARY,
|
|
"status_code": "TIMEOUT",
|
|
"message": err_msg,
|
|
"delay": delay,
|
|
"consecutive_errors": self._consecutive_api_errors,
|
|
}
|
|
except requests.exceptions.RequestException as req_err:
|
|
self._consecutive_api_errors += 1
|
|
self._in_backoff_mode = True
|
|
delay = self._calculate_next_backoff_delay()
|
|
err_msg = f"Network request error: {req_err}. Errors: {self._consecutive_api_errors}. Retrying in {delay:.1f}s."
|
|
module_logger.error(f"{self.name}: {err_msg}", exc_info=True)
|
|
return {
|
|
"error_type": STATUS_API_ERROR_TEMPORARY,
|
|
"status_code": "REQUEST_EXCEPTION",
|
|
"message": err_msg,
|
|
"delay": delay,
|
|
"consecutive_errors": self._consecutive_api_errors,
|
|
}
|
|
except Exception as e:
|
|
self._consecutive_api_errors += 1
|
|
self._in_backoff_mode = True
|
|
delay = self._calculate_next_backoff_delay()
|
|
err_msg = f"Unexpected error during API request: {e}. Errors: {self._consecutive_api_errors}. Retrying in {delay:.1f}s."
|
|
module_logger.critical(f"{self.name}: {err_msg}", exc_info=True)
|
|
return {
|
|
"error_type": STATUS_API_ERROR_TEMPORARY,
|
|
"status_code": "UNEXPECTED_ERROR",
|
|
"message": err_msg,
|
|
"delay": delay,
|
|
"consecutive_errors": self._consecutive_api_errors,
|
|
}
|
|
|
|
def _calculate_next_backoff_delay(
|
|
self, provided_retry_after: Optional[str] = None
|
|
) -> float:
|
|
calculated_delay = INITIAL_BACKOFF_DELAY_SECONDS * (
|
|
BACKOFF_FACTOR ** (self._consecutive_api_errors - 1)
|
|
)
|
|
api_delay_from_header = 0.0
|
|
if provided_retry_after and provided_retry_after.isdigit():
|
|
try:
|
|
api_delay_from_header = float(provided_retry_after)
|
|
except ValueError:
|
|
api_delay_from_header = 0.0
|
|
self._current_backoff_delay = max(api_delay_from_header, calculated_delay)
|
|
self._current_backoff_delay = min(
|
|
self._current_backoff_delay, MAX_BACKOFF_DELAY_SECONDS
|
|
)
|
|
self._current_backoff_delay = max(self._current_backoff_delay, 1.0)
|
|
return self._current_backoff_delay
|
|
|
|
def _reset_error_state(self):
|
|
if self._in_backoff_mode or self._consecutive_api_errors > 0:
|
|
module_logger.info(
|
|
f"{self.name}: API connection successful after {self._consecutive_api_errors} error(s). Resetting backoff."
|
|
)
|
|
self._send_status_to_queue(STATUS_RECOVERED, "API connection recovered.")
|
|
self._consecutive_api_errors = 0
|
|
self._current_backoff_delay = 0.0
|
|
self._in_backoff_mode = False
|
|
|
|
def run(self):
|
|
initial_settle_delay_seconds = 0.1
|
|
if self._stop_event.wait(timeout=initial_settle_delay_seconds):
|
|
module_logger.info(
|
|
f"{self.name}: Stop signal during initial settle. Terminating."
|
|
)
|
|
return
|
|
|
|
module_logger.info(
|
|
f"{self.name}: Thread operational. Polling interval: {self.polling_interval:.1f}s. OAuth mode: {self.use_oauth}"
|
|
)
|
|
self._send_status_to_queue(
|
|
STATUS_STARTING, "Adapter thread started, preparing initial fetch."
|
|
)
|
|
|
|
if self.use_oauth and not self._get_oauth_token():
|
|
module_logger.error(
|
|
f"{self.name}: Initial OAuth token fetch failed. Adapter may not function correctly or will use anonymous if fallback is enabled."
|
|
)
|
|
|
|
while not self._stop_event.is_set():
|
|
if self._consecutive_api_errors >= MAX_CONSECUTIVE_ERRORS_THRESHOLD:
|
|
perm_fail_msg = f"Reached max ({self._consecutive_api_errors}) API errors. Stopping live updates."
|
|
module_logger.critical(f"{self.name}: {perm_fail_msg}")
|
|
self._send_status_to_queue(STATUS_PERMANENT_FAILURE, perm_fail_msg)
|
|
break
|
|
|
|
api_result = self._perform_api_request()
|
|
|
|
if self._stop_event.is_set():
|
|
module_logger.info(
|
|
f"{self.name}: Stop event after API request. Exiting."
|
|
)
|
|
break
|
|
|
|
# MODIFICATO: Gestisce la nuova struttura del dizionario di ritorno
|
|
if "canonical_data" in api_result:
|
|
canonical_payload = api_result["canonical_data"]
|
|
raw_json_payload = api_result.get(
|
|
"raw_json_data", "{}"
|
|
) # Default a JSON vuoto
|
|
|
|
# Prepara il payload strutturato per la coda
|
|
queue_payload = {
|
|
"canonical": canonical_payload,
|
|
"raw_json": raw_json_payload,
|
|
}
|
|
|
|
try:
|
|
self.output_queue.put_nowait(
|
|
{"type": MSG_TYPE_FLIGHT_DATA, "payload": queue_payload}
|
|
)
|
|
except QueueFull:
|
|
module_logger.warning(
|
|
f"{self.name}: Output queue full. Discarding data packet."
|
|
)
|
|
except Exception as e:
|
|
module_logger.error(
|
|
f"{self.name}: Error putting data to queue: {e}", exc_info=True
|
|
)
|
|
|
|
elif "error_type" in api_result:
|
|
self._send_status_to_queue(
|
|
api_result["error_type"],
|
|
api_result.get("message", "API error."),
|
|
api_result,
|
|
)
|
|
else:
|
|
module_logger.error(
|
|
f"{self.name}: Unknown API result structure: {api_result}"
|
|
)
|
|
|
|
wait_time = (
|
|
self._current_backoff_delay
|
|
if self._in_backoff_mode
|
|
else self.polling_interval
|
|
)
|
|
if self._stop_event.wait(timeout=wait_time):
|
|
module_logger.info(f"{self.name}: Stop event during wait. Exiting.")
|
|
break
|
|
|
|
module_logger.info(
|
|
f"{self.name}: Exited main loop. Sending final STOPPED status."
|
|
)
|
|
self._send_status_to_queue(STATUS_STOPPED, "Adapter thread stopped.")
|
|
module_logger.info(f"{self.name}: RUN method terminating.")
|
|
|
|
def _generate_mock_flight_state(self, icao_suffix: int) -> CanonicalFlightState:
|
|
now = time.time()
|
|
lat_center = (self.bounding_box["lat_min"] + self.bounding_box["lat_max"]) / 2
|
|
lon_center = (self.bounding_box["lon_min"] + self.bounding_box["lon_max"]) / 2
|
|
lat_span = self.bounding_box["lat_max"] - self.bounding_box["lat_min"]
|
|
lon_span = self.bounding_box["lon_max"] - self.bounding_box["lon_min"]
|
|
return CanonicalFlightState(
|
|
icao24=f"mock{icao_suffix:02x}",
|
|
callsign=f"MOCK{icao_suffix:02X}",
|
|
origin_country="Mockland",
|
|
timestamp=now,
|
|
last_contact_timestamp=now,
|
|
latitude=round(
|
|
lat_center + random.uniform(-lat_span / 2.1, lat_span / 2.1), 4
|
|
),
|
|
longitude=round(
|
|
lon_center + random.uniform(-lon_span / 2.1, lon_span / 2.1), 4
|
|
),
|
|
baro_altitude_m=random.uniform(1000, 12000),
|
|
geo_altitude_m=random.uniform(1000, 12000) + random.uniform(-200, 200),
|
|
on_ground=random.choice([True, False]),
|
|
velocity_mps=random.uniform(50, 250),
|
|
true_track_deg=random.uniform(0, 360),
|
|
vertical_rate_mps=random.uniform(-10, 10),
|
|
squawk=str(random.randint(1000, 7777)),
|
|
spi=random.choice([True, False]),
|
|
position_source=0,
|
|
raw_data_provider=f"{PROVIDER_NAME}-Mock",
|
|
)
|