A Linux-native replacement for the PSTRotator + VK5DJ combo, for Sun/Moon (and manual az/el) dish tracking. Talks to the VK5DJ Beam/EME Rotator Controller over its native serial protocol -- no Windows, no Wine, no VM.
Built from John Drew VK5DJ's own published interface document ("How to interface to the VK5DJ Rotator Controller", 2009-11-02) and his reference Delphi sample, not from reverse-engineering any third-party program.
- Computes Sun and Moon azimuth/elevation for your site using Skyfield and the JPL DE421 ephemeris.
- Feeds that to the VK5DJ controller over serial (9600 8N1) in its native "Basic Interfacing" format -- the controller does its own tracking arithmetic, same as it does today with PSTRotator.
- Reads back the controller's actual antenna position.
- Serves a local dashboard (default
http://<host>:8420) with:- a live polar sky-view (antenna vs. target, plus the Sun/Moon's path for the next 12h),
- Sun/Moon rise & set times for the next 24h,
- mode switching (Sun / Moon / Manual az-el / Off),
- a world map shaded with the region currently having the Moon above the horizon.
When the current Sun/Moon target sets below limits.el_min, tracking
doesn't keep slewing azimuth to chase the body's real (invisible, below-
horizon) position -- it holds the beam at the upcoming rise azimuth,
parked at el_min, so physical movement actually stops while the antenna
stays ready for the moment the body rises again. The dashboard's target
marker still shows the true (below-horizon) position while this is
happening; only what's actually sent to the controller changes.
Remote/direct beam-steering mode (VK5DJ's "Remote Mode") is deliberately not implemented -- the interface doc itself recommends against it "unless there is a special reason", and Basic Interfacing (send target az/el, let the controller move the beam) is simpler and is what PSTRotator uses too.
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txtCopy the template and edit it -- config.yaml is gitignored since it holds
your exact site coordinates and hardware-specific serial device path:
cp config.example.yaml config.yamllocation:
lat: -34.9 # your latitude, decimal degrees
lon: 138.6 # your longitude, decimal degrees
elevation_m: 50
serial:
port: /dev/ttyUSB0 # wherever the VK5DJ controller shows upFind your serial device with ls /dev/serial/by-id/ (preferred -- stable
across reboots) or dmesg | tail after plugging it in.
First run downloads the ~17 MB DE421 ephemeris file (needs internet once):
python run.pyThen open http://<host>:8420 in a browser.
A systemd unit is in systemd/vk5dj-tracker.service. Adjust the paths/user,
then:
sudo cp -r . /opt/vk5dj-tracker
sudo useradd -r -G dialout vk5dj # dialout group -> serial port access
sudo cp systemd/vk5dj-tracker.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now vk5dj-trackervk5dj_tracker/
protocol.py serial protocol driver (the VK5DJ wire format)
astro.py Sun/Moon az/el, rise/set, sub-lunar point (Skyfield)
worldmap.py moon-visibility-by-country geometry
tracker.py background thread linking controller <-> astronomy, shared state
web.py FastAPI app (REST API + static dashboard)
static/ dashboard (vanilla HTML/CSS/JS, no build step, no CDN deps)
horizon_profile.json your site's surveyed horizon overlay (gitignored, optional)
tools/
horizon_csv_to_profile.py converts a horizon-survey.csv into horizon_profile.json
run.py entry point
config.yaml your site + serial port + web port
- The moon-visibility world map shades the actual geometric visibility region (per-pixel, against the sub-lunar point), ignoring atmospheric refraction and the Moon's parallax (both on the order of a degree) -- it's for orientation, not a link-budget tool.
- Country outlines (
vk5dj_tracker/static/world.geo.json) are derived from Natural Earth (public domain) via github.com/johan/world.geo.json. - The controller's clock is synced once per connection using the optional
time-calibration packet from the interface spec;
time_syncedon the dashboard reflects whether the controller echoed it back correctly. - Set
limits.el_minto your real local horizon, not just 0 -- it's not only a safety floor, it's also the elevation the "waiting for rise" parking behavior above uses and the below-horizon cutoff for deciding a target has set. If terrain/trees/buildings block your actual sky below a few degrees, set this to match (e.g. 4 degrees).
Beyond the flat limits.el_min floor, the sky-plot can also show your
site's actual surveyed horizon (buildings, trees, terrain) at each
azimuth, as an amber shaded region -- purely a visual reference on the
dashboard, not something the tracker's own driving logic reads or is
constrained by. Useful because a single flat elevation limit either
wastes visible sky in open directions or risks pointing at obstructions
in blocked ones, and because obstruction from foliage genuinely changes
with the seasons (a summer survey with full leaf cover isn't the same
as what's actually blocked in winter).
1. Survey the horizon with on6zg/horizon-survey -- a phone-based tool (camera + compass/tilt) for walking your site and recording the obstruction elevation at each direction. Export a CSV when done.
2. Convert the CSV to this app's overlay format:
python3 tools/horizon_csv_to_profile.py path/to/horizon_survey_*.csvThis writes vk5dj_tracker/static/horizon_profile.json. No other setup
needed -- it's served as a plain static file and picked up on the next
dashboard page load, no app restart required. Deploying it to a running
instance is just copying that one file into place (e.g.
pct push <ctid> vk5dj_tracker/static/horizon_profile.json /opt/vk5dj-tracker/vk5dj_tracker/static/horizon_profile.json
if you're running under an LXC container the way this station does).
If you don't have a horizon_profile.json, the overlay and its legend
entry simply don't appear -- this is entirely optional.
vk5dj_tracker/static/horizon_profile.json is gitignored (same as
config.yaml -- it's your own site's exact obstruction survey);
vk5dj_tracker/static/horizon_profile.example.json shows the format.
Reading the overlay: segments connecting two closely-spaced survey points (within 15 degrees of azimuth) are drawn as a solid line; wider gaps (nowhere nearby actually measured) are drawn dashed and fainter, so a stretch of "no real data, straight-line guess between two distant points" reads differently from an actually-measured obstruction edge.
If you want limits.az_min/az_max in config.yaml to match the
controller's own configured mechanical stops (menu items 16/17, "AZ High
Stop"/"AZ Low Stop"), don't use the menu numbers directly -- per the VK5DJ
manual, azimuth stops are stored as half their degree value (1 unit = 2
degrees; enter 90 to mean 180 degrees). So:
actual_degrees = menu_value * 2
Elevation stops (menu items 18/19) store true (unhalved) degree values, no
conversion needed. Hysteresis (menu items 14/15) is in 0.1-degree units (a
value of 3 = 0.3 degrees), which is the controller's own "close enough,
stop the motor" tolerance -- independent of, and generally larger than,
this app's deadband_deg.