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.
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:
- rtl-sdr
- piaware
- piaware-web
- skyview978
- dump978-fa
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)
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.