# The Station Monitor

At the end you have one page on your home network that shows every feed of your station. A checker tells you when a feed stops. Part A uses free public programs. Part B is the design of the program I wrote, as a recipe you can follow in any language.

Spurious Emissions · Rev A · checked 2026-10-10 · https://spurious-emissions.net/guides/station-monitor/

Some links are affiliate links: if you buy through them, I may earn a commission at no extra cost to you. As an Amazon Associate I earn from qualifying purchases.

## 1. What this guide builds

A station like mine has one program for each signal, and each program has its own web page. This guide puts all of them on one page. It also adds the part that a page of numbers does not have: a check that each feed is still alive.

The guide has two parts. You can stop after Part A.

| Part | What you get | Programs | Where it runs |
|---|---|---|---|
| A, sections 4 to 9 | One page with a link and a status light for each feed, and a checker that can alert you | Homepage, docker-socket-proxy, Uptime Kuma | The computer that already runs your decoders |
| B, sections 10 to 16 | Your own page: one number for each feed, a live list, a map, two days of history, a wall mode | A program you write. Mine uses Python, FastAPI, SQLite and Leaflet | Any computer on the home network |

The page in the episode is a program I wrote, called Station Monitor. Its code is private, so I cannot give it to you. Part A is the free public way to do most of the same job. Part B tells you how mine is designed, so you can write your own.

```
 feeds (each has its own program)             one page               you
 --------------------------------             --------               ---
 aircraft map ----------+
 ship map --------------+     PART A
 message log -----------+---- Homepage: link + status light ------- browser
 other containers ------+        |  reads container state through
                        |        |  docker-socket-proxy (read only)
 shortwave computer ----+---- Uptime Kuma: asks each feed again ---- alert
   (no Docker: it sends          and again, marks it UP or DOWN      (optional:
    a heartbeat)                                                      phone)

                              PART B
 each feed ---- adapter ----- number, event, position ---- database ---- page,
               (asks again    + health: ok, error, stale   (two days)    map,
                and again)                                               wall mode
```

The computer names in this guide are the names from [Build the station](/guides/build-the-station/): `vhf` runs the decoders in Docker containers, and `hf` runs the shortwave receiver with no Docker. If your computers have other names, use your names.

## 2. What you need

You need no new radio part. You need a station that already receives something, and a computer on the home network.

### Hardware

A part marked ◆ is what I use. A blank box is for your pencil.

| ☐ | Part | Job | Price | Note |
|---|---|---|---|---|
| ☐ | The computer that already runs your decoders (`vhf`) | Runs the page and the checker | Nothing new | This is the lowest-cost path |
| ☐ | ◆ [ZimaBlade](https://www.zimaspace.com/products/blade-personal-nas) small server | Runs my own page | See listing | A second computer is not necessary |
| ☐ | [Raspberry Pi 5](https://www.amazon.com/dp/B0CK2FCG1K?tag=spuriousemiss-20), 8 GB | A good `vhf`, if you have none | $175 list price, read for the station guide on 2026-10-07 | I did not measure the memory that Part A adds |
| ☐ | Any screen with a web browser | The wall screen (optional) | Use what you have | My own wall screen is not on the wall yet |

### Software for Part A

All of these are free and public.

| ☐ | Program | Job | Where it comes from |
|---|---|---|---|
| ☐ | [Homepage](https://gethomepage.dev) | The one page: a link, a status light and a number for each feed | `ghcr.io/gethomepage/homepage` |
| ☐ | [docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy) | Lets Homepage read container state, and nothing more | `ghcr.io/tecnativa/docker-socket-proxy` |
| ☐ | [Uptime Kuma](https://github.com/louislam/uptime-kuma), version 2 | The uptime checker: asks each feed again and again | `louislam/uptime-kuma:2` |
| ☐ | [Home Assistant](https://www.home-assistant.io/) | Sends an alert to a phone (optional) | Only if you already run it |

### Software for Part B

| ☐ | Program | Job | Note |
|---|---|---|---|
| ☐ | ◆ [Python](https://www.python.org/) | The language of my program | Any language that can read a web address works |
| ☐ | ◆ [FastAPI](https://fastapi.tiangolo.com/) | Serves the page and its data | |
| ☐ | ◆ [SQLite](https://www.sqlite.org/) | The one-file database that keeps two days | |
| ☐ | ◆ [Leaflet](https://leafletjs.com/) | Draws the map | |

### What the lowest-cost path gives up

Part A on the computer you already have costs nothing. It gives up these things:

- It shows the state of a program, not what the station hears. A number needs more work (section 5).
- It has no live list of events, no map of its own, and no history of your numbers.
- If `vhf` stops, the page and the checker stop with it. Then nothing tells you.

## 3. Before you start

**Skills.** You can log in to `vhf` with SSH. You can edit a text file there. You know the `docker compose up -d` command from the station guide. Part B needs a person, or an AI coding assistant, that can write a small program.

**Time.** I did not time Part A, because I did not run it. Part B is open-ended. My own program was less than two weeks old when I made the episode.

**Accounts.** None. Uptime Kuma asks you to create a local administrator name and password on its first page.

**Values to choose first.**

| Placeholder | Meaning | Example form |
|---|---|---|
| `<HOST>` | The name or address you type in the browser to reach `vhf` | `vhf`, `vhf.local`, or a fixed address |
| `<PUSH_URL>` | The address that Uptime Kuma gives a Push monitor | Copy it from the monitor's page |
| `<NAME>` | A page title with no personal detail | `my-station` |

**The law, in one paragraph.** I am not a lawyer. This is for the United States. Everything here is receive only. US law (47 U.S.C. section 605) says that you must not pass on the contents of a radio message that is not meant for the public. A dashboard can show the text of aircraft messages. So keep the page on your home network, and do not publish a picture of it that shows message text.

> **Warning.** A dashboard is a map of your home network, and a Docker socket is full control of the computer. Never open this page, the checker, or a Docker socket to the internet. Do not forward anything to them in your router. If you must see the page from outside, use a VPN to your home network. I did not test that, and this guide does not cover it.

## 4. Part A: start the page

Program: Homepage. It is one web page that you describe in a few text files. It can ask Docker which containers run and which are healthy.

A Docker container is one program in its own sealed box. The Docker socket is the file through which a program gives Docker orders. A program that can write to the socket can start, stop and change every container. So Homepage does not get the socket. It gets a proxy that lets only read requests through.

> **Caution.** Read access is not harmless. A read request can return the settings of each container, and those settings hold your feeder keys. Give the proxy no `ports:` line. Then only the containers in the same compose file can reach it.

1. Log in to `vhf`. Open the compose file from the station guide.

   ```bash
   cd /opt/station && nano docker-compose.yml
   ```

2. Add these two services under `services:`.

   ```yaml
     dockerproxy:
       image: ghcr.io/tecnativa/docker-socket-proxy:latest
       container_name: dockerproxy
       restart: unless-stopped
       environment:
         - CONTAINERS=1   # allow reading the list and state of containers
         - POST=0         # refuse every request that changes something
       volumes:
         - /var/run/docker.sock:/var/run/docker.sock:ro

     homepage:
       image: ghcr.io/gethomepage/homepage:latest
       container_name: homepage
       restart: unless-stopped
       ports:
         - 3000:3000
       environment:
         - HOMEPAGE_ALLOWED_HOSTS=<HOST>:3000
       volumes:
         - /opt/station/homepage:/app/config
   ```

   `HOMEPAGE_ALLOWED_HOSTS` is required. It is the exact text you type in the browser, with the number. For two names, write `vhf:3000,vhf.local:3000` with a comma and no space.

3. Make the settings folder and start both containers.

   ```bash
   mkdir -p /opt/station/homepage
   docker compose up -d
   docker ps --filter name=homepage --filter name=dockerproxy
   ```

   Result: both containers show `Up`.

4. Open `http://<HOST>:3000/` in a browser on the home network.

   Result: a page with example entries. The folder `/opt/station/homepage` now holds the settings files, among them `services.yaml`, `docker.yaml` and `settings.yaml`.

5. Tell Homepage where the proxy is. Put these three lines in `/opt/station/homepage/docker.yaml`.

   ```yaml
   station:
     host: dockerproxy
     port: 2375
   ```

   `station` is a name you choose. The next section uses it.

6. Give the page a title. Put these two lines in `/opt/station/homepage/settings.yaml`.

   ```yaml
   title: <NAME>
   statusStyle: "dot"
   ```

   `statusStyle: "dot"` shows each status as a colored dot. Leave the line out to see the status as a word.

These settings files are YAML. In YAML the spaces at the start of a line carry meaning. Use spaces, never tabs.

## 5. Part A: one tile for each feed

A tile in Homepage is called a service. Each service has a name, a link, and optional checks. You write them in `services.yaml`.

1. Replace the content of `/opt/station/homepage/services.yaml` with your feeds. This example has the feeds of the station guide. Delete what you do not run.

   ```yaml
   - Aircraft:
       - Aircraft map:
           href: http://<HOST>:8080/
           description: readsb and tar1090
           server: station
           container: ultrafeeder
           siteMonitor: http://ultrafeeder/
       - Aircraft alerts:
           href: http://<HOST>:8088/
           description: planefence
           server: station
           container: planefence

   - Ships:
       - Ship map:
           href: http://<HOST>:90/
           description: AIS-catcher
           server: station
           container: shipfeeder
           siteMonitor: http://shipfeeder/

   - Aircraft messages:
       - Message log:
           href: http://<HOST>:8081/
           description: ACARS Hub
           server: station
           container: acarshub
           siteMonitor: http://acarshub/
       - ACARS decoder:
           server: station
           container: acarsdec
       - VDL2 decoder:
           server: station
           container: dumpvdl2
       - Message router:
           server: station
           container: acars_router
   ```

2. Save the file. Load the page again in the browser.

   Result: one tile for each feed. Each tile has a status from Docker. A tile with `siteMonitor` also shows how long its web page took to answer.

What each line does:

- `href` is the link in the browser, so it uses `<HOST>` and the number from the station guide.
- `server` and `container` connect the tile to one container, by the name in `container_name`.
- `siteMonitor` is a web address that Homepage itself requests. Homepage runs inside the compose network, so it uses the container name and no number.

### What the status means

| Status | Meaning |
|---|---|
| healthy | The container runs, and its own health check passes |
| running | The container runs. It has no health check |
| starting | The health check did not give a result yet |
| unhealthy | The container runs, and its own health check fails |
| exited, restarting, not found | The container does not run |

A health check is a small test that the maker of a container puts inside it. Docker runs it on a timer. The containers in the station guide have slow timers. The ACARS, VDL2 and ACARS Hub containers check every 10 minutes and wait one hour after a start. The ship container checks every 2 minutes. The message router has no health check, so it only shows "running".

So the status can be late. A fault may show 10 minutes or more after it starts, and later after a new start. That is the reason for section 7.

### A number on a tile

Homepage can put a number from a feed on its tile. The widget is called `customapi`. It reads a JSON address and shows the fields you name. JSON is a plain text format for data that most of these programs offer.

1. Add these lines to the `Aircraft map` service, at the same depth as `siteMonitor`.

   ```yaml
           widget:
             type: customapi
             url: http://ultrafeeder/data/aircraft.json
             refreshInterval: 10000
             mappings:
               - field: aircraft
                 label: Aircraft now
                 format: size
               - field: messages
                 label: Messages since start
                 format: number
   ```

2. Save the file and load the page again.

   Result: the tile shows the count of aircraft and a message counter that rises.

`format: size` counts the entries of a list. `refreshInterval` is in milliseconds. For other feeds, open the feed's JSON address in a browser first, and pick a field. Section 14 lists the addresses that I confirmed.

## 6. Part A: a computer with no Docker

The shortwave computer `hf` in the station guide runs its receiver as system services. It has no containers and no web page. Homepage can show only that the computer answers.

1. Add a group for it in `services.yaml`.

   ```yaml
   - Shortwave:
       - Shortwave computer:
           description: ka9q-radio and its decoders
           ping: hf
   ```

2. Save the file and load the page again.

   Result: the tile shows a response time when `hf` is on.

This says that the computer is on. It does not say that the receiver or a decoder works. Section 7 adds a heartbeat that does.

Your containers may run on more than one computer. Then each of those computers needs its own proxy, and the network must reach that proxy. Then every device on your home network can read your container settings through it. I do not recommend that. Use the heartbeat of section 7 for the second computer.

## 7. Part A: the uptime checker

Program: Uptime Kuma. An uptime checker asks each feed a question again and again, keeps the answers, and tells you when the answers stop. In Uptime Kuma one question is called a monitor.

> **Caution.** The Uptime Kuma wiki says to give it the Docker socket to check containers, and warns that this gives full control of Docker. Do not do that. This guide points it at the read-only proxy from section 4.

1. Add this service to `/opt/station/docker-compose.yml`.

   ```yaml
     uptime-kuma:
       image: louislam/uptime-kuma:2
       container_name: uptime-kuma
       restart: unless-stopped
       ports:
         - 3001:3001
       volumes:
         - /opt/station/uptime-kuma:/app/data
   ```

   Keep the data folder on a local drive. The project says that network file systems are not supported.

2. Start it.

   ```bash
   cd /opt/station && docker compose up -d
   ```

3. Open `http://<HOST>:3001/` in a browser. Choose the SQLite database when the first page asks. Create the administrator account.

   Result: an empty dashboard with the button "Add New Monitor".

### Check that a container is healthy

1. Select "Add New Monitor". Set "Monitor Type" to "Docker Container".
2. Select "Set Up Docker Host". Set "Connection Type" to "TCP / HTTP".
3. Enter the proxy as the "Docker Daemon": `http://dockerproxy:2375`. Save the host.
4. Enter `ultrafeeder` as "Container Name / ID". Save the monitor.

   Result: the monitor shows UP with the message "healthy".

5. Repeat steps 1 and 4 for each container. The Docker host is already there.

This monitor is DOWN when the container does not run, or when its health check says "unhealthy". A container with no health check is UP while it runs.

### Check that the aircraft data is fresh

This is the check that the status light cannot do. The aircraft file has a field `now`: the time when the decoder wrote the file. If the decoder stops, the web server can still serve the old file.

1. Add a monitor. Set "Monitor Type" to "HTTP(s) - Json Query".
2. Enter the address `http://ultrafeeder/data/aircraft.json`.
3. Enter this as the "Json Query Expression".

   ```
   $millis() / 1000 - now < 60
   ```

4. Set the comparison to "equals" and the "Expected Value" to `true`. Save the monitor.

   Result: UP while the file is less than 60 seconds old.

The expression is in a small language called JSONata. `$millis()` is the checker's own clock in milliseconds. Uptime Kuma compares the result and the expected value as text. I did not run this monitor.

### A heartbeat from the shortwave computer

A Push monitor works the other way round. The checker asks nothing. The computer calls an address when all is well. If the calls stop, the monitor goes DOWN.

1. Add a monitor. Set "Monitor Type" to "Push". Set "Heartbeat Interval" to 180 seconds.
2. Copy the "Push URL". This is `<PUSH_URL>`. It has the form `http://<HOST>:3001/api/push/<token>`.
3. Save the monitor.
4. Log in to `hf`. Run `crontab -e` and add this one line.

   ```
   * * * * * systemctl is-active --quiet radiod@hf && curl -fsS -m 10 "<PUSH_URL>?status=up&msg=OK" >/dev/null
   ```

   Result: after one minute the monitor shows UP. Each minute, `hf` calls the address only if the receiver service is active.

5. Make a second Push monitor for a decoder. Use its own `<PUSH_URL>` in a second line.

   ```
   * * * * * find /var/log/ft8.log -mmin -3 | grep -q . && curl -fsS -m 10 "<PUSH_URL>?status=up&msg=OK" >/dev/null
   ```

   This line calls the address only if the FT8 log file changed in the last 3 minutes.

The second monitor is the important one. The receiver service can be active while a decoder writes nothing. Section 13 explains this trap. Use the same line for `/var/log/hfdl.log` and `/var/log/wspr.log`, with a longer time for WSPR: it decodes once every 2 minutes.

### Put the checker on the page

1. In Uptime Kuma, make a status page with a group that holds your monitors. Note the last part of its address, the slug.
2. Add a tile to `services.yaml`.

   ```yaml
   - Station:
       - Uptime checker:
           href: http://<HOST>:3001/
           widget:
             type: uptimekuma
             url: http://uptime-kuma:3001
             slug: <your slug>
   ```

   Result: the tile shows how many monitors are up and how many are down.

## 8. Part A: an alert to your phone (optional)

My own page does not send alerts. My phone gets them another way. For a distress call or a storm warning, the decoder tells my home automation system, and that system alerts my phone. I tested that path with a test message. My station did not receive a real distress call.

That is the pattern: the page shows, another program alerts. I describe the pattern here, not my setup. With Part A there are two public ways to do it. Both need Home Assistant and its phone app.

| Way | Who decides | What you set |
|---|---|---|
| Uptime Kuma tells Home Assistant | Uptime Kuma. A monitor goes DOWN and it sends a notification | In Uptime Kuma, add a notification of the type "Home Assistant". It asks for the Home Assistant address, a long-lived access token, and the name of a notify action such as `mobile_app_xyz` |
| Home Assistant reads Uptime Kuma | Home Assistant. An automation acts when a monitor's sensor says `down` | In Home Assistant, add the "Uptime Kuma" integration (in Home Assistant since release 2025.8). It asks for the address of Uptime Kuma and an API key from its "Settings", "API Keys" page |

> **Caution.** A long-lived access token is a key to your whole Home Assistant. Keep it in a password manager. Never put it in a file that you share.

Uptime Kuma also sends to many other services with no Home Assistant, for example email. Its notification page lists them.

Set "Retries" on each monitor before you turn alerts on. It is the number of failed checks before the monitor is DOWN. One slow answer then does not wake you.

## 9. Part A: what it does not do

Be honest with yourself about what you have now.

- **It shows that a program runs, not that the radio hears.** A container can be "healthy" with a dead antenna cable. Only a number that you know, such as messages an hour, shows that.
- **It cannot compare with the last answer.** Uptime Kuma tests one answer against a fixed value. It cannot say "this counter did not rise".
- **The checker lives on the computer it checks.** If `vhf` loses power, the checker is silent. A Push monitor on a second computer, or a second copy of Uptime Kuma, covers that. I did not set that up.
- **It has no wall mode.** I found no setting in Homepage that shows one feed at a time.

If those limits matter to you, write your own. That is Part B.

## 10. Part B: the design, and the adapter

This is the design that the episode explains. It is a recipe. It has no code from my program, and it works in any language.

The hard part is that no two feeds look alike. One gives a list of aircraft. One gives a stream of messages. One makes pictures. A page that knows the details of every feed becomes a tangle.

So each feed gets one small piece of code, called an adapter. An adapter has one job: ask its source again and again, and return the answer in a common form. Everything after the adapter knows nothing about the source.

An adapter needs four things:

1. **A name**, such as `aircraft`.
2. **A wait time**: how long it waits between two questions.
3. **A question**: one function that asks the source and returns the answer. It has a timeout, so a silent source cannot hold it forever.
4. **A loop**: ask, store the answer, record the health, wait, ask again.

My wait times are these. Use them as a start.

| Feed | Wait between questions |
|---|---|
| Aircraft | 10 seconds |
| Ships | 15 seconds |
| Shortwave spots | 30 seconds |
| Weather maps | 2 minutes |
| The sun | 5 minutes |

Two rules keep this simple:

- **One adapter cannot break another.** Run each loop by itself. Catch every failure inside the loop. A feed that fails turns its own tile red and touches nothing else.
- **Not every feed can be asked.** Twelve of my thirteen adapters ask. The adapter for aircraft messages holds a connection open and listens to a stream. It still returns the same common form.

Build it in this order. Each step gives you something that works.

1. Write one adapter for the feed with the easiest data. For most stations that is the aircraft JSON file (section 14).
2. Print its answer in the common form of section 11.
3. Add the health state of section 12.
4. Add a second adapter. Check that a failure in one does not stop the other.
5. Add the database of section 15.
6. Add the page of section 16.

## 11. Part B: three shapes of answer

Every adapter answers in the same three shapes. One answer can hold any mix of them.

| Shape | What it is | Example | Where it goes |
|---|---|---|---|
| A number | A name, a value, a time | `aircraft_now`, 23 | A tile |
| An event | A time, a kind, one line of text | "weather map received" | The live list |
| A position | A latitude, a longitude, a kind, a label, a time | One ship | The map |

Numbers go to the tiles. Events go to the live list, and each new event goes to the top. Positions go to the map.

Three shapes are enough for my thirteen feeds. Resist a fourth. When a feed seems to need a new shape, it is usually a number and an event.

Decide two things for each number before you write the adapter:

- **Is it a level or a counter?** "Aircraft now" is a level. "Messages since start" is a counter: it only rises, and it returns to zero when the program starts again. For a counter, show the change in the last hour.
- **What does zero mean?** No ships at 3 in the morning can be true. No ships for a day is a fault. The health state, not the number, must tell these apart.

## 12. Part B: a health state for each adapter

A screen full of numbers has one danger. A number that stopped changing still looks fine. So every adapter also reports its health, and the health is separate from the numbers.

| State | Rule | The tile |
|---|---|---|
| ok | The last question got a good answer | Green light |
| error | The last question failed | Red light. The tile keeps its last good numbers |
| stale | The last good answer is older than three times the normal wait | Amber light |

To do this, the loop records two things after each question: the time of the last good answer, and the result of the last question.

Notes on the rules:

- **Keep the last good numbers on an error.** An empty tile tells you less than an old number with a red light. Show the age of the number beside it.
- **Why three times.** One late answer is normal on a home network. Three missed turns in a row is not. For the aircraft adapter, stale means more than 30 seconds with no good answer.
- **Stale catches what error cannot.** If the adapter's own loop stops, no question fails, so no error appears. Only the age of the last good answer shows it. So compute stale when the page asks, from the clock, and not inside the adapter.
- **Say why.** Store the text of the failure with the state, and show it on the tile: "timeout", "connection refused", "answer was not JSON".

## 13. Part B: the trap, and the second question

There is a trap. A receiver can keep answering after its decoder has died.

A feed is often two programs: one that decodes, and one that serves the results. When the decoder dies, the server still answers. It returns the last data it has. The question gets a good answer, the health is "ok", and the number never changes again.

The aircraft file shows the trap in a public program. A decoder writes `aircraft.json`, and a web server serves it. The readsb documents give the file a field `now`: the time when the file was made. If the decoder stops and the web server continues, the address can still return a complete file. Its `now` is old.

So a good answer needs a second test. The monitor asks each source a second question: are you alive?

1. **Find a field that proves fresh work.** A time that the decoder writes (`now`), or a counter that must rise (`messages`).
2. **Compare it.** A time: compare it with your own clock. A counter: compare it with the answer before.
3. **Fail the question if the test fails.** The adapter reports an error with the reason: "file is 140 seconds old".

For my shortwave receivers I went one step further. Each receiver has its own "are you alive" address. The receiver checks its own audio and its own workers. If something died, it says what, for example "no audio for 75 seconds". The monitor asks that address after every update, and the tile shows the reason. Those receivers are my own programs, so you cannot ask yours the same way. The idea carries over:

- **If you write the decoder, give it a health address.** It returns "fine", or a failure with one line that says what died.
- **If you did not write it, find its proof of fresh work.** A log file that grows is such a proof. The heartbeat lines in section 7 use it.

Keep the clocks right. A test that compares a time from another computer with your own clock fails if one clock is wrong. The station guide installs `chrony` for this.

## 14. Part B: where each feed gives its data

These are the addresses that I confirmed in each project's documents on 2026-10-10. `<HOST>` and the numbers are the ones from the station guide. Open each address in a browser first, and read what it returns.

| Feed | Program | Address | What it gives | Source |
|---|---|---|---|---|
| Aircraft | readsb with tar1090, in ultrafeeder | `http://<HOST>:8080/data/aircraft.json` | `now` (time of the file), `messages` (counter since start), `aircraft` (a list: `hex`, `flight`, `lat`, `lon`, `alt_baro`, `seen` and more) | readsb `README-json.md`. ultrafeeder README |
| Aircraft | readsb | `receiver.json` and `stats.json`, in the same `data/` folder | `receiver.json`: version, refresh time, and the receiver position if set. `stats.json`: counts for the last 1, 5 and 15 minutes | readsb `README-json.md` |
| Aircraft | ultrafeeder | `9274/tcp`, and `9273/tcp` with the telegraf image | Statistics in Prometheus format | ultrafeeder README |
| Ships | AIS-catcher, in shipfeeder | `http://<HOST>:90/api/ships.json` | The ships it tracks now | AIS-catcher web viewer document |
| Ships | AIS-catcher | `http://<HOST>:90/api/stat.json` | Station statistics | AIS-catcher web viewer document |
| Ships | AIS-catcher | `/api/path.geojson`, `/api/allpath.geojson` | Ship tracks as GeoJSON, a map format | AIS-catcher web viewer document |
| Ships | shipfeeder | `/metrics`, when `PROMETHEUS_ENABLE=true` is set | Statistics in Prometheus format | shipfeeder README |
| Aircraft messages | acars_router | `15550/tcp` (ACARS), `15555/tcp` (VDL2), `15556/tcp` (HFDL) | A stream: connect, and each message arrives as JSON | acars_router README |
| Any container | Docker, through the proxy of section 4 | `http://dockerproxy:2375/containers/<name>/json` | `State.Status` and, with a health check, `State.Health.Status` | Docker Engine API. Uptime Kuma reads the same fields |
| Shortwave decoders | ka9q-radio services | The log files `/var/log/ft8.log`, `/var/log/wspr.log`, `/var/log/hfdl.log` on `hf` | New lines while the decoder works | Build the station, sections 12 to 14 |
| The sun | NOAA | <https://services.swpc.noaa.gov/json/goes/primary/> | Public X-ray data from the GOES satellites | Build the station, section 15 |

Notes:

- **The aircraft file is the easy first adapter.** The number is the length of the `aircraft` list. The positions are `lat` and `lon` of each entry. The second question is the age of `now`.
- **An aircraft is in the file for a short time.** The readsb document says why. An aircraft stays only if a message arrived in the last 60 seconds.
- **`receiver.json` can hold your position.** Do not copy it to a page that others see.
- **The message stream needs one more line.** The router's stream is open only inside the compose network. To read it from another computer, add `- 15550:15550` (and the others) to the `ports:` of `acars_router`.
- **The stream adapter cannot be asked.** It listens. Its health rule is the same: the last good answer is the last message. Choose its "normal wait" from your own traffic, because ACARS comes in bursts.
- **I do not list the keys of the ship addresses.** The document names the addresses and not their fields.

> **Caution.** The message stream carries the text of real aircraft messages. Count them. Show the kind and the time. Do not put the text on a page that leaves your home network.

## 15. Part B: two days in one small database

The tiles remember two days. That is enough to draw a line for the last 24 hours, and to see what happened last night. I keep everything in one database file. The program is SQLite: a database that is one file on the disk, with no server to run.

1. Make one table for each shape: numbers, events, positions. Each row has the adapter name and a time.
2. Write each answer of each adapter to those tables.
3. Delete rows that are older than 48 hours. Do it on a timer, for example once an hour.
4. Make an index on the time column of each table. The page asks "the last 24 hours" often.
5. Let one part of the program own the writing. Many writers on one SQLite file get in each other's way.

Notes:

- **Do not store every answer of a fast feed.** Aircraft every 10 seconds is 17,280 rows in two days for one number. That is fine. A row for each aircraft each time is not. Store the count, and keep positions only for the map's short memory.
- **Some things are worth keeping longer.** Seven of my feeds also have an archive page that goes back 30 days: received pictures, text warnings, beacon counts. Those are small and few. Keep them in their own tables with their own time limit.
- **An archive needs time to fill.** Mine did not all hold 30 days when I made the episode. The program was too young.

My beacon archive is a calendar. Each square is one hour. A darker square means that more beacons were heard in that hour. One number for each hour is all it stores.

## 16. Part B: the page, the map and the wall mode

### The page

1. Serve one address that returns everything the page needs as JSON: each tile's numbers, each adapter's health, and the newest events.
2. Draw one tile for each feed: its name, one big number, a health light, and the age of the number.
3. Draw the live list under the tiles. Put each new event at the top.
4. Ask the address again every few seconds, or let the program send new events to the page as they come.

Put the feeds you look at most first. My first four tiles are aircraft, unusual aircraft, ships and aircraft messages. Four of my tiles also draw a line for the last 24 hours.

### The map

Every position goes to a map page. Mine has two views: the area around me, and the whole world. The world view holds the shortwave traffic. I draw it with Leaflet, a free map library for web pages.

> **Caution.** A map that is centered on your antenna shows where you live. Do not share a picture of the local view. On a page that others can see, do not draw a mark for the station.

### The wall mode

A page of thirteen tiles is too small to read from across a room. So the page has a second mode for a screen on the wall.

1. Show one scene at a time: one big number, and one plain sentence.
2. Move to the next scene after a fixed time. Mine is 20 seconds.
3. Skip a scene if the health of its feed is not ok. A wall screen must never show a dead number as if it were live.
4. Let one rare event take the screen. On mine, an unusual aircraft takes it for 30 seconds.

My scenes are planes, ferries, flights over the oceans, the shortwave world, everything nearby, the sun, the weather map, pictures and beacons.

To be honest: the screen is not on my wall yet. This mode ran only in a browser.

## 17. Run it unattended

For Part A:

1. Keep `restart: unless-stopped` on each service. The containers then start again after a power cut.
2. Update on your own schedule.

   ```bash
   cd /opt/station && docker compose pull && docker compose up -d
   ```

3. Copy the folders `/opt/station/homepage` and `/opt/station/uptime-kuma` to another computer now and then. They hold all your settings.

For Part B:

1. Run your program as a system service, so that it starts with the computer and starts again after a failure.
2. Keep the page on the home network only. Give it no login that you then expose: do not expose it.
3. Watch the size of the database file for the first week. It must stop growing after two days.

## 18. Check that it works

Do these checks in order. Each one has a wait time.

| Check | Do this | Pass | Wait |
|---|---|---|---|
| 1. The page | Open `http://<HOST>:3000/` | Your tiles, each with a status | At once |
| 2. The proxy reads | Look at the `ultrafeeder` tile | "healthy" | A few minutes after a start. "starting" for 10 minutes or more on the ACARS containers |
| 3. The proxy refuses | `docker exec homepage wget -qO- --post-data='' http://dockerproxy:2375/containers/acarshub/restart` | An error with `403` | At once |
| 4. The proxy is closed | From another computer: `curl -m 5 http://<HOST>:2375/version` | No answer | 5 seconds |
| 5. The number | Look at the aircraft tile | A count, and a message counter that rises | 10 seconds |
| 6. The checker | Open `http://<HOST>:3001/` | Each monitor UP | One heartbeat interval |
| 7. A real fault | `docker stop dumpvdl2` | The tile says "exited". The monitor goes DOWN. The alert arrives, if you set one | One interval, times your retries |
| 8. Repair | `docker start dumpvdl2` | The tile and the monitor return | 10 minutes or more for "healthy" |
| 9. The heartbeat | On `hf`: `sudo systemctl stop ft8-decode@1`, then start it again after the test | The decoder's Push monitor goes DOWN. The receiver's monitor stays UP | 3 to 6 minutes |

Check 3 uses the `wget` program inside the Homepage container. I did not confirm that the container has it. Checks 7 and 9 are the ones that matter. A monitor that never went DOWN in a test is not a monitor you can trust.

For Part B, do the same two tests on your own page: stop a decoder, and wait for "error" or "stale". Then stop your own adapter loop, and wait for "stale".

## 19. If it does not work

| Symptom | Likely cause | What to do |
|---|---|---|
| The browser shows a host validation error | `HOMEPAGE_ALLOWED_HOSTS` does not match what you typed | Run `docker logs homepage`. Put the host exactly as the log prints it in the setting. Run `docker compose up -d` |
| Every tile says "error" for its status | Homepage cannot reach the proxy | Check `docker.yaml`. Check that both containers are in the same compose file. Run `docker logs dockerproxy` |
| The proxy logs "permission denied" for the socket | A security system of the operating system blocks it | The proxy's README says to run it privileged in that case: add `privileged: true` to `dockerproxy` |
| A tile says "not found" | `container:` does not match `container_name` | Run `docker ps --format '{{.Names}}'` and copy the name |
| A tile says "running" and never "healthy" | That container has no health check | Normal for `acars_router`. Add a monitor in Uptime Kuma for its feed |
| "healthy", and the feed is dead | The health check is slow, or it does not test what failed | This is the trap of section 13. Add a freshness check or a heartbeat |
| "healthy", and an RTL-SDR Blog V4L decodes nothing | An old driver does not know its tuner (station guide, section 17) | `docker compose pull && docker compose up -d`. The container state did not show this fault on my station |
| The page stops after you edit a file | A tab, or wrong spaces, in a YAML file | Put the file through a YAML checker. Use spaces only |
| The `customapi` tile is empty | The field name is wrong, or the address is the browser address | Open the address in a browser and read the keys. Inside a container use the container name, such as `http://ultrafeeder/...` |
| The Docker monitor in Uptime Kuma fails | The Docker host address is wrong, or the proxy refuses | The dialog shows examples of the address form. `CONTAINERS=1` must be set on the proxy |
| The JSON query monitor is DOWN at once | The result is compared as text | The expected value is `true`, in small letters |
| The Push monitor is DOWN, and `hf` works | `hf` cannot find `<HOST>`, or the heartbeat interval is too short | Run the `curl` part of the line by hand on `hf`. Use the fixed address of `vhf`. Keep 180 seconds |
| The FT8 heartbeat fails for a short time, sometimes | The band was quiet, so the log did not change | Raise `-mmin -3` and the heartbeat interval together |
| Too many alerts | No retries, so one slow answer is a fault | Set "Retries" on the monitor |
| Part B: a tile is green with an old number | The source answers with old data | Add the second question of section 13 |
| Part B: one slow feed stops every tile | The adapters share one loop, or a question has no timeout | One loop for each adapter. A timeout on every question |
| Part B: the freshness test fails on a healthy feed | The two clocks differ | Install `chrony` on both computers |
| Part B: the database file never stops growing | The delete step does not run | Run it on a timer, and write its result to the log |

## 20. What I did wrong

These are the lessons the episode tells. The fact sheet supports them as the reasons for the design. It does not give a date for each one.

- **I trusted a number.** A number that stopped changing still looks fine. A page with no health light for each feed is a page of old numbers that you believe.
- **I asked one question, and it was not enough.** A receiver kept answering after its decoder had died. A good answer is not proof of life. Ask for proof of fresh work.
- **I expected the page to alert me.** It does not. A page works only when you look at it. Urgent things need another path, and you must test that path. Mine was tested with a test message only.
- **I built the wall mode before I had the screen.** It ran only in a browser. I do not know yet what it looks like across a room.
- **I said "thirty days" for a young program.** An archive holds only as many days as it ran.

## 21. Not run and not confirmed

Be strict with this list. It is long, because I use my own program and not Part A.

**Not run at all**

- **All of Part A.** My station does not run Homepage, docker-socket-proxy or Uptime Kuma. Every setting in sections 4 to 8 comes from each project's documents, read on 2026-10-10. No step was run on any computer.
- The compose services in sections 4 and 7, as written. They are built from the projects' own examples with three changes: the folders are under `/opt/station`, the proxy has no `ports:` line, and the Swarm settings are left out.
- The `customapi` tile for aircraft, the JSON query monitor, the two heartbeat lines, and the Uptime Kuma tile.
- Every check in section 18.
- Both ways to alert through Home Assistant in section 8. My own alerts come from my own decoders.
- Part B as a recipe. It describes the design of a program that runs on my station. Nobody built a second program from this text.

**Not confirmed**

- The exact form of the Docker host address in Uptime Kuma (`http://dockerproxy:2375`). The source code accepts a TCP or HTTP connection. The dialog shows examples.
- That the Homepage container has `wget`, for check 3 of section 18.
- That Homepage writes example settings files on the first start. Its source has a folder of them. I did not see it happen.
- That `stats.json` and `receiver.json` are served under `/data/` by ultrafeeder. The readsb document says the JSON files come from the `data/` path. The ultrafeeder README names only `aircraft.json`.
- That a web server keeps serving an old `aircraft.json` after the decoder stops inside ultrafeeder. The documents describe the file and its `now` field. I did not stop a decoder to see it.
- The fields inside the AIS-catcher addresses `/api/ships.json` and `/api/stat.json`. The document lists the addresses only.
- That those AIS-catcher addresses answer at the web page of the shipfeeder container. They are AIS-catcher's own addresses, and shipfeeder runs AIS-catcher.
- A data address for ACARS Hub. Its README documents none. I list the router's stream in its place.
- A data address for planefence. I did not look for one.
- What each container's health check tests, and how late it shows a fault. I read only the timer values in each Dockerfile.
- That the FT8 log changes at least every 3 minutes at all hours on every antenna.
- The memory and processor load that Part A adds to a Raspberry Pi 5.
- The price of a ZimaBlade. Read the listing.
- A wall mode in Homepage. I found none in the documents I read. I did not read all of them.
- Access to the page from outside the home network through a VPN.

**Simplified**

- "Asks again and again" is true for twelve of my thirteen adapters. The one for aircraft messages listens to a stream.
- My page has thirteen tiles and thirteen sources. They are two lists of the same length, not one list.

## 22. Sources

All read on 2026-10-10.

**The episode.** Its fact sheet, links and script (episode 12, "The Station Monitor"). My own program is private and has no link.

**The station.** [Build the station](/guides/build-the-station/), revision A, checked 2026-10-07: the compose file, the container names, the web page numbers, the log files and the traps.

**The page.** Homepage: <https://gethomepage.dev/installation/> · <https://gethomepage.dev/installation/docker/> · <https://gethomepage.dev/configs/docker/> · <https://gethomepage.dev/configs/services/> · <https://gethomepage.dev/configs/settings/> · <https://gethomepage.dev/widgets/services/customapi/> · <https://gethomepage.dev/widgets/services/uptime-kuma/>. The status words come from its source file `src/components/services/status.jsx`: <https://github.com/gethomepage/homepage>.

**The proxy.** <https://github.com/Tecnativa/docker-socket-proxy> (README: the settings `CONTAINERS` and `POST`, the security advice, the privileged note). Docker Engine API, the "inspect a container" request: <https://docs.docker.com/reference/api/engine/>, read as `api/swagger.yaml` in <https://github.com/moby/moby>.

**The checker.** Uptime Kuma: <https://github.com/louislam/uptime-kuma> (README, `compose.yaml`, and the source files `server/model/monitor.js`, `server/routers/api-router.js`, `src/pages/EditMonitor.vue`, `src/lang/en.json` for the monitor types, the Push address and the labels). Wiki, "How to Monitor Docker Containers": <https://github.com/louislam/uptime-kuma/wiki/How-to-Monitor-Docker-Containers>. JSONata: <https://jsonata.org/>.

**Alerts.** Home Assistant, the Uptime Kuma integration: <https://www.home-assistant.io/integrations/uptime_kuma/>. The RESTful integration, read and not used in the text: <https://www.home-assistant.io/integrations/rest/>.

**Feed data.** readsb JSON formats: <https://github.com/wiedehopf/readsb/blob/dev/README-json.md> · <https://github.com/wiedehopf/tar1090> · <https://github.com/sdr-enthusiasts/docker-adsb-ultrafeeder> · AIS-catcher web viewer: <https://jvde-github.github.io/AIS-catcher-docs/configuration/output/web-viewer/> · <https://github.com/jvde-github/AIS-catcher> · <https://github.com/sdr-enthusiasts/docker-shipfeeder> · <https://github.com/sdr-enthusiasts/acars_router> · <https://github.com/sdr-enthusiasts/docker-acarshub>. Health check timers: the `Dockerfile` of each sdr-enthusiasts container named in section 5.

**Part B building blocks.** <https://www.python.org/> · <https://fastapi.tiangolo.com/> · <https://www.sqlite.org/> · <https://leafletjs.com/>. I name them. This guide uses no detail from their documents.

**Rules.** 47 U.S.C. 605: <https://www.law.cornell.edu/uscode/text/47/605>, as cited in the station guide. I did not read it again.
