diff --git a/shinysdr/i/webstatic/client/widgets/basic.js b/shinysdr/i/webstatic/client/widgets/basic.js
index 336ca005..eb7d9590 100644
--- a/shinysdr/i/webstatic/client/widgets/basic.js
+++ b/shinysdr/i/webstatic/client/widgets/basic.js
@@ -301,6 +301,7 @@ define([
update(value, draw);
});
}
+ exports.SimpleElementWidget = SimpleElementWidget;
function Generic(config) {
SimpleElementWidget.call(this, config, undefined,
diff --git a/shinysdr/plugins/flightradar24/__init__.py b/shinysdr/plugins/flightradar24/__init__.py
new file mode 100644
index 00000000..b73d680d
--- /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
+
+from collections import namedtuple
+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
+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
+_BASE_URL = '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'
+
+
+def Flightradar24(reactor, key='flightradar24', bounds=None, base_url=_BASE_URL):
+ """Create a flightradar24 client.
+
+ key: Component ID.
+ bounds: optional 4-element tuple of (lat1, lat2, lon1, lon2) to restrict search
+ base_url: optional URL to override the source of the data feed
+ """
+ return Device(components={six.text_type(key): _Flightradar24Client(
+ reactor=reactor,
+ bounds=bounds,
+ base_url=base_url)})
+
+
+@implementer(IComponent)
+class _Flightradar24Client(ExportedState):
+ def __init__(self, reactor, bounds, base_url=_BASE_URL):
+ self.__reactor = reactor
+ self.__agent = Agent(reactor)
+ self.__bounds = bounds
+ self.__device_contexts = []
+ self.__loop = None
+ self.__url = URL.fromText(base_url)
+
+ @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 attach_context(self, device_context):
+ """implements IComponent"""
+ self.__device_contexts.append(device_context)
+
+ def __make_url(self):
+ u = self.__url
+ 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.__device_contexts:
+ 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
+ for c in self.__device_contexts:
+ c.output_message(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
+
+
+FlightInfo = namedtuple('FlightInfo', [
+ 'callsign', # ICAO ATC call signature
+ 'registration',
+ 'origin', # airport IATA code
+ 'destination', # airport IATA code
+ 'flight',
+ 'squawk_code', # https://en.wikipedia.org/wiki/Transponder_(aeronautics)
+ 'model', # ICAO aircraft type designator
+])
+
+
+empty_flight_info = FlightInfo(
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+)
+
+
+@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.__flight_info = empty_flight_info
+
+ # 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.__flight_info = FlightInfo(
+ callsign=callsign,
+ registration=registration,
+ origin=origin,
+ destination=destination,
+ flight=flight,
+ squawk_code=squawk_code,
+ 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.__flight_info.callsign is not None or \
+ self.__flight_info.registration 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=FlightInfo, changes='explicit', sort_key='020')
+ def get_flight_info(self):
+ return self.__flight_info
+
+ @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..76dc8c07
--- /dev/null
+++ b/shinysdr/plugins/flightradar24/client/flightradar24.js
@@ -0,0 +1,113 @@
+// 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 {
+ SimpleElementWidget,
+ Block,
+ } = import_widgets_basic;
+
+ const exports = {};
+
+ function FlightInfoWidget(config) {
+ SimpleElementWidget.call(this, config, 'DIV',
+ function buildPanelForFlightInfo(container) {
+ return container.appendChild(document.createElement('DIV'));
+ },
+ function initEl(valueEl, target) {
+ /*
+ BOX451 / 3S451 (JFK / FRA)
+ B77L D-AALB
+ Squawk 1714
+ */
+ function addDiv() {
+ let div = valueEl.appendChild(document.createElement('div'));
+ let textNode = document.createTextNode('');
+ div.appendChild(textNode);
+ return textNode;
+ }
+ var row1 = addDiv();
+ var row2 = addDiv();
+ var row3 = addDiv();
+ return function updateEl(flight_info) {
+ row1.data = `${flight_info.callsign} / ${flight_info.flight}`;
+ if (flight_info.origin || flight_info.destination) {
+ row1.data += ` (${flight_info.origin || '???'} / ${flight_info.destination || '???'})`;
+ }
+ row2.data = `${flight_info.model} ${flight_info.registration}`;
+ row3.data = `Squawk ${flight_info.squawk_code}`;
+ };
+ });
+ }
+
+ function AircraftWidget(config) {
+ Block.call(this, config, function (block, addWidget, ignore, setInsertion, setToDetails, getAppend) {
+ addWidget('track', widgets.TrackWidget);
+ addWidget('flight_info', FlightInfoWidget);
+ }, 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) {
+ let trackCell = aircraft.track;
+ let flight_info = aircraft.flight_info.depend(dirty);
+ let callsign = flight_info.callsign;
+ let ident = flight_info.squawk_code;
+ let 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);
+});