From 9a19982dc37d61b86cd802c474b00a1a8530df33 Mon Sep 17 00:00:00 2001 From: Quentin Smith Date: Mon, 10 Dec 2018 02:50:31 -0500 Subject: [PATCH] Add flightradar24 client. This renders aircraft from flightradar24 much like the mode_s plugin. --- shinysdr/plugins/flightradar24/__init__.py | 267 ++++++++++++++++++ .../plugins/flightradar24/client/aircraft.svg | 37 +++ .../flightradar24/client/flightradar24.js | 80 ++++++ 3 files changed, 384 insertions(+) create mode 100644 shinysdr/plugins/flightradar24/__init__.py create mode 100644 shinysdr/plugins/flightradar24/client/aircraft.svg create mode 100644 shinysdr/plugins/flightradar24/client/flightradar24.js diff --git a/shinysdr/plugins/flightradar24/__init__.py b/shinysdr/plugins/flightradar24/__init__.py new file mode 100644 index 00000000..5147926f --- /dev/null +++ b/shinysdr/plugins/flightradar24/__init__.py @@ -0,0 +1,267 @@ +# Copyright 2013, 2014, 2015, 2016, 2017, 2018 Kevin Reid and the ShinySDR contributors +# +# This file is part of ShinySDR. +# +# ShinySDR is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# ShinySDR is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with ShinySDR. If not, see . + +# pylint: disable=maybe-no-member, no-member +# (maybe-no-member: GR swig) +# (no-member: Twisted reactor) + +from __future__ import absolute_import, division, print_function, unicode_literals + +import json +import os.path + +import six + +from twisted.internet import task +from twisted.python.url import URL +from twisted.web import static +from twisted.web.client import Agent, readBody +from zope.interface import Interface, implementer + +from shinysdr.devices import Device, IComponent, ITopWatcher +from shinysdr.interfaces import ClientResourceDef +from shinysdr.telemetry import ITelemetryMessage, ITelemetryObject, TelemetryItem, Track, empty_track +from shinysdr.types import TimestampT +from shinysdr.values import ExportedState, exported_value, setter + +_POLLING_INTERVAL = 8 +drop_unheard_timeout_seconds = 60 + + +_SECONDS_PER_HOUR = 60 * 60 +_METERS_PER_NAUTICAL_MILE = 1852 +_KNOTS_TO_METERS_PER_SECOND = _METERS_PER_NAUTICAL_MILE / _SECONDS_PER_HOUR +_CM_PER_INCH = 2.54 +_INCH_PER_FOOT = 12 +_METERS_PER_FEET = (_CM_PER_INCH * _INCH_PER_FOOT) / 100 +_FEET_PER_MINUTE_TO_METERS_PER_SECOND = _METERS_PER_FEET * 60 + + +# TODO: This really shouldn't be a Device, but that's the only way right now to hook into the config. +def Flightradar24(reactor, key='flightradar24', bounds=None): + """Create a flightradar24 client. + + key: Component ID. + bounds: optional 4-element tuple of (lat1, lat2, lon1, lon2) to restrict search + """ + return Device(components={six.text_type(key): _Flightradar24Client( + reactor=reactor, + bounds=bounds)}) + + +@implementer(IComponent, ITopWatcher) +class _Flightradar24Client(ExportedState): + def __init__(self, reactor, bounds): + self.__reactor = reactor + self.__agent = Agent(reactor) + self.__bounds = bounds + self.__top = None + self.__loop = None + + @exported_value(type=bool, changes='this_setter', label='Enabled') + def get_enabled(self): + return self.__loop is not None + + @setter + def set_enabled(self, enabled): + if enabled and not self.__loop: + self.__loop = task.LoopingCall(self.__send_request) + self.__loop.clock = self.__reactor + self.__loop.start(_POLLING_INTERVAL).addErrback(print) + elif not enabled and self.__loop: + self.__loop.stop() + self.__loop = None + + def close(self): + if self.__loop: + self.__loop.stop() + self.__loop = None + + def set_top(self, top): + self.__top = top + + def __make_url(self): + u = URL.fromText('https://data-live.flightradar24.com/zones/fcgi/feed.js?faa=1&mlat=1&flarm=1&adsb=1&gnd=0&air=1&vehicles=0&estimated=1&maxage=14400&gliders=1&stats=0') + if self.__bounds: + u = u.set('bounds', ','.join(str(b) for b in self.__bounds)) + return six.binary_type(u.asText()) + + def __send_request(self): + if not self.__top: + return + d = self.__agent.request(six.binary_type('GET'), self.__make_url()) + d.addCallback(readBody) + + def process(body): + data = json.loads(body) + for object_id, aircraft in six.iteritems(data): + if not isinstance(aircraft, list): + continue + self.__top.get_telemetry_store().receive(AircraftWrapper(object_id, aircraft)) + d.addCallback(process) + d.addErrback(print) + + +@implementer(ITelemetryMessage) +class AircraftWrapper(object): + def __init__(self, object_id, message): + self.object_id = object_id + self.message = message # list + + def get_object_id(self): + # TODO: add prefix to ensure uniqueness? + return self.object_id + + def get_object_constructor(self): + return Aircraft + + +class IAircraft(Interface): + """marker interface for client""" + pass + + +@implementer(IAircraft, ITelemetryObject) +class Aircraft(ExportedState): + def __init__(self, object_id): + """Implements ITelemetryObject. object_id is the hex formatted address.""" + self.__last_heard_time = None + self.__track = empty_track + self.__callsign = None + self.__registration = None + self.__origin = None + self.__destination = None + self.__flight = None + self.__squawk_code = None + self.__model = None + + # not exported + def receive(self, message_wrapper): + d = message_wrapper.message + # Fields from https://github.com/derhuerst/flightradar24-client/blob/master/lib/radar.js + + timestamp = d[10] + + # Part of self.__track + latitude = d[1] + longitude = d[2] + altitude = d[4] # in feet + bearing = d[3] # in degrees + speed = d[5] # in knots + rate_of_climb = d[15] # ft/min + + # Shown separately + callsign = d[16] # ICAO ATC call signature + registration = d[9] + origin = d[11] # airport IATA code + destination = d[12] # airport IATA code + flight = d[13] + squawk_code = d[6] # https://en.wikipedia.org/wiki/Transponder_(aeronautics) + model = d[8] # ICAO aircraft type designator + + # Unused + #is_on_ground = bool(d[14]) + #mode_s_code = d[0] # // ICAO aircraft registration number + #radar = d[7] # F24 "radar" data source ID + #is_glider = bool(d[17]) + + new = {} + if latitude and longitude: + new.update( + latitude=TelemetryItem(latitude, timestamp), + longitude=TelemetryItem(longitude, timestamp), + ) + if altitude: + new.update(altitude=TelemetryItem(altitude * _METERS_PER_FEET, timestamp)) + if speed: + new.update(h_speed=TelemetryItem(speed * _KNOTS_TO_METERS_PER_SECOND, timestamp)) + if bearing: + new.update( + heading=TelemetryItem(bearing, timestamp), + track_angle=TelemetryItem(bearing, timestamp), + ) + if rate_of_climb: + new.update(v_speed=TelemetryItem(rate_of_climb * _FEET_PER_MINUTE_TO_METERS_PER_SECOND, timestamp)) + if new: + self.__track = self.__track._replace(**new) + + self.__last_heard_time = timestamp + self.__callsign = callsign + self.__registration = registration + self.__origin = origin + self.__destination = destination + self.__flight = flight + self.__squawk_code = squawk_code + self.__model = model + self.state_changed() + + def is_interesting(self): + """ + Implements ITelemetryObject. Does this aircraft have enough information to be worth mentioning? + """ + # TODO: Loosen this rule once we have more efficient state transfer (no polling) and better UI for viewing them on the client. + return \ + self.__track.latitude.value is not None or \ + self.__track.longitude.value is not None or \ + self.__call is not None or \ + self.__aircraft_type is not None + + def get_object_expiry(self): + """implement ITelemetryObject""" + return self.__last_heard_time + drop_unheard_timeout_seconds + + @exported_value(type=TimestampT(), changes='explicit', sort_key='100', label='Last heard') + def get_last_heard_time(self): + return self.__last_heard_time + + @exported_value(type=six.text_type, changes='explicit', sort_key='020', label='Callsign') + def get_callsign(self): + return self.__callsign + + @exported_value(type=six.text_type, changes='explicit', sort_key='030', label='Registration') + def get_registration(self): + return self.__registration + + @exported_value(type=six.text_type, changes='explicit', sort_key='040', label='Origin') + def get_origin(self): + return self.__origin + + @exported_value(type=six.text_type, changes='explicit', sort_key='050', label='Destination') + def get_destination(self): + return self.__destination + + @exported_value(type=six.text_type, changes='explicit', sort_key='060', label='Flight') + def get_flight(self): + return self.__flight + + @exported_value(type=six.text_type, changes='explicit', sort_key='070', label='Squawk Code') + def get_squawk_code(self): + return self.__squawk_code + + @exported_value(type=six.text_type, changes='explicit', sort_key='080', label='Model') + def get_model(self): + return self.__model + + @exported_value(type=Track, changes='explicit', sort_key='010', label='') + def get_track(self): + return self.__track + + +plugin_client = ClientResourceDef( + key=__name__, + resource=static.File(os.path.join(os.path.split(__file__)[0], 'client')), + load_js_path='flightradar24.js') diff --git a/shinysdr/plugins/flightradar24/client/aircraft.svg b/shinysdr/plugins/flightradar24/client/aircraft.svg new file mode 100644 index 00000000..6710277f --- /dev/null +++ b/shinysdr/plugins/flightradar24/client/aircraft.svg @@ -0,0 +1,37 @@ + + + + + + + diff --git a/shinysdr/plugins/flightradar24/client/flightradar24.js b/shinysdr/plugins/flightradar24/client/flightradar24.js new file mode 100644 index 00000000..7ba8fa7c --- /dev/null +++ b/shinysdr/plugins/flightradar24/client/flightradar24.js @@ -0,0 +1,80 @@ +// Copyright 2014, 2015, 2016, 2017 Kevin Reid and the ShinySDR contributors +// +// This file is part of ShinySDR. +// +// ShinySDR is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ShinySDR is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with ShinySDR. If not, see . + +'use strict'; + +define([ + 'require', + 'map/map-core', + 'widgets', + 'widgets/basic', +], ( + require, + import_map_core, + widgets, + import_widgets_basic +) => { + const { + register, + renderTrackFeature, + } = import_map_core; + const { + Block, + } = import_widgets_basic; + + const exports = {}; + + function AircraftWidget(config) { + Block.call(this, config, function (block, addWidget, ignore, setInsertion, setToDetails, getAppend) { + addWidget('track', widgets.TrackWidget); + // TODO: More compact rendering of cells. + }, false); + } + + // TODO: Better widget-plugin system so we're not modifying should-be-static tables + widgets['interface:shinysdr.plugins.flightradar24.IAircraft'] = AircraftWidget; + + function addAircraftMapLayer(mapPluginConfig) { + mapPluginConfig.addLayer('flightradar24', { + featuresCell: mapPluginConfig.index.implementing('shinysdr.plugins.flightradar24.IAircraft'), + featureRenderer: function renderAircraft(aircraft, dirty) { + var trackCell = aircraft.track; + var callsign = aircraft.callsign.depend(dirty); + var ident = aircraft.squawk_code.depend(dirty); + var altitude = trackCell.depend(dirty).altitude.value; + var labelParts = []; + if (callsign !== null) { + labelParts.push(callsign.replace(/^ | $/g, '')); + } + if (ident !== null) { + labelParts.push(ident); + } + if (altitude !== null) { + labelParts.push(altitude.toFixed(0) + ' m'); + } + var f = renderTrackFeature(dirty, trackCell, + labelParts.join(' • ')); + f.iconURL = require.toUrl('./aircraft.svg'); + return f; + } + }); + } + + register(addAircraftMapLayer); + + return Object.freeze(exports); +});