Aircraft

SpotterLight

by JHA Design

Early Warning System

I live near a HEMS (Helicopter Emergency Medical Service), in this case a helipad on top of a hospital. As an aviation geek I'd always run to the window when I could hear a helicopter flapping in the vincinity.
There had to be another way, so I would have time to grab my camera as well.
So I had to build an EWS, to warn me by lighting up an LED, when a helicopter was approaching - and of course, the LED needed to be lit in a different color, depending on the aircraft type or operator.

Equipment Required

- Raspberry Pi (I used a 3B+).

- USB DVB-T SDR Tuner - e.g. one of these incl. antenna.

- One WS2812 8mm LED or similar.

- Some jumper wires.

- Access to 3D Printer (Optional).

 

Note: the better "line of sight" your antenna has to the aircraft, the better coverage you will get from your "radar".
Improve this by installing a better antenna (for 1090MHz ADS-B signals).


Setting up the receiver

In this section, we will set up the circles around your home using the tar1090 software on the Raspberry Pi.

while you are at it, you might want to share data to FR24 as well - in return, you get (at the time of writing) a business subscribtion while sharing data.


Light up an LED based on the aircraft seen by the tracker

Using the ADS-B data, have an LED light up based on the data - the hex code of a specific aircraft or a callsign using RegEx (e.g. all DNU.*W to get all DNUsomenumberW flights).

Using the LatLon from our home and the LatLon from the tracked aircraft, we can calculate the distance to our home with the law of haversines. And thereby determine if it is 'in range' and we want the LED lit.

I printed a model of Kastrup Tower to hold the LED.

The tower has a hollow canal to get the wires to GPIO18, +5V and GND

Click here to scroll down to the code


 

Create a service to run the program (and log the output):
[unit] Description=Aircraft SpotterLight by JHA DESIGN Wants=network-online.target After=network-online.target [Service] ExecStart=/usr/bin/python3 -u ac_spotterlight.py WorkingDirectory=/home/pi/ac_spotterlight Restart=always RestartSec=300 User=root Nice=10 [Install] WantedBy=multi-user.target

ac_spotterlight - how to add this script as a service on the RPi
#should probably move animations to a separate file. colors.py contain HTML colors to RGB. 
import re
import os
import sys
import signal
import board
import neopixel
import requests
import time
import json
from math import radians, cos, sin, asin, sqrt
from colors import *

#number of Pixels
numpix = 1
ORDER = neopixel.RGB

# Neopixels add on Pin 18 PWM pin, with 1 pixel 
pixels = neopixel.NeoPixel(board.D18, numpix, brightness=0.1, auto_write=False, pixel_order=ORDER)

center_point = [{'lat': 55.699531, 'lng': 12.585264}]
radius = 50.00 # in kilometer


# Test with history file remember to comment out live stream
#with open('history_87.json') as json_file:
#	json_dump = json.load(json_file)

# Signal Handler to Catch CTRL+C commands and turn off the lights
def signal_handler(signal, frame):
		pixels.fill((0, 0, 0))
		pixels.show()
		sys.exit(0)

signal.signal(signal.SIGINT, signal_handler)
#print('Press Ctrl+C')


def haversine(lon1, lat1, lon2, lat2):
	"""
	Calculate the great circle distance between two points
	on the earth (specified in decimal degrees)
	"""
	# convert decimal degrees to radians
	lon1, lat1, lon2, lat2 = map(radians, [lon1, lat1, lon2, lat2])
		# haversine formula
	dlon = lon2 - lon1
	dlat = lat2 - lat1
	a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
	c = 2 * asin(sqrt(a))
	r = 6371 # Radius of earth in kilometers. Use 3956 for miles
	return c * r

def in_or_out(tlat, tlon):
	lat1 = center_point[0]['lat']
	lon1 = center_point[0]['lng']
	a = haversine(lon1, lat1, tlon, tlat)
	#print('Distance (km) : ', a)
	if a <= radius:
		#print('Inside the area')
		return a 
	else:
		#print('Outside the area')
		return 9999


#pulse animation
smoothness = 100
def pulse(color): 
	for ii in range(smoothness):
		pwm_val = 100.0*(1.0 -  abs((2.0*(ii/smoothness))-1.0))
		pwm_val2 = round(pwm_val / 100,2)
		print(pwm_val2)
		pixels.brightness = pwm_val2
		pixels.fill((color))
		pixels.show()
		# in order to pulse we need a pause/sleep. Lets set this depending on the range. The closer, the faster the pulse. 
		inRange = in_or_out(foundLat[0],foundLon[0])
		print(time.ctime(), "found", dictFind[0]['name'], "range: ", inRange)
		if(inRange > 5): 
			time.sleep(inRange / 1000)
		elif(inRange <= 5): 
			time.sleep(0.01)
	time.sleep(0.5) 

def defaultAnim(color):
	if foundLat[0] and foundLon[0] != None:
		inRange = in_or_out(foundLat[0],foundLon[0])
		if inRange > 3: 
			pixels.brightness = round(1.0 - (inRange/5)/10,1) #divide the range of max 50km by 5, then by 10 and substract it from the max brightness
			print(time.ctime(), "found", dictFind[0]['name'], "range: ", inRange)
		else:
			pixels.brightness = 1.0 #it is less than 3km away lets shine bright 
	else:
		pixels.brightness = 0.5 #value between 0.0 and 1.0 (max). Increase 0.1 every time 5km mark is passed from 50km to 0km.	
	pixels.fill((color))
	pixels.show()
	print(time.ctime(), "found", dictFind[0]['name'], "range: ", inRange)

def noAnim(color): 
	pixels.brightness = 1.0
	pixels.fill((color))
	pixels.show()
	print(time.ctime(), "found", dictFind[0]['name'])


interestingFlights = [
	{"name": "Danish Air Force",
	"callSign":["DAF","RES","MERLN"],
	"icaoHex": ["45f42c", "45f42e", "45f431", "45f432", "45f434", "45f436", "45f437", "45f438", "45f42d", "45f42f", "45f430", "45f433", "45f435"],
	"color": green,
	"animation": defaultAnim,
	"priority": "1"
	},
	{
	"name": "HeliDoc",
	"callSign": ["DOC"],
	"icaoHex": ["47879d", "47a1ce", "47a20d", "47a210", "4784b1", "4783ca", "4783c9", "4783b9"],
	"color": yellow,
	"animation": noAnim,
	"priority": "1"
	},
	{
	"name": "Vandflyet",
	"callSign": ["DNU.*W"],
	"icaoHex": ["45ba61",],
	"color": blue,
	"animation": pulse,
	"priority": "1"
	}
]

def findhexCode(hexCode):
	for d in interestingFlights: 
		for h in d['icaoHex']:
			if h == hexCode:
				return d
	return None

def findcallSign(callSign):
	for d in interestingFlights: 
		for cs in d['callSign']: 
			#if callSign.find(cs) == 0:
			if re.search(cs, callSign):
				return d 
	return None


while True:
	json_dump = requests.get("http://{}:8080/data/aircraft.json".format("192.168.0.120")).json()

	found_flt = 0
	dictFind = [None,None]
	foundLat = [None,None]
	foundLon = [None,None]

	#look for the flights in the json file 
	for entry in json_dump["aircraft"]:
		if "hex" in entry: 
			dictFind[found_flt] = findhexCode(entry["hex"])
			if (dictFind[found_flt] == None):
				if "flight" in entry:
					dictFind[found_flt] = findcallSign(entry["flight"])
					#dictFind.append(findcallSign(entry["flight"]))
		if (dictFind[found_flt] != None):
			if "lat" in entry and "lon" in entry:
				foundLat[found_flt] = entry["lat"]
				foundLon[found_flt] = entry["lon"]
			if (found_flt > 0): 
				if (in_or_out(foundLat[0],foundLon[0]) > in_or_out(foundLat[1],foundLon[1])):
					dictFind[0] = dictFind[1]
					foundLat[0] = foundLat[1]
					foundLon[0] = foundLon[1]
			found_flt = 1

		#print(" ")
		#print("DEBUG: dictFind[0] = ",dictFind[0])
		#print("DEBUG: dictFind[1] = ",dictFind[1])

	if(found_flt == 1):
		dictFind[0]['animation'](dictFind[0]['color'])
	
	else:
		pixels.brightness = 1.0
		pixels.fill(black)
		pixels.show()
		#print("lights off.. scanning for aircraft.. blip.. blip.. blip..")
	time.sleep(0.05)
signal.pause()



				    
colors.py
maroon = (128, 0, 0)
darkred = (139, 0, 0)
brown = (165, 42, 42)
firebrick = (178, 34, 34)
crimson = (220, 20, 60)
red = (255, 0, 0)
tomato = (255, 99, 71)
coral = (255, 127, 80)
indianred = (205, 92, 92)
lightcoral = (240, 128, 128)
darksalmon = (233, 150, 122)
salmon = (250, 128, 114)
lightsalmon = (255, 160, 122)
orangered = (255, 69, 0)
darkorange = (255, 140, 0)
orange = (255, 165, 0)
gold = (255, 215, 0)
darkgoldenrod = (184, 134, 11)
goldenrod = (218, 165, 32)
palegoldenrod = (238, 232, 170)
darkkhaki = (189, 183, 107)
khaki = (240, 230, 140)
olive = (128, 128, 0)
yellow = (255, 255, 0)
yellowgreen = (154, 205, 50)
darkolivegreen = (85, 107, 47)
olivedrab = (107, 142, 35)
lawngreen = (124, 252, 0)
chartreuse = (127, 255, 0)
greenyellow = (173, 255, 47)
darkgreen = (0, 100, 0)
green = (0, 128, 0)
forestgreen = (34, 139, 34)
lime = (0, 255, 0)
limegreen = (50, 205, 50)
lightgreen = (144, 238, 144)
palegreen = (152, 251, 152)
darkseagreen = (143, 188, 143)
mediumspringgreen = (0, 250, 154)
springgreen = (0, 255, 127)
seagreen = (46, 139, 87)
mediumaquamarine = (102, 205, 170)
mediumseagreen = (60, 179, 113)
lightseagreen = (32, 178, 170)
darkslategray = (47, 79, 79)
teal = (0, 128, 128)
darkcyan = (0, 139, 139)
aqua = (0, 255, 255)
cyan = (0, 255, 255)
lightcyan = (224, 255, 255)
darkturquoise = (0, 206, 209)
turquoise = (64, 224, 208)
mediumturquoise = (72, 209, 204)
paleturquoise = (175, 238, 238)
aquamarine = (127, 255, 212)
powderblue = (176, 224, 230)
cadetblue = (95, 158, 160)
steelblue = (70, 130, 180)
cornflowerblue = (100, 149, 237)
deepskyblue = (0, 191, 255)
dodgerblue = (30, 144, 255)
lightblue = (173, 216, 230)
skyblue = (135, 206, 235)
lightskyblue = (135, 206, 250)
midnightblue = (25, 25, 112)
navy = (0, 0, 128)
darkblue = (0, 0, 139)
mediumblue = (0, 0, 205)
blue = (0, 0, 255)
royalblue = (65, 105, 225)
blueviolet = (138, 43, 226)
indigo = (75, 0, 130)
darkslateblue = (72, 61, 139)
slateblue = (106, 90, 205)
mediumslateblue = (123, 104, 238)
mediumpurple = (147, 112, 219)
darkmagenta = (139, 0, 139)
darkviolet = (148, 0, 211)
darkorchid = (153, 50, 204)
mediumorchid = (186, 85, 211)
purple = (128, 0, 128)
thistle = (216, 191, 216)
plum = (221, 160, 221)
violet = (238, 130, 238)
magenta = (255, 0, 255)
orchid = (218, 112, 214)
mediumvioletred = (199, 21, 133)
palevioletred = (219, 112, 147)
deeppink = (255, 20, 147)
hotpink = (255, 105, 180)
lightpink = (255, 182, 193)
pink = (255, 192, 203)
antiquewhite = (250, 235, 215)
beige = (245, 245, 220)
bisque = (255, 228, 196)
blanchedalmond = (255, 235, 205)
wheat = (245, 222, 179)
cornsilk = (255, 248, 220)
lemonchiffon = (255, 250, 205)
lightgoldenrodyellow = (250, 250, 210)
lightyellow = (255, 255, 224)
saddlebrown = (139, 69, 19)
sienna = (160, 82, 45)
chocolate = (210, 105, 30)
peru = (205, 133, 63)
sandybrown = (244, 164, 96)
burlywood = (222, 184, 135)
tan = (210, 180, 140)
rosybrown = (188, 143, 143)
moccasin = (255, 228, 181)
navajowhite = (255, 222, 173)
peachpuff = (255, 218, 185)
mistyrose = (255, 228, 225)
lavenderblush = (255, 240, 245)
linen = (250, 240, 230)
oldlace = (253, 245, 230)
papayawhip = (255, 239, 213)
seashell = (255, 245, 238)
mintcream = (245, 255, 250)
slategray = (112, 128, 144)
lightslategray = (119, 136, 153)
lightsteelblue = (176, 196, 222)
lavender = (230, 230, 250)
floralwhite = (255, 250, 240)
aliceblue = (240, 248, 255)
ghostwhite = (248, 248, 255)
honeydew = (240, 255, 240)
ivory = (255, 255, 240)
azure = (240, 255, 255)
snow = (255, 250, 250)
black = (0, 0, 0)
dimgray = (105, 105, 105)
gray = (128, 128, 128)
darkgray = (169, 169, 169)
silver = (192, 192, 192)
lightgray = (211, 211, 211)
gainsboro = (220, 220, 220)
whitesmoke = (245, 245, 245)
white = (255, 255, 255)


				    

Logging the aircraft to .csv and displaying it

inspired by CactusProjects

Next, I wanted to log the aircraft seen to a .csv file and then display the .csv file content on a webpage. Found some inspiration on the web and used bootstrap to make it prettier.

It is often the same helicopter operating so I wanted to be able to log the same aircraft multiple times a day but not the same flight every 5 minutes. So I currently do a check to see if I have seen the aircraft in the past 30 minutes. If not it is added to the log.

 

PHP/HTML code to display it once the .csv files are generated.


 

Create a crontab to run the logger every interval you'd like:
*/5 * * * * { printf "\%s: " "$(date "+\%F \%T")"; /usr/bin/env nice -10 python3 /home/pi/ac_spotterlight/ac_spotlogger.py; } >> /home/pi/log/ac_spotlogger.log 2>&1

ac_spotlogger
#!/usr/bin/env python3

from datetime import datetime, timedelta
from datetime import date
import requests
import os
import csv
import json

#today = date.today()
today = datetime.now().strftime("%d%b%y").upper()
month = datetime.now().strftime("%b").upper()
year = datetime.now().strftime("%Y")
time = datetime.now().strftime("%H:%M:%S")
interval = datetime.now() - timedelta(minutes=30)
timeInit = datetime.now() - timedelta(hours=1)
initTime = format(timeInit, '%H:%M:%S')
intervalTime = format(interval, '%H:%M:%S')

# calculate ac-reg for danish and swedish aircrafts
# Danish start hex: 458000 Swedish: 468000

def icao2reg_dk_se(hex):
  dk_hex = 0x458000
  se_hex = 0x4A8000
  reg = ""
  # Using binary and to compare the first nine bits to evaluate country code as given by ICAO
  if ((hex & 0xFF8000) == dk_hex or (hex & 0xFF8000) == se_hex):
    if ((hex & 0xFF8000) == dk_hex):
      reg = "OY-"
    else:
      reg = "SE-"
  # Convert HEX to REG starting after the nine bits - the hex is a string and therefore converted to binary prior to calling this func.
  # Testing five bits at a time as per Bilag C
  # This is done three times as three characters is expected after the country designator for DK SE
  #
  #
  # 0100 0101 1xxx xxyy yyyz zzzz
  #
  # for each i we calculate x y z respectively by bitshifting
  # first shift will be 10 bits to the right giving:
  # 0000 0001 0001 011x xxxx
  # then we and with 0x1F (0000 0000 0000 0001 1111) resulting in
  # 0000 0000 0000 000x xxxx
  # then we use this number as the index to the alphabet to provide the right character to add to the resulting string
  # next original number is then shifted 5 bits to the right and the last is not shifted (0)
  # now we have shifted all the bits and each time added the character calculated to the string
  # we now have transformed or calculated the hex into the aircraft registration using the algorithm
  # as provided by the danish civil aviation authority (and the swedish use the same)

    for i in range(2,-1,-1):
      reg = reg + chr(ord('A') - 1 + ((hex >> (5 * i)) & 0x1F))
  return reg


# file setup
filename = "{}.csv".format(today)
rootFolder = "/var/www/html/ac_logger/flights/"
#subFolder = "{}-{}/".format(year, month)
#filepath = rootFolder + subFolder + filename
filepath = rootFolder + filename

# create root folder and sub folder
if not os.path.isdir(rootFolder):
    os.mkdir(rootFolder)

#if not os.path.isdir(rootFolder + subFolder):
#    os.mkdir(rootFolder + subFolder)

# check if files exists
file_exists = os.path.isfile(filepath)

# get all flights tracked to check against for duplicates
flights = ""
if file_exists:
    with open(filepath, "r") as csv_file:
         flights = csv.reader(csv_file, delimiter=',')

# get json data
json_dump = requests.get("http://192.168.x.xxx:8080/data/aircraft.json".format("192.168.x.xxx")).json()

#for testing purposes
#with open('history_87.json') as json_file:
#  json_dump = json.load(json_file)

# hardcoded known hex to aircraft reg 
# Should do a lookup to the database provided by https://opensky-network.org/aircraft-database instead 
icaohex = [
#Danish Air Force
"45f42c","M-502",
"45f42e","M-504",
"45f430","M-506",
"45f431","M-507",
"45f432","M-508",
"45f434","M-510",
"45f436","M-512",
"45f437","M-513",
"45f438","M-514",
"45f42b","M-515/B-583",
"45f42d","M-516",
"45f42f","M-517",
"45f433","M-519",
"45f435","M-520",
"45f42a","B-538",
"45f428","B-536",
"45f422","C-080",
"45f424","C-168",
"45f425","C-172",
"45f426","C-215",
#Norsk Luftambulanse
"4783b9","LN-OOC",
"4783ba","LN-OOD",
"4783c9","LN-OOF",
"4783ca","LN-OOJ",
"4784b1","LN-OOK",
"47879d","LN-OON",
"47a210","LN-OOV",
"47a20d","LN-OOW",
"47a1ce","LN-OOZ",
"47bf10","LN-OUK",
#Danish private aircraft
"45f081","OY-9532",
"45f009","OY-9285",
#SWEFORCE
"4a81f4","100008",
"4a8182","84002",
""] #empty value for counting purposes

# write flights to csv file
with open(filepath, "a", newline="") as file, open(filepath, "r") as csv_file:
    writer = csv.writer(file)
    fieldnames = ["flight", "hex", "reg", "date", "time"]
    writer = csv.DictWriter(file, fieldnames=fieldnames)

    # create headers if first time writing to file
    if not file_exists:
        writer.writeheader()

    # add flights based on an interval, flights can be identical
    for entry in json_dump["aircraft"]:
      seenTime = initTime
      if "hex" in entry:
        flights = csv.reader(csv_file, delimiter=',')
        #next(flights) #skip first header line
        for row in flights:
          if entry["hex"].strip() == row[1]: #check if first csv column contains our flight
            seenTime = row[4]
        if seenTime < intervalTime:
          hexValue = int(entry["hex"],16)
          rego = icao2reg_dk_se(hexValue)
          for j in range(0,len(icaohex),2):
            if icaohex[j] == entry["hex"]:
              rego = icaohex[j+1]
              break
          if "flight" in entry:
            writer.writerow({"flight": entry["flight"].strip(), "hex": entry["hex"], "reg": rego,  "date": today, "time": time})
          else:
            writer.writerow({"flight": "", "hex": entry["hex"], "reg": rego,  "date": today, "time": time})
      csv_file.seek(0)

csv_file.close()
print("Program ran succesfully")
				

 

 

01001010 01001000 01000001 00100000 01000100 01100101 01110011 01101001 01100111 01101110
01001010 01001000 01000001 00100000 01000100 01100101 01110011 01101001 01100111 01101110
01001010 01001000 01000001 00100000 01000100 01100101 01110011 01101001 01100111 01101110
01001010 01001000 01000001 00100000 01000100 01100101 01110011 01101001 01100111 01101110
01001010 01001000 01000001 00100000 01000100 01100101 01110011 01101001 01100111 01101110
01001010 01001000 01000001 00100000 01000100 01100101 01110011 01101001 01100111 01101110
01001010 01001000 01000001 00100000 01000100 01100101 01110011 01101001 01100111 01101110
01001010 01001000 01000001 00100000 01000100 01100101 01110011 01101001 01100111 01101110
01001010 01001000 01000001 00100000 01000100 01100101 01110011 01101001 01100111 01101110
01001010 01001000 01000001 00100000 01000100 01100101 01110011 01101001 01100111 01101110
01001010 01001000 01000001 00100000 01000100 01100101 01110011 01101001 01100111 01101110
01001010 01001000 01000001 00100000 01000100 01100101 01110011 01101001 01100111 01101110
01001010 01001000 01000001 00100000 01000100 01100101 01110011 01101001 01100111 01101110
01001010 01001000 01000001 00100000 01000100 01100101 01110011 01101001 01100111 01101110

Bonus: Calculating the aircraft registration from the 24-bit ICAO hex code for danish/swedish aircraft

How do the larger trackers convert the 24-bit ICAO hex to the registration of the aircraft? By having a database or are some calculated?

I found out that some countries e.g. Denmark & Sweden - has publicly available - the algorithm to convert the 24-bit hex to aircraft registration.

Great! I wanted this to be calculated in the python script logging the aircraft.

However there are exceptions.. the algorithm seem only to apply for airliners or "commercial" aviation. Small privately owned aircraft as well as the fleet of the Royal Danish Airforce is not calculated using this algorithm. And it seems the same goes for the Swedish Air Force. I am still to figure out if there is any logic in how these are assigned.

This is a work in progress, you might want to disable it.