SatMe control station API — version 1
SatMe runs a small HTTP server on the phone. The control station web page is just one client among others: any program that can make an HTTP request can do the same, on Windows, Linux, macOS or elsewhere.
This document freezes the version 1 contract.
1. Principles#
Two doors, two tokens. /d/<jeton>/ is the public door: read-only, with a token meant to be shown as a QR code. /c/<jetonCommande>/ is the control station: it writes to the log, so its token never leaves the phone, and a six-digit code is required on top of it.
Everything is in clear text, on the local network. That is proportionate for a demo hotspot or a home network. For access from the internet, use a tunnel — WireGuard, for example — and certainly not port forwarding.
The version only changes if a client breaks. Adding a route or a field doesn't change the number: a program written for version 1 keeps working. Removing a field or changing its meaning does.
2. Connecting#
The operator enables the control station under Settings → Share and listen → Share (French UI: Réglages → Partage et écoute → Partage). The app then shows an address of the form:
http://192.168.1.42:8080/c/nkq7wz3d5f/and a six-digit code. This address is the base for all the requests that follow.
Finding out who you are talking to#
GET <base>/api
→ {"api":1,"satme":"20.53"}No authentication. Call it first: a client must be able to check the version before presenting a code.
Opening a session#
GET <base>/entrer?code=123456
→ {"ok":true,"cle":"a1b2c3…","api":1}
→ {"ok":false} (code refusé, réponse retardée d'une seconde)A rejected code gets {"ok":false}, with the response delayed by one second.
The returned key is valid for three hours. It goes with every subsequent request as the cle parameter. An expired or unknown key gets a 403 with {"ok":false,"raison":"session"} — the client must then ask for the code again.
If the bridge to the app isn't up yet — which happens during startup — routes answer {"ok":false,"raison":"pasPret"}.
3. Reading#
Current state#
GET <base>/etat?cle=…{
"station": "F4IOZ", "grille": "JN18FS",
"sat": "ISS",
"az": 226.4, "el": -59.0,
"rx": 437801100, "tx": 145989000,
"antaz": 231.0, "antel": 2.0,
"aos": 1790254285126, "los": 1790254885126,
"elmax": 41.0, "now": 1790254200000,
"ptt": false, "son": true,
"heure": "09:21:31 UTC",
"trace": [[118.0, 1.2], [126.0, 8.4]],
"qso": [{"h": "09:18", "c": "F5RRS", "l": "JN36EB"}],
"img": 3
}Frequencies are in hertz, Doppler-corrected. aos and los are milliseconds since 1970: compare them with now, the phone's clock, to get an accurate countdown even if the PC's clock drifts. antaz and antel are null when no compass is connected. trace is the pass track, as azimuth/elevation pairs.
Tracked satellites#
GET <base>/sats?cle=…
→ ["ISS","SO-50","JO-97"]Callsign suggestions#
GET <base>/propose?cle=…&q=F5R
→ [{"c":"F5RRS","l":"JN36EB","n":"Jean Dupont","q":4}]Three at most, ranked by contact frequency, recency, and a bonus if the callsign has already been worked on the current satellite. Nothing is suggested below two characters. q is the number of contacts already made.
QRZ lookup#
GET <base>/qrz?cle=…&call=F5RRS
→ {"ok":true,"c":"F5RRS","l":"JN36EB","n":"Jean Dupont",
"f":"Jean","v":"Quimper","p":"France","e":""}f is the first name only, v the city, p the country, e the error, if any. Requires a QRZ XML subscription configured in the app. The quota is the subscription's: a client that queries on every keystroke will use it up. SatMe caches responses, but the client should still space out its requests.
4. Acting#
Logging a contact#
GET <base>/qso?cle=…&call=F5RRS&loc=JN36EB&rse=59&rsr=59
→ {"ok":true}loc, rse and rsr are optional; RST defaults to 59. The contact is filed under the currently tracked satellite: if none is selected, the response is {"ok":false}.
Pass recording#
GET <base>/rec?cle=…&on=1 (1 démarre, 0 arrête)
→ {"ok":true}on=1 starts recording, on=0 stops it.
Switching satellite#
GET <base>/sat?cle=…&nom=SO-50
→ {"ok":true}
→ {"ok":false} (nom inconnu des satellites suivis){"ok":false} means the name isn't among the tracked satellites. The name must match one of those returned by /sats, case-insensitive.
SSTV sheets#
The page <base>/planche (no code to serve it; its requests need cle) lays out the SSTV pictures received by the phone. Its routes:
GET <base>/planche/liste?cle=… → {"modeles":[{"id","nom"}…],"indicatif","nom"}
GET <base>/planche/modele?cle=…&id=… → {"texte":"planche=1\n…","ratio","fond","recues","manquent":[…],
"locator","dates","titre"}
POST <base>/planche/enregistre?cle=…&id=… (body: the template's text) → {"ok":true}
GET <base>/planche/nouveau?cle=…&type=grille|tour → {"ok":true,"id":"…"}
POST <base>/planche/importe?cle=…&nom=… (body: a JPEG or PNG, 25 MB at most) → {"ok":true,"id","cases"}
GET <base>/planche/remplit?cle=…&id=…&sat=ISS → {"ok":true,"cases"}
GET <base>/planche/nombre?cle=…&id=…&n=4&sat=ISS → {"ok":true,"cases"} (n=0: the template's boxes)
GET <base>/planche/bascule?cle=…&id=…&f=… → {"ok","cases"} (ok false: the sheet is full)
GET <base>/planche/galerie?cle=… → [{"f","s","m","t","c","d"}…] (file, satellite, mode, time, complete, live)
GET <base>/planche/vignette?cle=…&f=…&w=320 → image/jpeg
GET <base>/planche/apercu?cle=…&id=…&w=1400[&ind&nom&loc&dates] → image/jpeg
GET <base>/planche/exporte?cle=…&id=…[&ind&nom&loc&dates] → image/png (attachment)
GET <base>/planche/decors?cle=… → [{"id","nom","nasa"}…] (the backgrounds offered)
GET <base>/planche/decor-vignette?cle=…&d=…&w=240 → image/jpeg
POST <base>/planche/decor?cle=…&id=… (body: a picture) → {"ok":true,"decor":"decor_….jpg"}
POST <base>/planche/ecusson?cle=…&id=… (body: a picture) → {"ok":true,"n"} (ok false, raison "plein": 3 already)
GET <base>/planche/modele-exporte?cle=…&id=…[&indicatif=1] → application/zip (attachment, SatMe_Modele_….satme-planche)
POST <base>/planche/modele-importe?cle=… (body: a .satme-planche, 25 MB at most) → {"ok":true,"id","nom","cases"}
(ok false, raison: zip, manifeste, version, chemin, image, taille)A template file (.satme-planche) is a zip: manifeste.json ({"format":"satme-planche","version":1,"lisiblePar":1,"nom","fichiers":[…],"planche":"planche=1\n…"}, the template's text form without its image= lines) and its pictures by plain name — fond.jpg (an imported template), decor.jpg, ecusson1.png…, the patches already cut out. A reader refuses a file whose lisiblePar is above its own version. indicatif=1 writes the station's callsign as a free text; otherwise the callsign and name @ locator stay fields. importe also takes such a file (it starts with PK). An import never replaces a template: a new id, the name made unique (« (2) »), the pictures written under new names.
The template travels in the text form the phone keeps it in (planche=1, case=x;y;w;h, texte=…, image=box;file, positions in fractions of the sheet). A template sent back keeps its own background: the client cannot point it at another file. vignette serves only pictures of the gallery. The preview and the sheet are drawn by the phone, with the drawing of its own screen.
A sheet can hold fewer pictures than its template: nombre fits it to n pictures (those on it kept, the rest the best received, one copy of each slide), in the grid that makes them largest; bascule puts a picture on a fitted sheet or takes it off. The template's boxes are kept as origine= lines (and its texts as texteorigine=, when fitting moved them), to go back to with n=0; a client sends back the lines it does not know as it read them.
The background of a generic sheet is a decor= line: one offered (decors) or the name of a picture sent with decor, always fitted to the sheet (filled, centred, cut rather than stretched). Patches, three at most, are ecusson=file;x;y;w;h;contour lines (contour 1: the plain surround made transparent), laid over everything else. A sheet sent back can only name files of the sheets folder; older versions ignore these lines.
Pass journal#
The page <base>/journal replays the passes kept by the phone. Its routes (read only, apart from what the phone makes):
GET <base>/journal/liste?cle=… → {"utc","indicatif","passages":[{"id","sat","de","a","el","son","q","i","r","s","cat","sm","rec","auto"}…]}
GET <base>/journal/passage?cle=…&id=… → {"id","sat","de","a","loc","tp","el","pts":[[t,az,el]…],"prevue":[[az,el]…],
"marques":[{"k","de","a","t","d","iss","f"}…],"sons":[{"n","nom","o","an","du"}…],
"sig":[[t,s]…],"freq":[[t,hz]…]}
GET <base>/journal/activite?cle=…&id=… → [[de,a]…] (activity heard in the sound)
GET <base>/journal/fiches?cle=…&id=… → {"EA4XYZ":{"nom","qth","pays","loc"}…} (who the stations are)
GET <base>/journal/segments?cle=…&id=…[&avant&apres&rapide&surActivite=1] → [[de,a,speed]…] (accelerated replay)
GET <base>/journal/reglages?cle=… → the phone's replay and export settings (read only)
GET <base>/journal/tuile?cle=…&z=…&x=…&y=… → image/png, an OpenStreetMap tile fetched and kept by the phone
GET <base>/journal/son?cle=…&id=…&n=0 → audio/mpeg, served by parts (Range)
GET <base>/journal/image?cle=…&f=… → image/png (a picture of the gallery)
GET <base>/journal/fabrique?cle=…&id=…&quoi=son|image|video|gif|paquet[&de&a&titre&carte=1&t] → {"ok":true,"travail":"…"}
options, "1"/"0" (else the phone's settings): res=XS|M|HD, ouverture, recap, avecSon, accelere, sstv, fiches, smetre, freq, locator
GET <base>/journal/travail?cle=…&n=… → {"fini","progres","ok","nom","taille"}
GET <base>/journal/fichier?cle=…&n=… → the file made (attachment)Times are UTC milliseconds. A sound file's position 0 is at o; its spoken header ends at o + an. What is made comes from the phone's own Share tab.
5. What the API doesn't do#
This is deliberate, and the list matters as much as the rest.
It doesn't control the radio. No frequency, no PTT, no mode. Transmitting puts the operator's license on the line, and it must not be triggered by an HTTP request on a shared local network.
It doesn't change the app's settings (only the name written on SSTV sheets), nor the log beyond adding a contact. Nothing can be deleted over the network.
It doesn't expose an audio stream on the control station. Audio goes through the public page — /d/<jeton>/son.pcm, 16-bit mono PCM at 22,050 Hz — and only while a pass is being recorded.
The exposed surface fits in three files, demo/PontCommande.kt, and for the SSTV sheets and the pass journal, demo/PlancheWeb.kt and demo/JournalWeb.kt: readable in one sitting. A surface you can read is a surface you can defend.
6. Writing a client#
Nothing more than an HTTP client is needed. In Python:
import requests
BASE = "http://192.168.1.42:8080/c/nkq7wz3d5f"
assert requests.get(f"{BASE}/api").json()["api"] == 1
cle = requests.get(f"{BASE}/entrer", params={"code": "123456"}).json()["cle"]
etat = requests.get(f"{BASE}/etat", params={"cle": cle}).json()
print(etat["sat"], etat["az"], etat["el"])
requests.get(f"{BASE}/qso", params={"cle": cle, "call": "F5RRS",
"loc": "JN36EB"})Three tips learned from writing the web page.
Polling the state once per second is enough: telemetry is only computed at that rate in the app, and polling faster gets you nothing fresher.
Treat a 403 as a return to the code screen, not as a failure.
Never steal the operator's cursor while they're typing. During a pass, every move back to the mouse is a lost contact.
Contract version 1, frozen at SatMe 20.53. Compatible changes will be added without changing this number.