#!/usr/bin/env python3 # SPDX-License-Identifier: MIT """Forward direct observations from an existing local ADS-B decoder to Skylark. Runs beside whatever already feeds from the receiver (readsb, dump1090-fa, ultrafeeder, adsb.im, FlightAware, FR24 and friends). It only reads the decoder's aircraft.json, from a file or a loopback web server, and never opens the radio. --pair get a pairing code, wait for it to be entered on skylark.watch/pair, and write the private config file --doctor check every step from the decoder to the station and say what to fix --detect list the decoder outputs this machine has --check read the decoder once and send nothing --once send one snapshot and stop """ from __future__ import annotations import argparse import contextlib import json import math import os from pathlib import Path import platform import random import stat import sys import time import uuid from urllib import request, error, parse VERSION = '0.3.1' MAX_FILE = 4_000_000 DEFAULT_SERVER = 'https://skylark.watch' FIELDS = ('hex', 'type', 'flight', 'lat', 'lon', 'alt_baro', 'alt_geom', 'gs', 'track', 'baro_rate', 'seen_pos', 'squawk') SUMMARY_SECONDS = 600 LOOPBACK = ('127.0.0.1', 'localhost', '::1') # Where the common decoders and feeder images write aircraft.json, most specific # first. Files are read directly; the web addresses are the loopback copies that # containerised setups (ultrafeeder, adsb.im) and lighttpd installs publish. CANDIDATES = ( '/run/readsb/aircraft.json', '/run/adsb-feeder-ultrafeeder/readsb/aircraft.json', '/run/dump1090-fa/aircraft.json', '/run/dump1090/aircraft.json', '/run/dump1090-mutability/aircraft.json', '/run/shm/aircraft.json', 'http://127.0.0.1/tar1090/data/aircraft.json', 'http://127.0.0.1:8080/data/aircraft.json', 'http://127.0.0.1/skyaware/data/aircraft.json', 'http://127.0.0.1:8080/skyaware/data/aircraft.json', 'http://127.0.0.1/dump1090/data/aircraft.json', ) class FeedError(Exception): pass class NoRedirect(request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): raise FeedError('Upload redirects are not followed; check the configured endpoint.') def finite(value): return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) def loopback_url(value) -> bool: """A decoder web address is only ever this machine: a URL is not a way to forward someone else's feed.""" if not isinstance(value, str): return False url = parse.urlsplit(value) return url.scheme == 'http' and url.hostname in LOOPBACK and not url.username and not url.password and not url.query and not url.fragment and url.path.endswith('.json') def source_setting(data: dict): """The configured decoder output: a Path, a loopback URL string, or None for automatic detection.""" if not isinstance(data, dict): raise FeedError('Expected a configuration JSON object.') if not isinstance(data, dict): raise FeedError('Expected a configuration JSON object.') if 'aircraftUrl' in data: if not loopback_url(data['aircraftUrl']): raise FeedError('aircraftUrl must be a http://127.0.0.1 or http://localhost address ending in .json.') return data['aircraftUrl'] if 'aircraftFile' not in data or data['aircraftFile'] == 'auto': return None if not isinstance(data['aircraftFile'], str): raise FeedError('aircraftFile must be an absolute local filename.') if not isinstance(data['aircraftFile'], str): raise FeedError('aircraftFile must be an absolute local filename.') source = Path(data['aircraftFile']) if not source.is_absolute(): raise FeedError('aircraftFile must be an absolute local filename.') return source def check_endpoint(value: str, allow_local: bool = False) -> str: if not isinstance(value, str): raise FeedError('Use an HTTPS endpoint ending in /api/network/ingest.') if not isinstance(value, str): raise FeedError('Use an HTTPS endpoint ending in /api/network/ingest.') url = parse.urlsplit(value or '') local = allow_local and url.hostname in LOOPBACK and url.scheme == 'http' if (url.scheme != 'https' and not local) or not url.netloc or url.username or url.password or url.query or url.fragment or url.path != '/api/network/ingest': raise FeedError('Use an HTTPS endpoint ending in /api/network/ingest.') return value def config(path: Path, allow_local: bool = False) -> dict: if os.name != 'nt' and stat.S_IMODE(path.stat().st_mode) & 0o077: raise FeedError('The config contains a credential. Set its permissions to 600 or 400.') data = json.loads(path.read_text()) if not isinstance(data, dict): raise FeedError('Expected a configuration JSON object.') if not isinstance(data, dict): raise FeedError('Expected a configuration JSON object.') check_endpoint(data.get('endpoint', ''), allow_local) key = data.get('feedKey', '') if not isinstance(key, str) or not key.startswith('sk_feed_') or len(key) != 51: raise FeedError('A valid station feed credential is required.') interval = data.get('intervalSeconds', 10) if not finite(interval) or not 5 <= interval <= 300: raise FeedError('intervalSeconds must be between 5 and 300.') return {'endpoint': data['endpoint'], 'feedKey': key, 'aircraftFile': source_setting(data), 'intervalSeconds': interval} def make_batch(document: dict, now: float | None = None) -> dict: now = time.time() if now is None else now captured = document.get('now') if not finite(captured) or captured > now + 5 or now - captured > 30: raise FeedError('Decoder snapshot is stale or its clock is incorrect. No data sent.') aircraft = document.get('aircraft') if not isinstance(aircraft, list): raise FeedError('Decoder file does not contain an aircraft array.') rows = [] for item in aircraft: if not isinstance(item, dict) or item.get('type') != 'adsb_icao' or item.get('mlat') or item.get('tisb'): continue age = item.get('seen_pos') if not finite(age) or age < 0 or now - captured + age > 45: continue if not finite(item.get('lat')) or not finite(item.get('lon')): continue rows.append({key: item[key] for key in FIELDS if key in item}) rows.sort(key=lambda row: row['seen_pos']) return {'schemaVersion': 1, 'batchId': uuid.uuid4().hex, 'capturedAt': round(captured * 1000), 'kind': 'adsb-direct', 'aircraft': rows[:500]} def bounded_read(handle) -> bytes: data = handle.read(MAX_FILE + 1) if len(data) > MAX_FILE: raise FeedError('Decoder file exceeds the 4 MB safety limit.') return data def read_snapshot(path) -> dict: if isinstance(path, str) and path.startswith('http://'): if not loopback_url(path): raise FeedError('Only a loopback decoder address can be read.') # Local decoder traffic must stay local even when a receiver has an # HTTP_PROXY setting and no NO_PROXY exemption for loopback. opener = request.build_opener(request.ProxyHandler({}), NoRedirect()) with opener.open(request.Request(path, headers={'User-Agent': 'SkylarkFeeder/' + VERSION}), timeout=4) as response: data = bounded_read(response) else: with Path(path).open('rb') as handle: data = bounded_read(handle) value = json.loads(data) if not isinstance(value, dict): raise FeedError('Expected a decoder JSON object.') return value def describe(document: dict, now: float | None = None) -> dict: """What a snapshot holds, in the terms the doctor explains.""" now = time.time() if now is None else now aircraft = document.get('aircraft') if isinstance(document.get('aircraft'), list) else [] captured = document.get('now') rows = [a for a in aircraft if isinstance(a, dict)] typed = sum(1 for a in rows if 'type' in a) return { 'ageSeconds': round(now - captured, 1) if finite(captured) else None, 'aircraft': len(rows), 'withPosition': sum(1 for a in rows if finite(a.get('lat')) and finite(a.get('lon'))), 'mlat': sum(1 for a in rows if a.get('mlat') or a.get('type') == 'mlat'), 'tisb': sum(1 for a in rows if a.get('tisb') or str(a.get('type', '')).startswith('tisb')), 'reportsSourceType': typed > 0 or not rows, } def detect(candidates=CANDIDATES, now: float | None = None, stop_after_usable=False) -> list: """Every decoder output this machine has, and whether the forwarder can use it.""" found = [] for source in candidates: if not source.startswith('http://') and not Path(source).exists(): continue entry = {'source': source, 'usable': False} try: document = read_snapshot(source) except (OSError, ValueError, FeedError) as exc: if source.startswith('http://'): continue entry['problem'] = 'unreadable' if isinstance(exc, PermissionError) else 'not decoder JSON' found.append(entry) continue facts = describe(document, now) entry.update(facts) try: entry['directPositions'] = len(make_batch(document, now)['aircraft']) entry['usable'] = facts['reportsSourceType'] if not entry['usable']: entry['problem'] = 'decoder does not label direct ADS-B positions (upgrade to readsb or dump1090-fa 4+)' except FeedError: entry['problem'] = 'snapshot is stale: the decoder is not running, or the clock is wrong' found.append(entry) if stop_after_usable and entry['usable']: break return found def resolve_source(settings: dict): if settings['aircraftFile'] is not None: return settings['aircraftFile'] if settings.get('_resolvedSource') is not None: return settings['_resolvedSource'] for entry in detect(stop_after_usable=True): if entry['usable']: settings['_resolvedSource'] = entry['source'] return entry['source'] raise FeedError('No running decoder found. Run the doctor for details.') def local_batch(source, require_labels=False) -> dict: document = read_snapshot(source) batch = make_batch(document) if require_labels and not describe(document)['reportsSourceType']: raise FeedError('Decoder does not label direct ADS-B positions. Use readsb or dump1090-fa 4+.') return batch def next_batch(settings: dict) -> dict: """Reuse an automatic source until it fails, then find a running decoder again.""" automatic = settings['aircraftFile'] is None cached = automatic and settings.get('_resolvedSource') is not None try: batch = local_batch(resolve_source(settings), require_labels=automatic) except (OSError, ValueError, FeedError): if not cached: raise settings.pop('_resolvedSource', None) return local_batch(resolve_source(settings), require_labels=True) return batch def check_local(args) -> int: """A receiver owner can inspect compatibility before creating a station.""" try: if args.source: source = source_setting({'aircraftUrl' if args.source.startswith('http://') else 'aircraftFile': args.source}) elif args.config.exists(): # Only read the source preference. Enrollment, a feed key and an # HTTPS endpoint are unnecessary for this entirely local check. source = source_setting(json.loads(args.config.read_text())) else: source = None settings = {'aircraftFile': source} batch = local_batch(resolve_source(settings), require_labels=True) print(json.dumps({'ok': True, 'directPositions': len(batch['aircraft']), 'transmitted': False})) except (OSError, ValueError, FeedError) as exc: detail = str(exc) if isinstance(exc, FeedError) else 'could not read the local decoder or source configuration' print('Local check failed: ' + detail + '.', file=sys.stderr) return 4 return 0 def post_json(url: str, value: dict, timeout: int = 12, headers: dict | None = None) -> dict: req = request.Request(url, data=json.dumps(value, separators=(',', ':'), allow_nan=False).encode(), method='POST', headers={'Content-Type': 'application/json', 'User-Agent': 'SkylarkFeeder/' + VERSION, **(headers or {})}) with request.build_opener(NoRedirect()).open(req, timeout=timeout) as response: data = response.read(65537) if len(data) > 65536: raise FeedError('Unexpectedly large response.') return json.loads(data) def upload(settings: dict, payload: bytes) -> dict: req = request.Request(settings['endpoint'], data=payload, method='POST', headers={'Authorization': 'Bearer ' + settings['feedKey'], 'Content-Type': 'application/json', 'User-Agent': 'SkylarkFeeder/' + VERSION}) opener = request.build_opener(NoRedirect()) with opener.open(req, timeout=12) as response: data = response.read(65537) if len(data) > 65536: raise FeedError('Unexpectedly large ingestion response.') result = json.loads(data) if not result.get('ok'): raise FeedError('Ingestion did not acknowledge the batch.') return result def station_status(settings: dict) -> dict: url = parse.urlsplit(settings['endpoint'])._replace(path='/api/network/feeder/status').geturl() req = request.Request(url, headers={'Authorization': 'Bearer ' + settings['feedKey'], 'User-Agent': 'SkylarkFeeder/' + VERSION}) with request.build_opener(NoRedirect()).open(req, timeout=12) as response: data = response.read(65537) if len(data) > 65536: raise FeedError('Unexpectedly large status response.') return json.loads(data) def paused_response(exc: error.HTTPError) -> bool: """Only the server's explicit pause code is safe to retry after a 403.""" if exc.code != 403: return False try: body = json.loads(exc.read(4097)) except (OSError, ValueError): return False return isinstance(body, dict) and body.get('error') == 'station-paused' def write_private(path: Path, value: dict) -> None: """Write the credential file whole or not at all, never readable by anyone else.""" path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) temporary = path.with_name('.' + path.name + '.' + uuid.uuid4().hex[:8]) fd = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) try: with os.fdopen(fd, 'w') as handle: handle.write(json.dumps(value, indent=2) + '\n') handle.flush() os.fsync(handle.fileno()) os.replace(temporary, path) except BaseException: with contextlib.suppress(OSError): os.unlink(temporary) raise def server_base(value: str, allow_local: bool) -> str: url = parse.urlsplit(value) local = allow_local and url.scheme == 'http' and url.hostname in LOOPBACK if (url.scheme != 'https' and not local) or not url.netloc or url.username or url.password or url.path not in ('', '/') or url.query or url.fragment: raise FeedError('The Skylark server must be an HTTPS origin such as https://skylark.watch.') return url.scheme + '://' + url.netloc def pair(args) -> int: if args.config.exists() and not args.force: print('A config already exists at ' + str(args.config) + '. Use --force to pair again; the old feed key stops working.', file=sys.stderr) return 2 base = server_base(args.server, args.allow_local_http) device = (args.device or platform.node() or 'Receiver')[:40] started = post_json(base + '/api/network/pair/start', {'device': device, 'version': VERSION}) code, token = started['userCode'], started['deviceToken'] deadline = time.time() + 600 print('\n Open ' + started['verificationUri'] + '\n Enter ' + code + '\n\n Waiting for the code to be entered (it expires in 10 minutes)...', flush=True) interval = max(2, int(started.get('intervalSeconds', 5))) while time.time() < deadline + interval: time.sleep(interval) try: result = post_json(base + '/api/network/pair/poll', {'deviceToken': token}) except error.HTTPError as exc: if exc.code in (404, 410): print('The code expired. Run the pairing again for a new one.', file=sys.stderr) return 4 if exc.code == 429: interval = min(30, interval * 2) continue raise if result.get('status') == 'paired': value = {'endpoint': check_endpoint(result['endpoint'], args.allow_local_http), 'feedKey': result['feedKey'], 'intervalSeconds': 10} if args.source: value['aircraftUrl' if args.source.startswith('http://') else 'aircraftFile'] = args.source source_setting(value) write_private(args.config, value) station = result.get('station') or {} print('Paired with ' + str(station.get('name', 'your station')) + '. Config written to ' + str(args.config) + '.', flush=True) return 0 print('The code expired. Run the pairing again for a new one.', file=sys.stderr) return 4 MARK = {'ok': '✓', 'warn': '!', 'fail': '✗', 'info': '·'} def doctor(args) -> int: """Walk the chain from the decoder to the public map and say what to fix at the first weak link.""" checks = [] def add(level, name, detail, fix=''): checks.append({'level': level, 'check': name, 'detail': detail, 'fix': fix}) add('ok' if sys.version_info >= (3, 9) else 'fail', 'Python', platform.python_version(), '' if sys.version_info >= (3, 9) else 'Install Python 3.9 or later.') settings = None try: settings = config(args.config, args.allow_local_http) add('ok', 'Config', str(args.config) + ' is private and valid') except FileNotFoundError: add('fail', 'Config', 'no config at ' + str(args.config), 'Pair this receiver: sudo skylark-feeder pair') except (OSError, ValueError, FeedError) as exc: add('fail', 'Config', str(exc) if isinstance(exc, FeedError) else 'unreadable or not JSON', 'Pair again with sudo skylark-feeder pair, or fix the file permissions (chmod 600).') configured = settings['aircraftFile'] if settings else None entries = detect(((str(configured),) if configured is not None else CANDIDATES), time.time()) usable = [e for e in entries if e['usable']] if configured is not None and not entries: add('fail', 'Decoder', 'the configured source %s is missing or not answering' % configured, 'Start the decoder, or remove aircraftFile from the config so the forwarder finds it: skylark-feeder detect') elif usable: e = usable[0] add('ok', 'Decoder', '%s: %d aircraft, %d with position, %d direct ADS-B positions to send, snapshot %ss old' % (e['source'], e['aircraft'], e['withPosition'], e['directPositions'], e['ageSeconds'])) if e['directPositions'] == 0: add('warn', 'Sky', 'the decoder is running but has no current direct positions', 'A quiet sky is normal at night. By day, check the antenna, cable and gain.') if e['mlat'] or e['tisb']: add('info', 'Left out', '%d MLAT and %d TIS-B aircraft stay local; Skylark takes direct ADS-B only' % (e['mlat'], e['tisb'])) elif entries: for e in entries: add('fail', 'Decoder', e['source'] + ': ' + e.get('problem', 'not usable'), 'Check that readsb or dump1090-fa is running: systemctl status readsb') else: add('fail', 'Decoder', 'no aircraft.json found in the usual places', 'Install a decoder (readsb, dump1090-fa, or the adsb.im image), or set aircraftFile in the config.') if settings: try: def timed(): began = time.time() answer = station_status(settings) return answer, began, time.time() - began status, began, took = timed() # Straight after an install the first upload is seconds away: give it # a moment rather than reporting a service that is about to work. waited = 0 while args.wait and waited < 30 and not status['station']['paused'] and (status['lastHeartbeat'] is None or status['asOf'] - status['lastHeartbeat'] > 60000): time.sleep(3) waited += 3 status, began, took = timed() add('ok', 'Connection', 'reached %s in %.1f s' % (parse.urlsplit(settings['endpoint']).netloc, took)) skew = began + took / 2 - status['asOf'] / 1000 if abs(skew) > 5: add('fail', 'Clock', 'this clock is %.0f s %s the server' % (abs(skew), 'ahead of' if skew > 0 else 'behind'), 'Turn on time sync: sudo timedatectl set-ntp true') elif abs(skew) > 2: add('warn', 'Clock', 'this clock is %.1f s off' % skew, 'Check time sync: timedatectl') else: add('ok', 'Clock', 'within %.1f s of the server' % abs(skew)) station = status['station'] if station['paused']: add('warn', 'Station', station['name'] + ' is paused', 'Resume it in your workspace: ' + status.get('workspace', '')) elif station['approval'] != 'approved': add('info', 'Station', station['name'] + ' is ' + station['approval'] + ': your workspace shows everything now; the public map follows once Skylark approves the station', '') else: add('ok', 'Station', station['name'] + ' is approved and publishing') recent = status['lastTwoHours'] if status['lastHeartbeat'] is None: add('warn', 'Uploads', 'nothing received yet', 'Restart the service and watch its log: sudo skylark-feeder restart; sudo skylark-feeder logs') elif status['asOf'] - status['lastHeartbeat'] > 120000: add('fail', 'Uploads', 'last upload %d minutes ago' % ((status['asOf'] - status['lastHeartbeat']) / 60000), 'Check the service and its log: sudo skylark-feeder logs') else: add('ok', 'Uploads', '%d positions accepted, %d rejected in the last two hours' % (recent['accepted'], recent['rejected'])) reasons = sorted(status.get('rejectedReasons24h') or [], key=lambda r: -r['count']) if reasons and recent['rejected'] > recent['accepted'] / 10: add('warn', 'Rejections', ', '.join('%s %d' % (r['reason'], r['count']) for r in reasons[:3]), 'stale-position or clock reasons point at time sync; implausible-jump at a decoder fault.') today = status['today'] if today['aircraft']: add('info', 'Today', '%d aircraft%s' % (today['aircraft'], ', furthest %.0f nm' % today['maxRangeNm'] if today['maxRangeNm'] else '')) except error.HTTPError as exc: if exc.code == 401: add('fail', 'Station', 'the feed key was not accepted (it was replaced or the station removed)', 'Pair again: sudo skylark-feeder pair --force') else: add('fail', 'Connection', 'server answered HTTP %d' % exc.code, 'Try again shortly; if it persists, check https://skylark.watch/network') except (OSError, ValueError, KeyError, FeedError) as exc: add('fail', 'Connection', 'could not reach Skylark (%s)' % type(exc).__name__, 'Check the internet connection and DNS: curl -I https://skylark.watch') failed = any(c['level'] == 'fail' for c in checks) if args.json: print(json.dumps({'ok': not failed, 'version': VERSION, 'checks': checks})) else: for c in checks: print(' %s %-11s %s' % (MARK[c['level']], c['check'], c['detail'])) if c['fix'] and c['level'] in ('fail', 'warn'): print(' %-11s %s' % ('', c['fix'])) warned = any(c['level'] == 'warn' for c in checks) print('\n Fix the first \u2717 above, then run the doctor again.' if failed else '\n Working, with a note above worth a look.' if warned else '\n All good. Skylark is receiving from this station.') return 1 if failed else 0 class Summary: """One journal line every ten minutes instead of one per upload.""" def __init__(self): self.reset(time.monotonic()) def reset(self, at): self.started, self.uploads, self.accepted, self.rejected, self.failures, self.reasons = at, 0, 0, 0, 0, {} def add(self, receipt): self.uploads += 1 self.accepted += receipt.get('accepted') or 0 self.rejected += receipt.get('rejected') or 0 for reason, count in (receipt.get('reasons') or {}).items(): self.reasons[reason] = self.reasons.get(reason, 0) + count def due(self): return time.monotonic() - self.started >= SUMMARY_SECONDS def line(self): top = ', '.join('%s %d' % item for item in sorted(self.reasons.items(), key=lambda item: -item[1])[:3]) text = 'Last %d min: %d uploads, %d positions accepted, %d rejected%s, %d failed uploads' % ( round((time.monotonic() - self.started) / 60), self.uploads, self.accepted, self.rejected, ' (' + top + ')' if top else '', self.failures) self.reset(time.monotonic()) return text def main() -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument('--config', type=Path, default=Path('/etc/skylark-feeder/config.json')) parser.add_argument('--lab', action='store_true', help='Open private loopback Receiver Lab; never enroll or upload.') parser.add_argument('--port', type=int, default=8765, help='Receiver Lab loopback port (default: 8765).') parser.add_argument('--latitude', type=float, help='Optional antenna latitude for Receiver Lab range.') parser.add_argument('--longitude', type=float, help='Optional antenna longitude for Receiver Lab range.') parser.add_argument('--check', action='store_true', help='Validate the local decoder file without transmitting.') parser.add_argument('--once', action='store_true', help='Send one current snapshot and stop.') parser.add_argument('--pair', action='store_true', help='Pair this receiver with a station and write the config.') parser.add_argument('--doctor', action='store_true', help='Check the whole chain and say what to fix.') parser.add_argument('--detect', action='store_true', help='List decoder outputs found on this machine.') parser.add_argument('--json', action='store_true', help='Machine-readable doctor output.') parser.add_argument('--wait', action='store_true', help='Doctor: allow up to 30 s for a first upload.') parser.add_argument('--force', action='store_true', help='Pair again over an existing config.') parser.add_argument('--server', default=DEFAULT_SERVER, help=argparse.SUPPRESS) parser.add_argument('--device', help='Label shown on the pair page (default: this host name).') parser.add_argument('--source', help='Decoder aircraft.json file or loopback URL for a local check, Receiver Lab or pairing.') parser.add_argument('--version', action='version', version='skylark-feeder ' + VERSION) parser.add_argument('--allow-local-http', action='store_true', help='Localhost integration testing only.') args = parser.parse_args() if args.lab: if (args.latitude is None) != (args.longitude is None): parser.error('--latitude and --longitude must be supplied together') if not 0 <= args.port <= 65535: parser.error('--port must be between 0 and 65535') if args.latitude is not None and (not finite(args.latitude) or not finite(args.longitude) or not -90 <= args.latitude <= 90 or not -180 <= args.longitude <= 180): parser.error('antenna coordinates are out of range') try: from receiver_lab import run source = args.source if source is None and args.config.exists(): source = source_setting(json.loads(args.config.read_text())) return run(source=source, port=args.port, latitude=args.latitude, longitude=args.longitude) except (OSError, ValueError, FeedError) as exc: print('Receiver Lab could not start: ' + str(exc), file=sys.stderr) return 4 # --check is a local-only mode even if another mode was also requested. if args.check: return check_local(args) if args.detect: print(json.dumps({'version': VERSION, 'sources': detect()}, indent=2)) return 0 if args.doctor: return doctor(args) if args.pair: try: return pair(args) except error.HTTPError as exc: print('Pairing failed: Skylark answered HTTP %d. Try again in a minute.' % exc.code, file=sys.stderr) return 4 except (OSError, ValueError, KeyError, FeedError) as exc: print('Pairing failed: ' + (str(exc) if isinstance(exc, FeedError) else 'could not reach Skylark (' + type(exc).__name__ + ')'), file=sys.stderr) return 4 try: settings = config(args.config, args.allow_local_http) except (OSError, ValueError, FeedError): print('Configuration invalid. Check private file permissions, endpoint and credential.', file=sys.stderr) return 2 pending = None delay = settings['intervalSeconds'] summary, first = Summary(), True while True: try: if pending is None or time.time() * 1000 - pending['capturedAt'] > 25000: pending = next_batch(settings) result = upload(settings, json.dumps(pending, separators=(',', ':'), allow_nan=False).encode()) summary.add(result) if first or args.once: print(json.dumps({key: result.get(key) for key in ('accepted', 'duplicate', 'rejected', 'publication', 'replayed')}), flush=True) first = False elif summary.due(): print(summary.line(), flush=True) pending = None delay = settings['intervalSeconds'] if args.once: return 0 except error.HTTPError as exc: summary.failures += 1 if paused_response(exc): # The server may resume this station later. Discard the old batch: # a successful retry must use a fresh decoder snapshot. pending = None print('Station is paused. Waiting for owner resume before retrying.', file=sys.stderr, flush=True) if args.once: return 3 delay = 30 else: print('Receiver service returned HTTP ' + str(exc.code) + '. Credentials are not logged.', file=sys.stderr, flush=True) if exc.code in (401, 403) or args.once: return 3 if exc.code in (400, 409, 413, 422): pending = None delay = min(60, max(10, delay * 2)) except (OSError, ValueError, FeedError): summary.failures += 1 print('No data sent or acknowledged. Check the local decoder, clock and connection (skylark-feeder doctor).', file=sys.stderr, flush=True) if args.once or args.check: return 4 delay = min(60, max(10, delay * 2)) time.sleep(delay + random.uniform(0, 1)) if __name__ == '__main__': try: sys.exit(main()) except KeyboardInterrupt: sys.exit(0)