Building a FlightAware UAT Feeder_

🇺🇦 Resources to help support the people of Ukraine. 🇺🇦
January 13, 2020 @17:30

Back in 2014 I built a FlightAware ADS-B feeder using a Raspberry Pi and a USB SDR dongle. While all commercial traffic is required to use the 1090MHz 'Extended Squitter' extension to the Mode S transponder as of January 1, 2020 there is an option for the general aviation community known as UAT, which operates on 978MHz and is meant to provide more affordable in-aircraft equipment for aircraft that will not operate above 18,000 ft MSL. Now that adoption is mandatory in US controlled airspace, I wanted to add UAT capability to my surveillance site. Since the 1090MHz feeder uses most of the capability of the Raspberry Pi in it, I decided to use a Raspberry Pi Zero W that I had laying around to build a separate feeder for UAT.

uat-feeder insides

The basic setup is similar to the 1090MHz version. As of this writing the FlightAware software requires Raspbian Stretch (not Buster), so I started with a clean install of the lite version.

Building

You then need to install the FlightAware repository package which at the moment is located at: https://flightaware.com/adsb/piaware/files/packages/pool/piaware/p/piaware-support/piaware-repository_3.7.2_all.deb

Then after an apt-get update you can install the following packages:

You will also want to make sure you have the correct rtl-sdr udev rules, there is a version located here that you can place in /etc/udev/rules.d/rtl-sdr.rules. Make sure you configure your piaware-config.txt file with your FlightAware information as per their instructions here.

After a reboot everything should come up and you should be able to view the SkyAware interface at http://[Your Raspberry Pi IP]/skyaware978/ and after a short period of time if everything is working you should get an e-mail from FlightAware confirming your new receiver.

Monitoring

Similar to my first feeder I added a sensor to monitor the temperature inside the enclosure since I again plan on mounting it outside. I also modified my collectd and Icinga monitoring scripts to support the new feeder with the same configuration and dashboard as the 1090MHz feeder uses. Some information is not available since dump978-fa uses a different SDR layer and does not report all the same statistics, but most of the information is available. The original setup is described in a previous post, and here are the new scripts specific to the BMP280 temperature sensor and dump978-fa.

collectd-dump978.py

#!/usr/bin/env python3
''' collectd-skyaware978.py (c) 2019 Matthew Ernisse <matt@going-flying.com>
 All Rights Reserved.

Collect statistics from skyaware978 and send to collectd.  Uses the collectd
Exec plugin.  This uses the same counter names as the dump1090-fa version
so you can share the same dashboard across both 978 and 1090 feeder systems.

The receiver stats aren't presently collected by dump978 it seems.

Redistribution and use in source and binary forms,
with or without modification, are permitted provided
that the following conditions are met:

    * Redistributions of source code must retain the
      above copyright notice, this list of conditions
      and the following disclaimer.
    * Redistributions in binary form must reproduce
      the above copyright notice, this list of conditions
      and the following disclaimer in the documentation
      and/or other materials provided with the distribution.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
'''
import json
import os
import socket
import time

def print_aircraft(stats):
    ''' Parse and emit information from the aircraft.json file. '''
    aircraft = len(stats.get('aircraft', []))
    messages = stats.get('messages')
    if not messages:
        raise ValueError('JSON stats undefined')

    m = "PUTVAL \"{}/dump1090/counter-messages\" interval={} N:{}".format(
        hostname,
        interval,
        messages
    )
    print(m)

    m = "PUTVAL \"{}/dump1090/gauge-aircraft\" interval={} N:{}".format(
        hostname,
        interval,
        aircraft
    )
    print(m)


def print_stats(stats):
    ''' Parse and emit information from the stats.json file. '''
    # These come from stats['total']
    counters = [
        'samples_processed',
        'samples_dropped',
    ]

    # These come from stats['last1min']
    gauges = [
        'signal',
        'noise'
    ]
    total = stats.get('total')
    latest = stats.get('last1min')

    total_values = total.get('local')
    latest_values = latest.get('local')
    if not total_values or not type(total_values) == dict:
        return

    if not latest_values or not type(latest_values) == dict:
        return

    for k in counters:
        value = total_values.get(k)
        if not value:
            value = 'U'

        m = "PUTVAL \"{}/dump1090/counter-{}\" interval={} N:{}".format(
            hostname,
            k,
            interval,
            value
        )
        print(m)

    for k in gauges:
        value = latest_values.get(k)
        if not value:
            value = 'U'

        m = "PUTVAL \"{}/dump1090/gauge-{}\" interval={} N:{}".format(
            hostname,
            k,
            interval,
            value
        )
        print(m)


if __name__ == '__main__':
    interval = float(os.environ.get('COLLECTD_INTERVAL', 10))
    hostname = os.environ.get('COLLECTD_HOSTNAME', socket.getfqdn())

    while True:
#       with open('/var/run/dump1090-fa/stats.json') as fd:
#           stats = json.load(fd)
#
#       print_stats(stats)

        with open('/var/run/skyaware978/aircraft.json') as fd:
            stats = json.load(fd)

        print_aircraft(stats)
        time.sleep(interval)

/usr/local/lib/python3.5/dist-packages/bmp280.py

#!/usr/bin/env python3
'''bmp280.py (c) 2019 Matthew J Ernisse <matt@going-flying.com>
All Rights Reserved.


Redistribution and use in source and binary forms,
with or without modification, are permitted provided
that the following conditions are met:

    * Redistributions of source code must retain the
      above copyright notice, this list of conditions
      and the following disclaimer.
    * Redistributions in binary form must reproduce
      the above copyright notice, this list of conditions
      and the following disclaimer in the documentation
      and/or other materials provided with the distribution.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
'''
import struct
import smbus
import time


class BMP280(object):
    ''' Read the data from a BMP 280 Temperature/Pressure
    sensor.  This operates in a single shot mode, so once the
    class is instansiated the values are available as
    temperature, pressure and humidity properties on the
    instance.
    '''
    # Home altitude is 147m, 482ft ASL
    altitude = 147
    registers = {
        'TMP_XLSB': 0xFC,
        'TMP_LSB': 0xFB,
        'TMP_MSB': 0xFA,
        'PRESS_XLSB': 0xF9,
        'PRESS_LSB': 0xF8,
        'PRESS_MSB': 0xF7,
        'CONFIG': 0xF5,
        'CTRL_MEAS': 0xF4,
        'STATUS': 0xF3,
        'RESET': 0xE0,
        'ID': 0xD0,
        'CAL00': 0x88,
    }

    def __init__(self, devAddr, busId=1):
        self.busId = busId
        self.calTable = {
            'dig_T1': 0x00,
            'dig_T2': 0x00,
            'dig_T3': 0x00,

            'dig_P1': 0x00,
            'dig_P2': 0x00,
            'dig_P3': 0x00,
            'dig_P4': 0x00,
            'dig_P5': 0x00,
            'dig_P6': 0x00,
            'dig_P7': 0x00,
            'dig_P8': 0x00,
            'dig_P9': 0x00,
        }
        self.calTableKeys = [
            'dig_T1',
            'dig_T2',
            'dig_T3',

            'dig_P1',
            'dig_P2',
            'dig_P3',
            'dig_P4',
            'dig_P5',
            'dig_P6',
            'dig_P7',
            'dig_P8',
            'dig_P9',
        ]

        self.devAddr = devAddr
        self.devId = None

        self.bus = smbus.SMBus(self.busId)
        self.reset()
        self.devId = self.bus.read_byte_data(
            self.devAddr,
            self.registers['ID']
        )


        # Setting up monitor modes as suggested in 3.5.1
        # forced mode, 1 sample / minute, oversampling x1, no filter.
        # See 5.4.3
        ctrl_reg = 0x4A # osrs_t 001, osrs_p 001, mode 010
        self.bus.write_byte_data(
            self.devAddr,
            self.registers['CTRL_MEAS'],
            ctrl_reg
        )

        # Load the factory calibration values from the onboard EEPROM.
        # There is 1 block of memory from 0x88 to 0xA1.  Page 21 of
        # the datasheet explains how they are layed out.
        cals = self.bus.read_i2c_block_data(
            self.devAddr,
            self.registers['CAL00'],
            24
        )

        calTable = struct.unpack(
            '<HhhHhhhhhhhh',
            bytes(cals)
        )

        for i, val in enumerate(calTable):
            key = self.calTableKeys[i]
            self.calTable[key] = val

        time.sleep(0.2)

        measurements = self.bus.read_i2c_block_data(
            self.devAddr,
            self.registers['PRESS_MSB'],
            6
        )

        press = self._unpacker(measurements[0:3])
        temp = self._unpacker(measurements[3:6])

        (t_fine, self.temp) = self._compensate_temp(temp)
        self.press = self._compensate_press(press, t_fine)

    def __str__(self):
        return "<BME280 bus={:X}, addr=0x{:X}, id=0x{:X}>".format(
            self.busId,
            self.devAddr,
            self.devId,
        )

    # Compensation functions adapted from Appendix Section 8.1
    def _compensate_temp(self, val):
        ''' Return the compensated temperature measurement
        along with the fine adjustment value in a tuple of
        (t_fine, degrees C) as t_fine is used by the other
        compensation functions.
        '''
        var1 = (val / 16384.0 - self.calTable['dig_T1'] / 1024.0) \
            * self.calTable['dig_T2']
        var2 = (val / 131072.0 - self.calTable['dig_T1'] / 8192.0) * \
            (val / 131072.0 - self.calTable['dig_T1'] / 8192.0) * \
            self.calTable['dig_T3']

        t_fine = var1 + var2
        return (t_fine, t_fine / 5120.0)

    def _compensate_press(self, measurement, t_fine):
        ''' Return the compensated pressure measurement in
        hectopascals (hPa).
        '''
        var1 = (t_fine / 2.0) - 64000.0
        var2 = var1 * var1 * self.calTable['dig_P6'] / 32768.0
        var2 = var2 + var1 * self.calTable['dig_P5'] * 2
        var2 = (var2 / 4.0) + self.calTable['dig_P4'] * 65530.0
        var1 = (self.calTable['dig_P3'] * var1 * var1 / 524288.0 + \
            self.calTable['dig_P2'] * var1) / 524288.0
        var1 = (1.0 + var1 / 32768.0) * self.calTable['dig_P1']

        if var1 == 0:
            return 0.0

        pressure = 1048576.0 - measurement
        pressure = (pressure - (var2 / 4096.0)) * 6250.0 / var1

        var1 = self.calTable['dig_P9'] * pressure * pressure / \
            2147483648.0
        var2 = pressure * self.calTable['dig_P8'] / 32768.0
        pressure = pressure + \
            (var1 + var2 + self.calTable['dig_P7']) / 16.0

        # Convert Pascals to hPa
        return float(pressure / 100.0)

    def _unpacker(self, vals):
        ''' Take the 3 bytes from the device in XLSB, LSB, MSB and
        convert to a Python int.

        Section 4.3.7 describes the formats.
        '''

        return (vals[0] << 12) | (vals[1] << 4) | (vals[2] >> 4)

    def reset(self):
        ''' Soft reset the sensor. '''
        # 5.4.2
        self.bus.write_byte_data(
            self.devAddr,
            self.registers['RESET'],
            0xB6
        )
        time.sleep(0.05)

    @property
    def baroStr(self):
        ''' Return a string for the barometric pressure. '''
        if self.press < 985:
            return 'Stormy'
        elif self.press < 1005:
            return 'Rain'
        elif self.press < 1025:
            return 'Fair'
        else:
            return 'Very Dry'

    @property
    def mmhg(self):
        ''' Returns the current barometric pressure in mmHg instead of
        hPa.
        '''
        return self.press * 0.02953

    @property
    def qnh(self):
        ''' Given a local altitude determine the current QNH at the
        sensor location.
        '''
        diff = self.altitude / 30
        return self.press + diff

    @property
    def qnhhg(self):
        ''' Given a local altitude determine the current QNH at the
        sensor location.
        '''
        diff = self.altitude / 30
        return (self.press + diff) * 0.02953

if __name__=="__main__":
    sensor = BMP280(0x76, 1)
    print('{:.02f}°C, QFE {:.02f} inHg ({}), QNH {:.02f} inHg'.format(
        sensor.temp,
        sensor.mmhg,
        sensor.baroStr,
        sensor.qnhhg
    ))

/usr/bin/collectd-bmp280.py

#!/usr/bin/env python3
'''collectd-bmp280.py (c) 2019 Matthew J Ernisse <matt@going-flying.com>
All Rights Reserved.

Redistribution and use in source and binary forms,
with or without modification, are permitted provided
that the following conditions are met:

    * Redistributions of source code must retain the
      above copyright notice, this list of conditions
      and the following disclaimer.
    * Redistributions in binary form must reproduce
      the above copyright notice, this list of conditions
      and the following disclaimer in the documentation
      and/or other materials provided with the distribution.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
'''
import bmp280
import os
import socket
import time


def read_sensor():
    sensor = bmp280.BMP280(0x76, 1)
    return (sensor.temp, 0.0)


if __name__ == '__main__':
    interval = float(os.environ.get('COLLECTD_INTERVAL', 10))
    hostname = os.environ.get('COLLECTD_HOSTNAME', socket.getfqdn())

    while True:
        retval = read_sensor()
        print("PUTVAL \"{}/hih6130/gauge-temperature\" interval={} N:{:.2f}".format(
            hostname,
            interval,
            retval[0]
        ))

        print("PUTVAL \"{}/hih6130/gauge-humidity\" interval={} N:{:.2f}".format(
            hostname,
            interval,
            retval[1]
        ))

        time.sleep(interval)

Graphana Dashboard

At the moment the feeder is inside and isn't seeing much, but I expect it will see a lot more once I get it mounted outside alongside the 1090MHz receiver. That being said I am receiving positions and sending them along to the FlightAware network.

Coverage Graph of new feeder

Comment via e-mail. Subscribe via RSS.