first push of

This commit is contained in:
legop3
2025-11-09 21:15:13 -05:00
parent b6bc4674f4
commit 3a91e0cff0
24 changed files with 2952 additions and 1 deletions
+15
View File
@@ -0,0 +1,15 @@
BIN_DIR ?= ../../dist
GOOS ?= linux
GOARCH ?= arm
GOARM ?= 6
.PHONY: build pi-build clean
build:
go build -o $(BIN_DIR)/roverd ./cmd/roverd
pi-build:
GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/roverd ./cmd/roverd
clean:
rm -f $(BIN_DIR)/roverd
+83
View File
@@ -0,0 +1,83 @@
package roverd
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"time"
)
type BRCPulser struct {
cfg BRCConfig
logger *log.Logger
}
func NewBRCPulser(cfg BRCConfig, logger *log.Logger) (*BRCPulser, error) {
if err := exportGPIO(cfg.GPIOPin); err != nil {
return nil, err
}
if err := writeGPIO(cfg.GPIOPin, "direction", []byte("out\n")); err != nil {
return nil, err
}
if err := writeGPIO(cfg.GPIOPin, "value", []byte("1\n")); err != nil {
return nil, err
}
return &BRCPulser{cfg: cfg, logger: logger}, nil
}
func (b *BRCPulser) Close() {
_ = writeGPIO(b.cfg.GPIOPin, "value", []byte("1\n"))
_ = unexportGPIO(b.cfg.GPIOPin)
}
func (b *BRCPulser) Start(ctx context.Context) {
go func() {
ticker := time.NewTicker(b.cfg.PulseEvery.Duration)
defer ticker.Stop()
for {
b.pulseOnce()
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
}
func (b *BRCPulser) pulseOnce() {
if err := writeGPIO(b.cfg.GPIOPin, "value", []byte("0\n")); err != nil {
b.logger.Printf("brc pulse low: %v", err)
return
}
time.Sleep(b.cfg.PulseWidth.Duration)
if err := writeGPIO(b.cfg.GPIOPin, "value", []byte("1\n")); err != nil {
b.logger.Printf("brc pulse high: %v", err)
}
}
func exportGPIO(pin int) error {
err := os.WriteFile("/sys/class/gpio/export", []byte(strconv.Itoa(pin)), 0o644)
if err != nil && !os.IsExist(err) {
return err
}
return nil
}
func unexportGPIO(pin int) error {
err := os.WriteFile("/sys/class/gpio/unexport", []byte(strconv.Itoa(pin)), 0o644)
if err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func writeGPIO(pin int, field string, data []byte) error {
path := filepath.Join("/sys/class/gpio", fmt.Sprintf("gpio%d", pin), field)
return os.WriteFile(path, data, 0o644)
}
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"context"
"flag"
"log"
"os"
"os/signal"
"syscall"
"time"
roverd "multiroombarover/pi/roverd"
)
func main() {
var cfgPath string
flag.StringVar(&cfgPath, "config", "/etc/roverd.yaml", "path to roverd configuration file")
flag.Parse()
cfg, err := roverd.LoadConfig(cfgPath)
if err != nil {
log.Fatalf("load config: %v", err)
}
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
logger := log.New(os.Stdout, "roverd: ", log.LstdFlags|log.Lmicroseconds|log.LUTC)
serialPort, err := roverd.OpenSerial(cfg.Serial)
if err != nil {
logger.Fatalf("open serial: %v", err)
}
defer serialPort.Close()
var pulser *roverd.BRCPulser
if cfg.BRC.Enabled() {
pulser, err = roverd.NewBRCPulser(cfg.BRC, logger)
if err != nil {
logger.Fatalf("init BRC pulser: %v", err)
}
defer pulser.Close()
pulser.Start(ctx)
}
sensorFrames := make(chan []byte, 8)
streamer := roverd.NewSensorStreamer(serialPort, sensorFrames, logger)
go streamer.Run(ctx)
adapter := roverd.NewSerialAdapter(serialPort, logger)
mediaSupervisor := roverd.NewMediaSupervisor(cfg.Media, logger)
if mediaSupervisor != nil {
mediaSupervisor.Start(ctx)
}
client := roverd.NewWSClient(cfg, adapter, sensorFrames, mediaSupervisor, logger)
retryDelay := time.Second
for ctx.Err() == nil {
if err := client.Run(ctx); err != nil {
logger.Printf("websocket loop ended: %v", err)
}
select {
case <-ctx.Done():
return
case <-time.After(retryDelay):
}
if retryDelay < 30*time.Second {
retryDelay *= 2
}
}
}
+51
View File
@@ -0,0 +1,51 @@
package roverd
type helloMessage struct {
Type string `json:"type"`
Name string `json:"name"`
Battery BatteryConfig `json:"battery"`
MaxWheelSpeed int `json:"maxWheelSpeed"`
Media MediaConfig `json:"media"`
}
type sensorMessage struct {
Type string `json:"type"`
Timestamp int64 `json:"ts"`
Data string `json:"data"`
}
type inboundMessage struct {
Type string `json:"type"`
ID string `json:"id"`
DriveDirect *driveDirectPayload `json:"driveDirect,omitempty"`
MotorPWM *motorPWMPayload `json:"motorPwm,omitempty"`
Raw string `json:"raw,omitempty"`
SensorStream *sensorStreamPayload `json:"sensorStream,omitempty"`
Media *mediaCommand `json:"media,omitempty"`
}
type driveDirectPayload struct {
Left int `json:"left"`
Right int `json:"right"`
}
type motorPWMPayload struct {
Main int `json:"main"`
Side int `json:"side"`
Vacuum int `json:"vacuum"`
}
type sensorStreamPayload struct {
Enable bool `json:"enable"`
}
type mediaCommand struct {
Action string `json:"action"`
}
type ackMessage struct {
Type string `json:"type"`
ID string `json:"id"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
+118
View File
@@ -0,0 +1,118 @@
package roverd
import (
"errors"
"fmt"
"os"
"time"
"gopkg.in/yaml.v3"
)
type SerialConfig struct {
Device string `yaml:"device"`
Baud int `yaml:"baud"`
}
type Duration struct {
time.Duration
}
func (d *Duration) UnmarshalYAML(value *yaml.Node) error {
var raw string
if err := value.Decode(&raw); err != nil {
return err
}
parsed, err := time.ParseDuration(raw)
if err != nil {
return err
}
d.Duration = parsed
return nil
}
func (d Duration) MarshalYAML() (interface{}, error) {
return d.Duration.String(), nil
}
type BRCConfig struct {
GPIOPin int `yaml:"gpioPin"`
PulseEvery Duration `yaml:"pulseEvery"`
PulseWidth Duration `yaml:"pulseWidth"`
}
func (b BRCConfig) Enabled() bool {
return b.GPIOPin >= 0
}
type BatteryConfig struct {
Full int `yaml:"full"`
Warn int `yaml:"warn"`
Urgent int `yaml:"urgent"`
}
type MediaConfig struct {
WhepURL string `yaml:"whepUrl"`
StreamKey string `yaml:"streamKey"`
Manage bool `yaml:"manage"`
Service string `yaml:"service"`
HealthURL string `yaml:"healthUrl"`
HealthInterval Duration `yaml:"healthInterval"`
}
type Config struct {
Name string `yaml:"name"`
ServerURL string `yaml:"serverUrl"`
Serial SerialConfig `yaml:"serial"`
BRC BRCConfig `yaml:"brc"`
Battery BatteryConfig `yaml:"battery"`
MaxWheelMMs int `yaml:"maxWheelSpeed"`
Media MediaConfig `yaml:"media"`
}
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
cfg := Config{
MaxWheelMMs: 500,
BRC: BRCConfig{
GPIOPin: -1,
PulseEvery: Duration{
Duration: time.Minute,
},
PulseWidth: Duration{
Duration: time.Second,
},
},
Media: MediaConfig{
HealthInterval: Duration{Duration: 30 * time.Second},
},
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
if cfg.Name == "" {
return nil, errors.New("missing name")
}
if cfg.ServerURL == "" {
return nil, errors.New("missing serverUrl")
}
if cfg.Serial.Device == "" || cfg.Serial.Baud == 0 {
return nil, errors.New("serial device/baud required")
}
if cfg.Battery.Full == 0 {
return nil, errors.New("battery thresholds required")
}
if cfg.MaxWheelMMs <= 0 || cfg.MaxWheelMMs > 500 {
return nil, fmt.Errorf("maxWheelSpeed must be 1-500, got %d", cfg.MaxWheelMMs)
}
if cfg.Media.Manage && cfg.Media.Service == "" {
return nil, errors.New("media.manage requires media.service")
}
if cfg.Media.Manage && cfg.Media.HealthInterval.Duration <= 0 {
cfg.Media.HealthInterval = Duration{Duration: 30 * time.Second}
}
return &cfg, nil
}
+11
View File
@@ -0,0 +1,11 @@
module multiroombarover/pi/roverd
go 1.25.4
require (
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
gopkg.in/yaml.v3 v3.0.1
nhooyr.io/websocket v1.8.17
)
require golang.org/x/sys v0.38.0 // indirect
+10
View File
@@ -0,0 +1,10 @@
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU=
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
+121
View File
@@ -0,0 +1,121 @@
package roverd
import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"os/exec"
"time"
)
type MediaSupervisor struct {
cfg MediaConfig
logger *log.Logger
client *http.Client
checkInterval time.Duration
}
func NewMediaSupervisor(cfg MediaConfig, logger *log.Logger) *MediaSupervisor {
if !cfg.Manage || cfg.Service == "" {
return nil
}
interval := cfg.HealthInterval.Duration
if interval <= 0 {
interval = 30 * time.Second
}
return &MediaSupervisor{
cfg: cfg,
logger: logger,
client: &http.Client{Timeout: 5 * time.Second},
checkInterval: interval,
}
}
func (m *MediaSupervisor) Start(ctx context.Context) {
if m == nil || m.cfg.HealthURL == "" {
return
}
go func() {
ticker := time.NewTicker(m.checkInterval)
defer ticker.Stop()
if err := m.checkAndRepair(); err != nil {
m.logger.Printf("media supervisor: %v", err)
}
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := m.checkAndRepair(); err != nil {
m.logger.Printf("media supervisor: %v", err)
}
}
}
}()
}
func (m *MediaSupervisor) HandleAction(ctx context.Context, action string) error {
if m == nil {
return errors.New("media supervisor disabled")
}
switch action {
case "start", "stop", "restart", "reload", "status":
return m.runSystemctl(ctx, action)
default:
return fmt.Errorf("unknown media action: %s", action)
}
}
func (m *MediaSupervisor) checkAndRepair() error {
ctx, cancel := context.WithTimeout(context.Background(), 7*time.Second)
defer cancel()
if m.checkHealth(ctx) {
return nil
}
m.logger.Printf("media supervisor: health check failed, restarting %s", m.cfg.Service)
if err := m.runSystemctl(ctx, "restart"); err != nil {
return fmt.Errorf("restart mediamtx: %w", err)
}
return nil
}
func (m *MediaSupervisor) checkHealth(ctx context.Context) bool {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.cfg.HealthURL, nil)
if err != nil {
m.logger.Printf("media supervisor: health request: %v", err)
return false
}
resp, err := m.client.Do(req)
if err != nil {
m.logger.Printf("media supervisor: health request failed: %v", err)
return false
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return true
}
m.logger.Printf("media supervisor: unexpected health status %d", resp.StatusCode)
return false
}
func (m *MediaSupervisor) runSystemctl(ctx context.Context, action string) error {
if m.cfg.Service == "" {
return errors.New("no media service configured")
}
runCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
cmd := exec.CommandContext(runCtx, "systemctl", action, m.cfg.Service)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("systemctl %s %s: %w (%s)", action, m.cfg.Service, err, string(output))
}
return nil
}
BIN
View File
Binary file not shown.
+22
View File
@@ -0,0 +1,22 @@
# Sample configuration for roverd
name: roomba-alpha
serverUrl: ws://control-server.local:8080/rover
serial:
device: /dev/ttyAMA0
baud: 115200
brc:
gpioPin: 17
pulseEvery: 1m
pulseWidth: 1s
battery:
full: 2068
warn: 1700
urgent: 1650
maxWheelSpeed: 350
media:
whepUrl: https://mediaserver.local/whep/roomba-alpha
streamKey: roomba-alpha
manage: false
service: mediamtx.service
healthUrl: http://127.0.0.1:9997/v3/paths/list/rovercam
healthInterval: 30s
+22
View File
@@ -0,0 +1,22 @@
# Sample configuration for roverd
name: roomba-alpha
serverUrl: ws://control-server.local:8080/rover
serial:
device: /dev/ttyAMA0
baud: 115200
brc:
gpioPin: 17
pulseEvery: 1m
pulseWidth: 1s
battery:
full: 2068
warn: 1700
urgent: 1650
maxWheelSpeed: 350
media:
whepUrl: https://mediaserver.local/whep/roomba-alpha
streamKey: roomba-alpha
manage: false
service: mediamtx.service
healthUrl: http://127.0.0.1:9997/v3/paths/list/rovercam
healthInterval: 30s
+76
View File
@@ -0,0 +1,76 @@
package roverd
import (
"bufio"
"context"
"encoding/hex"
"io"
"log"
"time"
)
const (
sensorHeader = 19
sensorReadTimeout = 150 * time.Millisecond
streamGroupDefault = 100
)
type SensorStreamer struct {
r io.Reader
out chan<- []byte
logger *log.Logger
}
func NewSensorStreamer(r io.Reader, out chan<- []byte, logger *log.Logger) *SensorStreamer {
return &SensorStreamer{r: r, out: out, logger: logger}
}
func (s *SensorStreamer) Run(ctx context.Context) {
reader := bufio.NewReader(s.r)
for {
select {
case <-ctx.Done():
return
default:
}
b, err := reader.ReadByte()
if err != nil {
if ctx.Err() != nil {
return
}
continue
}
if b != sensorHeader {
continue
}
nBytes, err := reader.ReadByte()
if err != nil {
continue
}
frame := make([]byte, int(nBytes)+3)
frame[0] = sensorHeader
frame[1] = nBytes
if _, err := io.ReadFull(reader, frame[2:]); err != nil {
continue
}
if !validateChecksum(frame) {
s.logger.Printf("sensor checksum failed: %s", hex.EncodeToString(frame))
continue
}
select {
case s.out <- frame:
default:
}
}
}
func validateChecksum(buf []byte) bool {
var sum int
for _, b := range buf {
sum += int(b)
}
return byte(sum&0xFF) == 0
}
+86
View File
@@ -0,0 +1,86 @@
package roverd
import (
"encoding/base64"
"fmt"
"io"
"log"
"sync"
"github.com/tarm/serial"
)
type SerialAdapter struct {
port io.ReadWriteCloser
encoder *base64.Encoding
mu sync.Mutex
log *log.Logger
}
func OpenSerial(cfg SerialConfig) (*serial.Port, error) {
return serial.OpenPort(&serial.Config{
Name: cfg.Device,
Baud: cfg.Baud,
ReadTimeout: sensorReadTimeout,
})
}
func NewSerialAdapter(port io.ReadWriteCloser, logger *log.Logger) *SerialAdapter {
return &SerialAdapter{
port: port,
encoder: base64.StdEncoding,
log: logger,
}
}
func (s *SerialAdapter) write(buf []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
n, err := s.port.Write(buf)
if err != nil {
return err
}
if n != len(buf) {
return fmt.Errorf("short write %d/%d", n, len(buf))
}
return nil
}
func (s *SerialAdapter) DriveDirect(left, right int) error {
payload := []byte{
145,
byte((right >> 8) & 0xFF),
byte(right & 0xFF),
byte((left >> 8) & 0xFF),
byte(left & 0xFF),
}
return s.write(payload)
}
func (s *SerialAdapter) MotorPWM(main, side, vacuum int) error {
payload := []byte{
144,
byte(main & 0xFF),
byte(side & 0xFF),
byte(vacuum & 0xFF),
}
return s.write(payload)
}
func (s *SerialAdapter) StartSensorStream(group byte) error {
payload := []byte{148, 1, group}
return s.write(payload)
}
func (s *SerialAdapter) PauseSensorStream(pause bool) error {
state := byte(1)
if pause {
state = 0
}
return s.write([]byte{150, state})
}
func (s *SerialAdapter) SendRaw(raw []byte) error {
return s.write(raw)
}
+172
View File
@@ -0,0 +1,172 @@
package roverd
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"time"
"nhooyr.io/websocket"
)
type WSClient struct {
cfg *Config
adapter *SerialAdapter
sensorFrames <-chan []byte
media *MediaSupervisor
log *log.Logger
}
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, media *MediaSupervisor, logger *log.Logger) *WSClient {
return &WSClient{
cfg: cfg,
adapter: adapter,
sensorFrames: frames,
media: media,
log: logger,
}
}
func (c *WSClient) Run(ctx context.Context) error {
conn, _, err := websocket.Dial(ctx, c.cfg.ServerURL, nil)
if err != nil {
return err
}
defer conn.Close(websocket.StatusInternalError, "closed")
if err := c.sendHello(ctx, conn); err != nil {
return err
}
errCh := make(chan error, 1)
go func() {
errCh <- c.readLoop(ctx, conn)
}()
go c.forwardSensors(ctx, conn)
select {
case <-ctx.Done():
conn.Close(websocket.StatusNormalClosure, "context done")
return ctx.Err()
case err := <-errCh:
return err
}
}
func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
msg := helloMessage{
Type: "hello",
Name: c.cfg.Name,
Battery: c.cfg.Battery,
MaxWheelSpeed: c.cfg.MaxWheelMMs,
Media: c.cfg.Media,
}
return writeJSON(ctx, conn, msg)
}
func (c *WSClient) readLoop(ctx context.Context, conn *websocket.Conn) error {
for {
_, data, err := conn.Read(ctx)
if err != nil {
return err
}
var msg inboundMessage
if err := json.Unmarshal(data, &msg); err != nil {
c.log.Printf("invalid command: %v", err)
continue
}
if msg.ID == "" {
continue
}
status := "ok"
cmdErr := c.dispatch(ctx, &msg)
if cmdErr != nil {
status = "error"
}
ack := ackMessage{
Type: "ack",
ID: msg.ID,
Status: status,
}
if cmdErr != nil {
ack.Error = cmdErr.Error()
}
if err := writeJSON(ctx, conn, ack); err != nil {
return err
}
}
}
func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
switch {
case msg.DriveDirect != nil:
left := clamp(msg.DriveDirect.Left, -c.cfg.MaxWheelMMs, c.cfg.MaxWheelMMs)
right := clamp(msg.DriveDirect.Right, -c.cfg.MaxWheelMMs, c.cfg.MaxWheelMMs)
return c.adapter.DriveDirect(left, right)
case msg.MotorPWM != nil:
main := clamp(msg.MotorPWM.Main, -127, 127)
side := clamp(msg.MotorPWM.Side, -127, 127)
vac := clamp(msg.MotorPWM.Vacuum, 0, 127)
return c.adapter.MotorPWM(main, side, vac)
case msg.SensorStream != nil:
if msg.SensorStream.Enable {
if err := c.adapter.StartSensorStream(streamGroupDefault); err != nil {
return err
}
return c.adapter.PauseSensorStream(false)
}
return c.adapter.PauseSensorStream(true)
case msg.Raw != "" && len(msg.Raw) > 0:
buf, err := base64.StdEncoding.DecodeString(msg.Raw)
if err != nil {
return fmt.Errorf("raw decode: %w", err)
}
return c.adapter.SendRaw(buf)
case msg.Media != nil:
if c.media == nil {
return fmt.Errorf("media supervisor disabled")
}
return c.media.HandleAction(ctx, msg.Media.Action)
default:
return fmt.Errorf("unsupported command type: %s", msg.Type)
}
}
func (c *WSClient) forwardSensors(ctx context.Context, conn *websocket.Conn) {
for {
select {
case <-ctx.Done():
return
case frame := <-c.sensorFrames:
msg := sensorMessage{
Type: "sensor",
Timestamp: time.Now().UnixMilli(),
Data: base64.StdEncoding.EncodeToString(frame),
}
if err := writeJSON(ctx, conn, msg); err != nil {
c.log.Printf("sensor send failed: %v", err)
return
}
}
}
}
func writeJSON(ctx context.Context, conn *websocket.Conn, v any) error {
data, err := json.Marshal(v)
if err != nil {
return err
}
return conn.Write(ctx, websocket.MessageText, data)
}
func clamp(value, min, max int) int {
if value < min {
return min
}
if value > max {
return max
}
return value
}