How a small volunteer tech project grew into a self-hosted radio platform, aviation monitoring system, election-night Mission Control, historical telemetry database and live broadcast infrastructure
Some projects start with requirements. Some start with a customer. Some start with a business plan. This one started because I wanted to help a radio station. And then my wife made a joke.
A few weeks later I was running an aircraft telemetry collector, several aviation-data providers, SQLite history, Discord and Telegram alerting, a radar interface, historical heatmaps, a radio automation system, Low-Latency HLS video, SRT contribution feeds and a small Mission Control environment spread across multiple servers and my home network. That escalated quickly.
For obvious reasons I have anonymized this story. Personal names, domains, IP addresses, server names, aircraft registrations and a few operational details have been removed or generalized. The technologies, architecture and technical problems are real.
It actually started with radio, not aircraft
The beginning had nothing to do with tracking government aircraft. I had been following a small independent Serbian online radio project. They were still developing both the station and the technical side around it.
I am retired from full-time IT work, but retirement in my case mostly means that I finally have time to build things because they interest me rather than because somebody has put them in a ticketing system. So I contacted them and basically said:
I have time. I have infrastructure. I have decades of IT experience. If I can help, let me know.
That was supposed to be a small favour. Naturally, it was not.
The stream itself became the next project
Once you start building around somebody else’s audio stream, you quickly discover that playing the stream is the easy part. What I wanted was a controlled copy of the stream that I could integrate into other systems without interfering with the station’s own infrastructure.
So I built a relay. The source was the station’s existing HLS feed. From there the architecture grew into something like this:
Station HLS
|
v
Liquidsoap
|
+-------------------+
| |
v v
MP3 output AAC output
| |
+--------+----------+
|
Icecast
|
+--------> web players
|
+--------> monitoring
|
v
DVR / HLS
That relay ran on a modest Linux VPS in Germany. Nothing spectacular:
- 2 virtual CPU cores
- 4 GB RAM
- Ubuntu Linux
- roughly a few tens of GB of free storage
And that is worth mentioning because people have a tendency to assume every streaming project needs an enormous machine. It does not. The radio workload was perfectly happy on a small VPS.
Why both MP3 and AAC?
Compatibility. There are still plenty of clients for which MP3 is the least troublesome format in existence. So one Icecast mount provided an approximately 128 kbit/s MP3 stream, and another provided AAC at a higher quality level. The architecture deliberately kept both, so a device or browser could use whichever made sense.
More importantly, these were now streams under my control. That meant I could build things around them without touching the station’s primary delivery path.
Then came DVR
Normal internet radio is ephemeral. If you miss something, you miss it. That did not fit what I wanted to build, so the relay gained a rolling DVR system. The stream was segmented and stored as HLS, allowing a listener to rewind several hours. At one point the rolling window was roughly six hours.
Alongside the media segments I maintained metadata history. That made it possible to answer a surprisingly difficult question:
What song was playing at the moment corresponding to this point in the rewind stream?
Now Playing became a proper service rather than a piece of text scraped from a player. The stack gained:
- current track metadata
- historical track timeline
- DVR / rewind
- artist and title information
- cover artwork
- cached artwork
That history would later turn out to be useful in an entirely different part of the system.
Album artwork is much more annoying than it looks
Displaying a cover image sounds trivial. It is not. Metadata is inconsistent. Artist names vary. Track names contain suffixes. Releases have different artwork. Some metadata providers know the recording but not the release, and some have the release group but no useful image.
So another little subsystem appeared: a cover resolver. It could take the current metadata, perform lookups, select a sensible result, cache the artwork locally and expose it to the website.
At one stage I thought the resolver was broken because the backend correctly reported that artwork existed while the webpage showed nothing. The backend was fine. The artwork URL returned HTTP 200. The problem was Content Security Policy. The browser was simply prohibited from displaying the image host I had created.
Add one hostname to img-src, and suddenly the system that had apparently been unable to find cover art had magically become an excellent cover-art system. Web development in one paragraph.
Liquidsoap became the centre of the radio side
The more the radio system evolved, the clearer it became that Liquidsoap was the right place to make decisions about audio. It eventually became much more than a relay. It could handle:
- station identification
- hourly jingles
- scheduled jingles
- manual jingles
- breaking-news stings
- special event stings
- aircraft alerts
- audio ducking
- priority sources
- queues
- manual triggers
- metadata
- live source switching
One of the most important design decisions was that the original station stream remained the base source. Everything else should be added around it. If my automation disappeared, listeners should still hear radio. The clever layer must never become a single point of failure for the basic service.
Mission Control for the radio
Eventually I wanted control from the same web environment I was already building for other parts of the project. That led to a Radio Control section. I did not want a PHP page to have unrestricted shell access to the media server, so I built a deliberately narrow chain. Conceptually:
Authenticated web admin
|
v
server-side command
|
v
restricted SSH
|
v
forced command
|
v
radio-control program
|
v
Liquidsoap control socket
The web interface did not get to run arbitrary remote commands. It could invoke known actions. Things like:
- play station ID
- play breaking-news sting
- play aircraft alert
- play election sting
- skip current queued item
- clear queue
- enable special mode
- disable special mode
Actions could be logged. State could be persisted. The automation could recover after restarts. That is the kind of boring infrastructure work that eventually makes experimental systems reliable.
Then came “Election Mode”
Because the radio project and my own monitoring experiments were increasingly overlapping, I added a special operating mode. Internally it could switch the presentation from normal station behaviour to an election-night state. Metadata could be modified while preserving the original underlying song information. A live alert could temporarily override the special-mode metadata. When the alert finished, the special mode returned, and when the special mode ended, normal metadata returned.
That sounds simple until you encounter timing races between stream metadata, automation events and client updates. So the system gained reconciliation logic. If the metadata state drifted away from what it was supposed to be, it would correct itself within seconds.
This was becoming rather more sophisticated than the radio player I originally volunteered to help with. And then my wife made the comment.
“What if the president takes off and disappears, like al-Assad?”
We were talking at home when my wife jokingly asked something along the lines of:
“What if the president suddenly takes off and runs away, like al-Assad?”
I already had some very basic aircraft-tracking code from an unrelated experiment. Nothing fancy. It could look up aircraft and put them on a map. So naturally my first reaction was not “That is ridiculous.” My reaction was:
“Actually, I could build that.”
That sentence has cost me a lot of hours over the years.
The first Election Flight Watch
The first version was almost embarrassingly simple. There was a small watchlist. A public aircraft-data source was queried. If a watched aircraft returned coordinates, it was placed on a Leaflet map. That was about it.
There was no real flight model, no telemetry fusion and no persistent history worth mentioning. There was no continuity detection, no source provenance and no proper event engine. There was no distinction between a provider having heard from an aircraft and the provider having a valid position. But the basic concept worked.
And because the radio station was already part of the broader project, the site also incorporated its live stream. At that point the two projects started merging. The radio infrastructure helped the monitoring project, and the monitoring project created new use cases for the radio infrastructure.
A deliberate small-aircraft watchlist
I was never trying to build another FlightRadar24. There is no point. The large commercial tracking sites already do general aviation tracking extremely well. My problem was different. I wanted deep monitoring of a very small set of aircraft.
The eventual strict allowlist contained seven aircraft. That changes what you can do technically. Instead of processing tens of thousands of aircraft and throwing most information away, I could retain a much richer history for a tiny number of targets. The question was no longer:
Where are the planes?
It became:
What do we currently know about each watched aircraft, how old is that information, which provider supplied each field, how confident are we, and what happened before and after this state?
That is a much more interesting engineering problem.
One provider is never enough
The first thing I learned was that public aviation tracking is inconsistent. Provider A sees the aircraft. Provider B does not. Then Provider B suddenly gets the better position. Provider C has a more recent callsign. One service exposes extra navigation fields, another reports MLAT data, and another updates faster in a particular area.
So eventually I stopped thinking of a provider as the database. I started thinking of each provider as a sensor. The live pipeline became roughly:
ADSB provider 1
ADSB provider 2
paid / backup provider
|
v
normalization
|
v
comparison
|
v
field-level fusion
|
v
current aircraft state
The tracker can retain provenance per telemetry field. That means a current state does not need to come entirely from one source. Position may have one provenance, callsign another, and navigation state a third. And every value has an age.
Free first, paid only when useful
I generally prefer self-hosted and free systems where possible. The aircraft tracker follows the same philosophy. The main collector relies primarily on public sources. A commercial ADS-B provider is also available through an API gateway, but I deliberately wrapped that one in strict controls.
During ordinary pre-event monitoring it can make at most approximately one paid call every five minutes. During the special election-night window the allowable cadence can increase dramatically, to roughly one call every thirty seconds. There are local counters and hard-stop limits. The API key never goes to the browser. The public website cannot freely spend quota. Only controlled server-side code can invoke it. That protects both the key and my wallet.
A 30-second collector became a 24/7 recorder
The backend eventually became a permanent systemd service. Approximately every thirty seconds it performs another collection cycle. Responses are normalized. Useful telemetry is fused. Aircraft states are updated. History is written. Events may be generated, and notification queues may be filled. A typical cycle can run completely unattended.
The browser is irrelevant to collection. I can close every webpage and turn off every monitor, and the history continues. That separation between collector and frontend became fundamental.
SQLite turned the tracker into a recorder
The next major change was persistent history. I chose SQLite. For a seven-aircraft watchlist it is almost ideal. It is simple, fast, transactional, portable, easy to back up and easy to inspect. And it avoids adding an unnecessary standalone database service.
As the project evolved, the schema grew substantially. At one migration point the existing database already contained more than twenty recorded flights and over fifteen hundred historical position points. The flight table had grown to dozens of fields. The position-point schema had grown even larger because telemetry, source information and quality data were being preserved alongside the basic coordinates.
At that moment I realized I was no longer writing a map application. I was building a flight telemetry database.
“Seen” does not mean “position known”
One of the most important lessons came from a confusing notification. A provider could report a watched aircraft. The system would announce that contact had returned. But no aircraft appeared on the map. At first glance that looked broken. It was not. The provider knew something about the aircraft but did not have a usable fresh latitude and longitude.
The mistake was mine. I had treated “provider contact” and “usable geographic position” as the same state. They are not. So the whole state model was redesigned around four concepts:
NO CONTACT
The system has no current useful feed contact.
CONTACT ONLY
A provider is returning the aircraft, but there is no sufficiently fresh usable position.
POSITION LIVE
The system has a valid current geographic position.
COVERAGE GAP
The aircraft belongs to a known active flight, but public tracking has temporarily lost geographic coverage.
That distinction fixed both the user interface and the notifications.
Never invent the missing part of a flight
Coverage gaps created another problem. Suppose you have:
real position A
[37 minutes with no useful public position]
real position B
It is extremely tempting to draw a line and let the user mentally treat that as the aircraft’s path. But the system does not know that path. It knows the start. It knows the end. Everything between them is unknown. So I made a hard rule:
Never fabricate intermediate aircraft positions.
Observed route distance is kept separate from any simple estimated distance across the gap. The interface can indicate that continuity probably exists without pretending it knows where the aircraft travelled inside the gap. That distinction matters enormously in a system whose graphics increasingly resemble a professional surveillance display.
Smart Flight Continuity
A disappearing transponder feed does not mean an aircraft landed. Public ADS-B coverage can disappear for all sorts of reasons. So a flight cannot simply end because the last point became old. The system now maintains coverage gaps inside active flights.
When the aircraft returns, continuity logic looks at evidence such as:
- same aircraft identity
- previous flight state
- time elapsed
- distance travelled
- implied average speed
- plausible aircraft speed envelope
- callsign consistency
- heading consistency
- airborne / ground state
If the return is plausible, the same logical flight continues. If it is not plausible, the old flight can be closed and a new one created. That logic prevents a single real journey from becoming three fake flights simply because public reception disappeared twice.
Notifications made every weak assumption visible
Once Discord and Telegram were connected, bad state modelling became impossible to ignore. A strange event in a database is easy to miss. A phone notification every five minutes is not.
One aircraft exposed a particularly noisy pattern. Its position would disappear while provider contact remained. A few minutes later coordinates returned. The tracker sent POSITION ACQUIRED. Then it happened again. And again. Technically true. Operationally annoying.
So another timing rule was introduced. A short contact-only interruption does not deserve a notification. The current concept is:
contact only < 10 minutes
update website
do not notify
contact only >= 10 minutes
position returns
POSITION ACQUIRED
proper extended coverage gap
position returns
REACQUIRED
Notification systems should reduce uncertainty. They should not become the main source of noise.
Discord and Telegram became proper outputs
The platform eventually gained both Discord and Telegram notification pipelines. They have queueing and retry behaviour rather than assuming every webhook works on the first attempt. Events can include things such as:
- airborne detected
- ground confirmed
- contact detected
- position acquired
- reacquired
- squawk changes
- coverage events
- special alerts
One deliberate privacy decision was to omit exact latitude and longitude from public notification text. The website can display the aviation data on a map. That does not mean every coordinate needs to be pushed permanently into messaging platforms.
The tracker got its own radar
At some point sensible UI design lost a battle against my inner twelve-year-old. I added a radar sweep. The reference station is fixed around Belgrade. The sweep is projected across the visible Leaflet map rather than being a decorative static circle. Aircraft can ping when the sweep reaches them, but only if the state justifies it:
- POSITION LIVE + AIRBORNE: radar ping
- CONTACT ONLY: no ping
- GROUND: no ping
- COVERAGE GAP: no ping
The display also reports the number of watched aircraft currently airborne. Is this necessary? Absolutely not. Do I like it? Absolutely.
Satellite, street, topographic and night maps
The basemap selection grew as well. The site now supports:
- Satellite
- Night
- OpenStreetMap
- OpenTopoMap
The Night layer uses NASA night imagery. That feature introduced a memorable debugging exercise. The JavaScript was correct. The requested map tile returned HTTP 200. Yet the browser showed a blank map.
The problem was Content Security Policy, again. I had changed the NASA tile hostname but forgotten that the active CSP still allowed only the old hostname. The browser was doing exactly what I had instructed it to do: block the new map. After updating CSP, everything appeared instantly. Later OpenTopoMap was added, requiring another controlled CSP change.
Automatic night mode
I then decided that if the interface was about Belgrade, the map should know whether Belgrade was actually in daylight. The site queries a weather/time API for astronomical is_day status at the Belgrade reference coordinates. If the site opens during the night there, it can automatically select the Night basemap.
It does not use the visitor’s location. And if the visitor manually chooses another map before the automatic decision arrives, the visitor wins. I hate software that fights its user because an automatic preference thinks it knows better.
The Heatmap that wasn’t really a heatmap
For quite a while there was a Heatmap button. One day I actually inspected the old implementation. It was hilarious. The browser started with:
const heatPoints = [];
Then it added current airborne aircraft positions while the page remained open. Refresh the browser? History gone. Open the site while nothing is flying? Almost nothing to see. That was not historical analysis. It was a glorified live trail. So I replaced it with a real historical Heatmap.
A real SQLite-backed historical Heatmap
The current Heatmap asks the local history database for stored position samples. The visitor can choose:
- 24 hours
- 7 days
- 30 days
- 90 days
- all history
and either all watched aircraft or one specific aircraft.
The default uses airborne positions only. That matters because otherwise an aircraft sitting at an airport for hours can create an enormous hot spot that visually overwhelms the actual routes. The server aggregates samples into rounded geographic cells and gives each cell a weight. On the client, the intensity is normalized logarithmically so high-density areas do not erase lower-density corridors.
During one production test, the 30-day view returned approximately:
- 1,533 historical airborne samples
- 1,471 heat cells
- 3 aircraft represented
Not huge data by Big Data standards. More than enough to create a useful picture of movement patterns. And importantly, no paid ADS-B calls are made to create it. The data was already collected locally.
Of course the Heatmap immediately failed
The API worked. SQLite worked. The response was correct. The page displayed:
L.heatLayer is not a function
The cause? The HTML requested leaflet-heat.min.js, while the installed package version exposed leaflet-heat.js. That was it. One missing .min. Hours of backend architecture undone by six characters.
PHP caching provided the next trap
Another odd situation occurred during the Heatmap work. I uploaded a new PHP API file. The file on disk clearly contained the new code. PHP’s syntax checker said it was valid. Yet HTTP requests continued returning the old response.
The website was running PHP 8.3 through PHP-FPM. The old code was still cached. Restarting the correct PHP-FPM service fixed it immediately. That generated a useful operating rule:
new PHP visibly on disk
+
old behaviour over HTTP
=
check PHP-FPM cache first
Do not start rewriting working code because a cache is lying to you.
Three levels of interface
As telemetry increased, I ran into another problem: too much information. It is very easy to build a dashboard that is impressive to its author and completely unusable to everybody else. So I started separating the frontend conceptually into three levels.
The first is the simple view. It answers:
- Is the aircraft currently known?
- Is there a live position?
- Is it airborne?
- How old is the data?
Then comes Mission Control. That can expose things such as:
- altitude
- ground speed
- Mach
- heading
- vertical rate
- callsign
- position source
- selected altitude
- navigation state
And then there is Deep Telemetry. That is where the serious source/provenance information belongs. The browser does not need to scream fifty telemetry fields at every visitor all the time.
Mobile needed its own compromises
The desktop interface is increasingly intended for large displays. A mobile phone is different. For example, the historical Heatmap is useful on a desktop monitor but adds clutter to the mobile toolbar. So the Heatmap control is intentionally hidden below the mobile breakpoint. That does not remove the underlying functionality. It simply accepts the reality that mobile interface design is not desktop interface design scaled down.
Then I added live television
Because apparently radio, aircraft tracking, history, notifications and radar were not enough. I wanted to incorporate a live video source into Mission Control. The source comes from live video production software. The transport from the production workstation to my NAS uses SRT. The NAS runs MediaMTX. MediaMTX produces HLS. Caddy exposes that HLS securely over HTTPS. The simplified chain is:
Video source
|
| SRT
v
10 GbE NAS
|
MediaMTX
|
| Low-Latency HLS
v
Caddy
|
HTTPS
|
v
Website / HLS.js
The video is currently:
- 1280 x 720
- 29.97 fps
- H.264
- AAC audio
- roughly 2 Mbit/s
The direct public HLS stream typically sits only around two to three seconds behind the video source. For a home-hosted chain, I am quite pleased with that.
Making HTTPS coexist with existing services
My home connection was already serving other HTTPS traffic. The NAS video service could not simply take over TCP 443. So I put an HAProxy layer at the edge. It performs SNI-based TCP routing. Conceptually:
Internet TCP :443
|
v
Home router / NAT
|
v
HAProxy
|
| hostname = TV service
+----------------------> Caddy -> MediaMTX
|
| all other HTTPS
+----------------------> existing NAS / services
HAProxy does not need to decrypt all the traffic just to determine where the TLS connection belongs. The TV hostname reaches Caddy. Existing services continue through the previous path. That allowed me to insert a new public video origin without destroying the network architecture that was already working.
The video was live, but the website drifted
Direct HLS was about two to three seconds behind. The embedded player was sometimes noticeably further behind. That was an important clue. It meant that the chain from the video source through SRT and MediaMTX to HTTPS was already fine, so there was no point “optimizing” the server chain. The extra latency was inside the browser player.
The site uses HLS.js with Low-Latency mode enabled. Eventually I added a small live-edge correction. When playback resumes, the code checks hls.liveSyncPosition. If the video is more than roughly 1.5 seconds behind that position, it seeks forward. Conceptually:
const lag =
hls.liveSyncPosition -
video.currentTime;
if (lag > 1.5) {
video.currentTime =
hls.liveSyncPosition;
}
After that, the embedded video behaved much better. This is one of my favourite kinds of fixes. Do not optimize the five components that are already working. Fix the one that is not.
The radio and aircraft systems eventually started talking to each other
Once both projects existed inside the same broader Mission Control environment, the obvious integration opportunities appeared. An aircraft event could trigger a radio sting. The radio interface could be controlled from the same authenticated admin environment. Election Mode could alter metadata presentation. A Breaking News or Aircraft Alert trigger could duck the station audio, play an insert, then restore the underlying programme.
The architecture remained deliberately separated. The aircraft collector does not depend on the radio. The radio does not depend on the map. The video server does not control the database. They communicate through restricted interfaces. If one fails, it should not bring down the others. That principle has probably prevented more problems than any particular programming trick.
The home lab behind all of this
One reason this project could grow so quickly is that I was not starting with a laptop and a cheap hosting account. I already had a substantial home lab. Not enterprise infrastructure, but certainly more than the average home network.
The main workstation
The primary Windows machine is built around an AMD Ryzen 9 7950X. That means sixteen Zen 4 CPU cores and thirty-two threads available for development, media work, virtual machines, AI workloads and video production. The graphics card is an NVIDIA RTX 4070 Ti with 12 GB of VRAM. Memory is currently 64 GB DDR5-6000. Storage is approximately 10 TB of local SSD capacity, including NVMe and additional multi-terabyte SSDs. There is a 1000 W Gold-class power supply and substantial cooling.
This is also the kind of workstation on which running live video production software, development tools, local AI models and a ridiculous number of browser tabs simultaneously does not immediately become unpleasant.
The main NAS
The newer NAS is a UGREEN NASync DXP4800 Plus. It has been upgraded to 64 GB RAM. Storage consists of four 8 TB hard drives, plus two 1 TB SSDs used for fast storage/cache duties. Most importantly for the media side, it has 10 GbE.
That machine has gradually become a small services platform rather than merely network storage. Docker workloads running across the NAS environment have included things such as:
- MediaMTX
- Portainer
- n8n
- WireGuard
- Jellyfin
- Immich
- mail archiving
- other internal services
The live SRT video receiver and HLS origin now live there as well.
The second NAS
An older Synology NAS remains part of the environment. It already handled existing network and HTTPS-related services, so instead of ripping everything out when the new NAS arrived, I let the systems coexist. That decision is the reason the later HAProxy SNI solution made sense. New TV traffic could be selectively routed to the newer NAS while established HTTPS services continued towards the older one. There is an important homelab lesson in there:
Replacing something because something newer exists is not automatically an improvement.
The network connection
The home internet connection is 8 Gbit/s symmetric fibre. Not 8 down and 1 up. Not 8 down and 500 Mbit up. Approximately 8 Gbit/s in both directions. That is hilariously excessive for listening to internet radio. It is also extremely convenient when your house contains:
- multiple NAS systems
- servers
- VPN users
- self-hosted websites
- video streaming
- large file transfers
- AI workloads
- remote access
- media libraries
- automation
The main storage server has 10 GbE, so the internal network is capable of moving data fast enough that the internet connection itself is no longer absurdly faster than everything behind it. Does this project require 8 Gbit/s? Absolutely not. Does having it make self-hosting more enjoyable? Absolutely.
Local AI infrastructure
The lab also includes local AI services. Models are served locally through Ollama. There is a vector database for embeddings. A separate embeddings service runs dedicated models. Search and automation services are self-hosted. There are also systems such as n8n and Home Assistant.
None of those is essential to the aircraft collector itself. But they are part of the environment that makes experimentation cheap. I can prototype something without first deciding whether I want another monthly SaaS subscription. That has always been my preferred way of working: run it locally where practical, understand how it works, and connect external services only where they genuinely add something.
What runs where?
As the project grew, I deliberately did not put everything on one machine. The rough separation is now:
HOME WORKSTATION
- development
- live video production
- local AI
- media production
HOME 10 GbE NAS
- Docker
- MediaMTX
- SRT ingest
- HLS origin
- storage
- supporting services
SECOND NAS
- legacy/existing network services
- existing HTTPS paths
RADIO VPS
- Liquidsoap
- Icecast
- MP3/AAC
- DVR
- now-playing
- timeline
- radio automation
WEB / TRACKER SERVER
- PHP application
- aircraft collector
- SQLite
- admin interface
- APIs
- Discord/Telegram processing
The separation was not designed on day one. It emerged naturally. And that is often how good homelab architecture happens. You move a workload when you finally understand what that workload needs.
Security became more important as the toy became real
The original aircraft page was a hobby script. The current environment is a public-facing application with:
- authentication
- API credentials
- remote control
- webhooks
- SSH
- media services
- public APIs
- historical data
That changes the threat model. Secrets therefore stay server-side. Paid API credentials do not go to JavaScript. The public history API uses controlled database access. Remote radio commands use restricted SSH and forced commands rather than arbitrary shell access. Admin operations use authentication and CSRF protection. Exact coordinates are deliberately omitted from messaging alerts. Content Security Policy restricts what the browser can load.
None of those measures is particularly exotic. But together they are the difference between a fun demo and something I am willing to leave running 24/7.
Even the service worker became part of operations
The tracker is installable as a Progressive Web App. That is useful. It also means caching becomes part of deployment. When JavaScript or CSS changes, it is not enough to upload a new file and hope. Assets carry explicit version strings. The service-worker cache gets bumped. Otherwise yesterday’s JavaScript can survive with astonishing determination.
At the time of writing, the project has already moved through enough iterations that I finally gave the frontend a conventional visible version number. Current generation: v5.0.2. The fact that this thing now has semantic-style versioning probably tells you everything you need to know about how far the joke went.
There is even an LED matrix side project
Because every serious aviation monitoring centre obviously needs a small LED display. I have an AWTRIX-compatible matrix on the network. Manual testing confirmed that it can receive HTTP notifications from the local environment, flash an aircraft alert and even play a short RTTTL sound. A test message can effectively turn the little display into “AIRCRAFT DETECTED”, with blinking text and an alert tone.
The plan is to keep any eventual integration one-way. The display can listen to tracker events. The tracker should never care whether the display exists. Again: optional integrations must remain optional.
What I learned from the entire project
The biggest lesson is not about ADS-B. It is not about Liquidsoap. It is not about SRT or HLS. It is about boundaries.
When the Night map was broken, I did not need to restart the aircraft collector. When PHP served stale code, I did not need to reboot the server. When the video was six seconds further behind inside the browser, I did not need to reconfigure SRT. When cover art failed to display, I did not need to rewrite the metadata resolver. When an aircraft lost position, I did not need to declare the flight over. Each of those problems becomes much easier once you identify which layer actually owns it.
The architecture now reflects that philosophy:
collection is collection
history is history
notifications are notifications
radio automation is radio automation
video delivery is video delivery
the frontend is the frontend
They cooperate. They are not the same thing.
The complete picture
Today the system looks approximately like this:
PUBLIC AVIATION SOURCES
/ | \
/ | \
Free ADS-B Free ADS-B Paid backup
\ | /
\ | /
TELEMETRY FUSION
|
v
30-SECOND COLLECTOR
|
+------------+------------+
| |
v v
SQLite Event Engine
| |
+-----+------+ +----+----+
| | | |
v v v v
Flights Telemetry Discord Telegram
|
+----------+
|
v
History API
|
+----------+-----------+
| | |
v v v
Map Radar Heatmap
|
v
Mission Control
|
+--------------------------+
| |
v v
Radio Player Live Video
| |
| |
v v
Radio VPS HLS.js
Liquidsoap ^
Icecast |
DVR |
Metadata |
Timeline |
|
Video ----SRT----> Home NAS ----MediaMTX
|
Caddy
|
HTTPS
And underneath all of that:
Ryzen 9 workstation
RTX-class GPU
64 GB workstation RAM
roughly 10 TB workstation SSD storage
64 GB 10 GbE NAS
4 x 8 TB HDD
2 x 1 TB SSD
second NAS
8 Gbit/s symmetric fibre
Linux VPS infrastructure
Docker
PHP 8.3
SQLite
Liquidsoap
Icecast
MediaMTX
Caddy
HAProxy
Leaflet
HLS.js
All because I volunteered to help somebody with a radio station. And because one evening my wife asked:
“What if the president gets on a plane and disappears?”
The dangerous phrase: “That should be easy”
I had already written a simple aircraft tracker. So I thought:
“I can adapt that. That should be easy.”
That may be the most dangerous sentence in software engineering. Because “easy” became:
- a streaming relay,
- two audio codecs,
- a DVR,
- a metadata service,
- cover artwork,
- Liquidsoap automation,
- a remote control layer,
- an election mode,
- a flight collector,
- multiple ADS-B providers,
- field-level telemetry fusion,
- SQLite history,
- flight reconstruction,
- coverage gaps,
- continuity analysis,
- Discord,
- Telegram,
- a radar,
- four map styles,
- automatic night mode,
- a historical heatmap,
- a PWA,
- a video production pipeline,
- SRT,
- MediaMTX,
- Low-Latency HLS,
- SNI routing,
- a live-edge video correction,
- and potentially a tiny LED aircraft-warning system.
It is completely disproportionate to the original question. Which is precisely why I have enjoyed building it so much.
Sometimes the most interesting projects are not the ones you plan. They are the ones where every solved problem reveals a slightly more interesting one underneath. And apparently, in my case, they eventually end up with a version number.
v5.0.2.
For now.