Configuration
hamctl.toml
The server’s own settings live in the home’s hamctl.toml (created by hamctl init):
bind = "0.0.0.0:8080" # all interfaces; auth protects it
services_dir = "/opt/hamctl/services" # absolute, so it runs from any cwd
password_hash = "$argon2id$..." # set via `hamctl set-password`
use_systemd = false # false = built-in supervisor; true = systemd
config_root = "" # substituted for {root} in manifest paths
log_root = "" # substituted for {log_root} in monitor paths
The file holds the password hash, so hamctl init creates it chmod 600.
Path tokens
Manifests use tokens so one manifest set serves a device and development unchanged:
{root}→config_root. On a Pi-Star device setconfig_root = "/etc"so a manifest path like{root}/mmdvmhostresolves to/etc/mmdvmhost; in development point it at a directory of sample configs.{log_root}→log_root, plus{date}(UTCYYYY-MM-DD) — used by monitor log paths, e.g.{log_root}/MMDVM-{date}.log. Setlog_rootto where the daemon logs (on Pi-Star,/var/log/pi-star).{arch}→ the host architecture token (amd64/aarch64/…).
Process control
Each service is run either by the built-in supervisor (hamctl spawns it, captures
logs to a ring buffer, and auto-restarts on crash) or by systemd (the unit is
<service-id>.service) — selected globally by use_systemd. A manifest with no [run]
block is controlled purely via systemd.
cleanup lists files the supervisor deletes immediately before every (re)launch —
typically a PID/lock file. Some daemons (e.g. the AFRN client) write a PID file and then
refuse to start if it already exists; on a crash they leave it behind, so with
auto_restart enabled the next launch sees the stale file, aborts with “already
running?”, and the daemon wedges in a restart loop. Listing the PID file here breaks that
loop. Paths accept the {config}/{root}/{arch} tokens; relative paths resolve against
the working directory, absolute paths are used as-is.
The manifest schema
A service is a manifest.toml. The UI renders forms from it; no recompile needed.
[service]
id = "myservice" # also the systemd unit basename
name = "My Service"
category = "service" # "os" lists it under "OS Configuration" instead
[run] # omit entirely for systemd-only services
binary_glob = "bin/myservice-*" # {arch} expands to amd64/aarch64/…
args = ["--config", "{config}"]
auto_restart = true
cleanup = ["/run/myservice.pid"] # files removed before each (re)launch
[[config]]
id = "main"
label = "myservice.conf"
path = "{root}/myservice.conf"
format = "ini" # ini | raw | records
[[config.sections]]
name = "General"
# ini_section = "" # edit a section-less / shared INI section
[[config.sections.fields]]
key = "Listen"
label = "Listen address"
# type: text | password | number | bool | enum | textarea | path | file
Config formats
ini— section/key, edited field-by-field; comments and layout are preserved byte-for-byte and only changed values are rewritten. Bool fields map to literaltrue_value/false_value(AFRN usesYes/No; Pi-Star uses1/0). Set several sections toini_section = ""to present a flatkey=valuefile as readable groups.records— a delimited table (pipe or whitespace) edited as a responsive, paginated grid; the file’s comment header is kept.raw— opaque text edited in a textarea.
A file-typed field backed by a [[files]] set gets a suggestion list of files that
exist in that managed directory; uploads are size-capped and filenames sanitized.
Host device pickers
A field that names a host device can offer the machine’s real devices as suggestions, while still letting you type a value (handy when the device is unplugged or you’re configuring remotely):
[[config.sections.fields]]
key = "OutDevice"
devices = "audio_out" # serial | audio_in | audio_out
device_prefix = "ALSA:" # prepended to each suggestion (AFRN audio uses ALSA:)
hamctl enumerates serial ports (/dev/serial/by-id/*, ttyUSB*/ttyACM*/…) and ALSA
audio devices (from /proc/asound) and serves them at GET /devices/<kind>; the field
becomes an editable dropdown. AFRN’s InDevice/OutDevice and MMDVMHost’s modem Port
use this.
Managed files
[[files]] # browse / upload / replace / delete via the UI
id = "sounds"
label = "Sounds"
path = "{root}/sounds"
accept = [".wav"] # allowed extensions; empty = any
See Services for real examples (AFRN, the Pi-Star stack), and
Monitoring for the [[monitors]] blocks.