Running modules
Everything goes through one *gorpitx.RPITX value. You get it from GetInstance, pass a module name and JSON arguments to Exec, and optionally stream output or stop the process from another goroutine. The signatures are in gorpitx.go.
func GetInstance() *RPITX
func (r *RPITX) Exec(ctx context.Context, name ModuleName, args []byte, timeout time.Duration) error
func (r *RPITX) Stop(ctx context.Context) error
func (r *RPITX) StreamOutputs(stdout, stderr chan<- string)
func (r *RPITX) StreamOutputsAsync(stdout, stderr chan<- string)
func (r *RPITX) GetSupportedModules() []ModuleName
func (r *RPITX) IsSupportedModule(name ModuleName) bool
Getting the instance
GetInstance builds the instance on its first call and returns the same pointer forever after. The first call reads GORPITX_PATH and, outside dev mode, panics if the program is not running as root. It also panics if the config cannot be parsed.
GetSupportedModules returns the 12 registered names in no fixed order (it walks a map). IsSupportedModule checks one name. Both take the rpitx binary name, so IsSupportedModule("pift8") is true and IsSupportedModule("ft8") is false. Use the ModuleName* constants rather than strings.
Exec
args, err := json.Marshal(gorpitx.PICHIRP{
Frequency: 434000000, // Hz
Bandwidth: 100000, // Hz
Time: 5, // seconds
})
if err != nil {
return err
}
err = rpitx.Exec(ctx, gorpitx.ModuleNamePICHIRP, args, 0)
args is JSON. Marshal the module’s struct as above, or send JSON you received from somewhere else; the field names are on each module page. Unknown fields are ignored.
Exec blocks, and it goes through these steps in order:
- If another
Execis running, it returnsErrExecutingat once. Only one module runs at a time. - It looks up the module (
ErrUnknownModuleif the name is not registered) and calls itsParseArgs, which unmarshals and validates the JSON. A validation error comes back wrapped withfailed to parse args, and nothing starts. - It starts the process:
stdbuf -oL <GORPITX_PATH>/<name> <args>, a script under/tmpforfskandaudiosock-broadcast, or the shell loop in dev mode. Modules that feed the program on stdin (pocsag,fsk) pass that data here. - It waits for the process to exit, then kills anything still running in it and clears the busy flag.
The timeout argument decides how the wait ends:
timeout | What happens |
|---|---|
0 | Exec waits until the process exits on its own, or until ctx is cancelled. |
> 0 | When the timeout passes, Exec sends SIGTERM to the process group, gives it 3 seconds, sends SIGKILL if it is still alive, and returns commonerrors.ErrTimeout. |
If the program exits by itself before the timeout, Exec returns nil for status 0. A non-zero status comes back wrapped with failed to wait for process, as commonerrors.ErrFailed with the exit code and the program’s stderr in the message.
Cancelling ctx kills the process. Exec then returns an error that wraps commonerrors.ErrKilled.
Some programs never exit by themselves: tune holds a carrier, audiosock-broadcast streams until the socket closes, sendiq with loopMode repeats the file, and every module in dev mode loops. Give those a timeout, a cancellable context, or call Stop.
Arguments persist between calls
Each module is one struct inside the instance, and ParseArgs unmarshals into it without resetting it first. A field you set in one call and leave out of the next keeps its old value:
| Call | JSON sent to tune | Command line |
|---|---|---|
| First | {"frequency": 434000000, "ppm": 2.5} | tune -f 434000000 -p 2.5 |
| Second | {"frequency": 144000000} | tune -f 144000000 -p 2.5 |
Until that changes, send every field you care about on each call. To clear an optional field, send it as null, or as "" or false for the plain string and bool fields such as pi or loopMode.
Stopping a transmission
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
err := rpitx.Stop(ctx)
Stop sends SIGTERM to the running process group and waits for it to exit. When ctx has no deadline it waits 3 seconds. If the process is still alive after that, it gets SIGKILL. Call it from another goroutine while Exec is blocked.
What it returns:
| Situation | Stop returns |
|---|---|
| Nothing is running | ErrNotExecuting |
| The process exited on SIGTERM | an error wrapping commonerrors.ErrTerminated |
| The process needed SIGKILL | an error wrapping commonerrors.ErrKilled |
| The process exited cleanly | nil |
A successful graceful stop is reported as an error wrapping ErrTerminated, so check for it with errors.Is instead of treating every non-nil result as a failure. The blocked Exec returns an error wrapping the same sentinel.
Streaming output
Each line the process writes to stdout or stderr can go to a channel. There are two ways to attach one.
StreamOutputsAsync can be called before Exec. It starts a goroutine that waits until a process is running and then attaches the channels:
stdout := make(chan string, 100)
stderr := make(chan string, 100)
rpitx.StreamOutputsAsync(stdout, stderr)
go func() {
for line := range stdout {
fmt.Println("stdout:", line)
}
}()
err := rpitx.Exec(ctx, gorpitx.ModuleNameMORSE, args, 30*time.Second)
StreamOutputs attaches the channels right now, and only works while a process is running. Called while idle, it logs a warning and does nothing, so you have to call it from another goroutine after Exec has started:
go func() {
err := rpitx.Exec(ctx, gorpitx.ModuleNameMORSE, args, 30*time.Second)
if err != nil {
log.Println(err)
}
}()
time.Sleep(100 * time.Millisecond)
rpitx.StreamOutputs(stdout, stderr)
The sleep is a guess, which is why StreamOutputsAsync is the better choice.
Rules that hold for both:
- The channels are closed when the process exits, so a channel pair serves one
Exec. Make new channels for the next one. - Use buffered channels and read them promptly. If a send blocks for more than 100 ms, that channel is dropped from the stream and never closed, and a
rangeover it hangs. - Lines written before the channels are attached are not replayed.
StreamOutputsAsyncpolls every 10 ms until a module starts. If noExecfollows, its goroutine keeps polling for the life of the program.- The production command runs under
stdbuf -oL, so the program’s stdout arrives line by line instead of in 4 KB blocks.
Concurrency
The instance is safe to share between goroutines. Exec uses an atomic flag, so two concurrent calls never both start a process: the second gets ErrExecuting. Stop and the stream methods read the current process under a lock. There is no queue; if you want transmissions to wait their turn, serialize the calls yourself.