package roverd import ( "context" "encoding/json" "errors" "fmt" "io" "sync" ) // Firmata command and mode constants are kept here instead of scattering raw // bytes through the peripheral code. The values come directly from the Firmata // protocol, so captures from a rover can be compared with the specification. const ( firmataReportVersion byte = 0xF9 firmataSetPinMode byte = 0xF4 firmataSetDigitalPin byte = 0xF5 firmataStartSysex byte = 0xF0 firmataEndSysex byte = 0xF7 firmataReportFirmware byte = 0x79 firmataCapabilityQuery byte = 0x6B firmataCapabilityReply byte = 0x6C firmataExtendedAnalog byte = 0x6F firmataServoConfig byte = 0x70 firmataPeripheralFeature byte = 0x01 firmataPeripheralDescribe byte = 0x00 firmataPeripheralDescription byte = 0x01 firmataPeripheralControl byte = 0x02 firmataMaximumSysexDataBytes = 252 FirmataPinModeOutput byte = 0x01 FirmataPinModePWM byte = 0x03 FirmataPinModeServo byte = 0x04 ) // FirmataMessage is the transport-neutral result of parsing one complete // Firmata message. For SysEx messages Command is the SysEx feature byte and // Data is everything between that feature byte and END_SYSEX. type FirmataMessage struct { Command byte Data []byte Sysex bool } // FirmataParser incrementally parses a byte stream. USB serial reads may split // a message anywhere or combine several messages, so parsing whole Read calls // as though they were packets would intermittently corrupt valid traffic. type FirmataParser struct { inSysex bool sysex []byte command byte data []byte expected int } // Feed accepts any fragment of the serial stream and returns every complete // message found in it, preserving wire order. func (p *FirmataParser) Feed(fragment []byte) ([]FirmataMessage, error) { var messages []FirmataMessage for _, value := range fragment { if p.inSysex { switch { case value == firmataEndSysex: if len(p.sysex) == 0 { p.resetSysex() return messages, errors.New("Firmata SysEx message is missing a feature byte") } messages = append(messages, FirmataMessage{ Command: p.sysex[0], Data: append([]byte(nil), p.sysex[1:]...), Sysex: true, }) p.resetSysex() case value&0x80 != 0: // Bytes inside SysEx must be seven-bit clean. Reset immediately so // a damaged frame cannot consume every later message on the port. p.resetSysex() return messages, fmt.Errorf("invalid 8-bit value 0x%02x inside Firmata SysEx", value) default: p.sysex = append(p.sysex, value) } continue } if value == firmataStartSysex { p.inSysex = true p.sysex = p.sysex[:0] p.resetFixed() continue } if value&0x80 != 0 { p.command = value p.data = p.data[:0] p.expected = firmataDataLength(value) if p.expected == 0 { messages = append(messages, FirmataMessage{Command: value}) p.resetFixed() } continue } // Stray data before a status byte is harmless serial noise. Firmata // has no framing information that could assign it to a command. if p.expected == 0 { continue } p.data = append(p.data, value) if len(p.data) == p.expected { messages = append(messages, FirmataMessage{ Command: p.command, Data: append([]byte(nil), p.data...), }) p.resetFixed() } } return messages, nil } func (p *FirmataParser) resetSysex() { p.inSysex = false p.sysex = p.sysex[:0] } func (p *FirmataParser) resetFixed() { p.command = 0 p.data = p.data[:0] p.expected = 0 } // firmataDataLength returns the number of seven-bit data bytes used by the // fixed-length messages relevant to normal Firmata traffic. Unknown system // commands are treated as single-byte messages so they cannot stall parsing of // the rover-peripheral SysEx frames that follow them. func firmataDataLength(command byte) int { switch command { case firmataReportVersion, firmataSetPinMode, firmataSetDigitalPin: return 2 } switch command & 0xF0 { case 0x80, 0x90, 0xA0, 0xE0: return 2 case 0xC0, 0xD0: return 1 default: return 0 } } // EncodeFirmata7Bit converts arbitrary bytes into the two-byte representation // required inside Firmata SysEx. Keeping this transform below the JSON layer // means firmware authors and UI code never need to think about wire encoding. func EncodeFirmata7Bit(raw []byte) []byte { encoded := make([]byte, 0, len(raw)*2) for _, value := range raw { encoded = append(encoded, value&0x7F, (value>>7)&0x01) } return encoded } // DecodeFirmata7Bit reverses EncodeFirmata7Bit and rejects malformed pairs. func DecodeFirmata7Bit(encoded []byte) ([]byte, error) { if len(encoded)%2 != 0 { return nil, fmt.Errorf("Firmata 7-bit payload has odd length %d", len(encoded)) } decoded := make([]byte, 0, len(encoded)/2) for index := 0; index < len(encoded); index += 2 { low, high := encoded[index], encoded[index+1] if low&0x80 != 0 || high > 1 { return nil, fmt.Errorf("invalid Firmata 7-bit pair at byte %d", index) } decoded = append(decoded, low|(high<<7)) } return decoded, nil } // PeripheralDescription is generated by the ESP32 at boot. Controls is a slice // intentionally: registration order is part of the UI contract and must never // be replaced by map iteration or alphabetical sorting. type PeripheralDescription struct { Name string `json:"name"` RoverControls PeripheralRoverControls `json:"roverControls,omitempty"` Controls []PeripheralControl `json:"controls"` } type PeripheralRoverControls struct { CameraServo *PeripheralCameraServo `json:"cameraServo,omitempty"` Headlight *PeripheralDigitalRole `json:"headlight,omitempty"` Laser *PeripheralDigitalRole `json:"laser,omitempty"` } type PeripheralCameraServo struct { Pin int `json:"pin"` MinimumAngleDegrees float64 `json:"minimumAngleDegrees"` MaximumAngleDegrees float64 `json:"maximumAngleDegrees"` HomeAngleDegrees float64 `json:"homeAngleDegrees"` NudgeDegrees float64 `json:"nudgeDegrees"` MinimumPulseMicroseconds int `json:"minimumPulseMicroseconds"` MaximumPulseMicroseconds int `json:"maximumPulseMicroseconds"` AllowRawPulse bool `json:"allowRawPulse"` Inverted bool `json:"inverted"` } type PeripheralDigitalRole struct { Pin int `json:"pin"` ActiveLow bool `json:"activeLow"` InitiallyOn bool `json:"initiallyOn"` } type PeripheralControl struct { ID string `json:"id"` Type string `json:"type"` Name string `json:"name"` Mode string `json:"mode,omitempty"` Minimum *int `json:"min,omitempty"` Maximum *int `json:"max,omitempty"` MaximumLength *int `json:"maxLength,omitempty"` Output PeripheralOutput `json:"output"` } type PeripheralOutput struct { Type string `json:"type"` Pin *int `json:"pin,omitempty"` ActiveLow bool `json:"activeLow,omitempty"` } // Validate catches authoring mistakes at connection time, where the error can // name the offending peripheral, instead of allowing a malformed declaration // to turn into a confusing no-op later when a driver uses the control. func (description PeripheralDescription) Validate() error { if description.Name == "" { return errors.New("peripheral description requires a name") } if camera := description.RoverControls.CameraServo; camera != nil { if err := validateFirmataPin("cameraServo", camera.Pin); err != nil { return err } if camera.MinimumAngleDegrees >= camera.MaximumAngleDegrees { return errors.New("cameraServo angle range must be increasing") } if camera.HomeAngleDegrees < camera.MinimumAngleDegrees || camera.HomeAngleDegrees > camera.MaximumAngleDegrees { return errors.New("cameraServo home angle must be inside its angle range") } if camera.NudgeDegrees <= 0 { return errors.New("cameraServo nudge must be positive") } if camera.MinimumPulseMicroseconds <= 0 || camera.MaximumPulseMicroseconds <= camera.MinimumPulseMicroseconds { return errors.New("cameraServo pulse range must be positive and increasing") } } if role := description.RoverControls.Headlight; role != nil { if err := validateFirmataPin("headlight", role.Pin); err != nil { return err } } if role := description.RoverControls.Laser; role != nil { if err := validateFirmataPin("laser", role.Pin); err != nil { return err } } seen := make(map[string]struct{}, len(description.Controls)) for index, control := range description.Controls { if control.ID == "" || control.Name == "" { return fmt.Errorf("control %d requires both id and name", index) } if _, exists := seen[control.ID]; exists { return fmt.Errorf("control id %q is duplicated", control.ID) } seen[control.ID] = struct{}{} switch control.Type { case "slider", "number": if control.Minimum == nil || control.Maximum == nil || *control.Minimum > *control.Maximum { return fmt.Errorf("control %q requires a valid min and max", control.ID) } case "button": if control.Mode != "toggle" && control.Mode != "momentary" { return fmt.Errorf("button %q requires toggle or momentary mode", control.ID) } case "text": if control.MaximumLength == nil || *control.MaximumLength <= 0 { return fmt.Errorf("text control %q requires a positive maxLength", control.ID) } default: return fmt.Errorf("control %q has unsupported type %q", control.ID, control.Type) } switch control.Output.Type { case "digital": if control.Output.Pin == nil { return fmt.Errorf("control %q output %q requires a pin", control.ID, control.Output.Type) } if err := validateFirmataPin("control "+control.ID, *control.Output.Pin); err != nil { return err } if control.Type != "button" { return fmt.Errorf("digital output control %q must be a button", control.ID) } case "pwm", "servo": if control.Output.Pin == nil { return fmt.Errorf("control %q output %q requires a pin", control.ID, control.Output.Type) } if err := validateFirmataPin("control "+control.ID, *control.Output.Pin); err != nil { return err } if control.Type != "slider" && control.Type != "number" { return fmt.Errorf("%s output control %q must be a slider or number", control.Output.Type, control.ID) } case "custom": default: return fmt.Errorf("control %q has unsupported output %q", control.ID, control.Output.Type) } } return nil } func validateFirmataPin(owner string, pin int) error { // Firmata represents pin numbers with one seven-bit byte. Rejecting values // outside that wire range avoids silently wrapping a declaration when it is // converted to a byte for output commands. if pin < 0 || pin > 127 { return fmt.Errorf("%s pin must be between 0 and 127", owner) } return nil } // FirmataFirmware identifies the implementation answering the standard // REPORT_FIRMWARE query. It is diagnostic metadata, not a protocol gate. type FirmataFirmware struct { Major int Minor int Name string } // FirmataPinCapability is one mode/resolution pair from CAPABILITY_RESPONSE. type FirmataPinCapability struct { Mode byte Resolution byte } // FirmataClient owns one already-open serial connection. Its reader goroutine // separates arbitrary USB read boundaries from request/response handling while // writeMu prevents two commands from interleaving on the byte stream. type FirmataClient struct { connection io.ReadWriteCloser parser FirmataParser messages chan FirmataMessage errors chan error writeMu sync.Mutex requestMu sync.Mutex stateMu sync.RWMutex terminalErr error // terminalErrorHandler is invoked only for the first non-timeout read // failure while the client context remains active. PeripheralManager uses it // to turn an unexpected USB loss into one operator-facing broadcast. terminalErrorHandler func(error) } func NewFirmataClient(connection io.ReadWriteCloser) *FirmataClient { return &FirmataClient{ connection: connection, messages: make(chan FirmataMessage, 16), errors: make(chan error, 1), } } // Start begins consuming the serial stream. The caller still owns the port and // closes it during shutdown; this makes the client usable with both real serial // ports and deterministic in-memory test connections. func (client *FirmataClient) Start(ctx context.Context) { go client.readLoop(ctx) } func (client *FirmataClient) readLoop(ctx context.Context) { buffer := make([]byte, 256) for { count, err := client.connection.Read(buffer) if count > 0 { messages, parseErr := client.parser.Feed(buffer[:count]) if parseErr != nil { client.publishError(ctx, parseErr) return } for _, message := range messages { select { case client.messages <- message: case <-ctx.Done(): return } } } if err != nil { if errors.Is(err, io.EOF) { // tarm/serial represents an ordinary ReadTimeout with io.EOF. A // Firmata connection is expected to be quiet between commands, so // treating that timeout as a closed device kills the reader before // the next request can receive its reply. A real USB removal is // reported by the serial driver as a non-EOF error. select { case <-ctx.Done(): return default: continue } } client.publishError(ctx, err) return } select { case <-ctx.Done(): return default: } } } func (client *FirmataClient) publishError(ctx context.Context, err error) { firstTerminalError, handler := client.recordTerminalError(err) if firstTerminalError && handler != nil && ctx.Err() == nil { handler(err) } select { case client.errors <- err: case <-ctx.Done(): default: } } func (client *FirmataClient) recordTerminalError(err error) (bool, func(error)) { client.stateMu.Lock() defer client.stateMu.Unlock() firstTerminalError := client.terminalErr == nil if client.terminalErr == nil { client.terminalErr = err } handler := client.terminalErrorHandler return firstTerminalError, handler } // SetTerminalErrorHandler registers the one-shot observer used after a device // has completed discovery. If the connection already failed, the observer is // called immediately so a narrow handshake-to-registration race is not lost. func (client *FirmataClient) SetTerminalErrorHandler(handler func(error)) { client.stateMu.Lock() client.terminalErrorHandler = handler terminalErr := client.terminalErr client.stateMu.Unlock() if terminalErr != nil && handler != nil { handler(terminalErr) } } func (client *FirmataClient) write(message []byte) error { client.writeMu.Lock() defer client.writeMu.Unlock() client.stateMu.RLock() terminalErr := client.terminalErr client.stateMu.RUnlock() if terminalErr != nil { return fmt.Errorf("Firmata connection unavailable: %w", terminalErr) } written, err := client.connection.Write(message) if err != nil { firstTerminalError, handler := client.recordTerminalError(err) if firstTerminalError && handler != nil { handler(err) } return err } if written != len(message) { err := fmt.Errorf("short Firmata write %d/%d", written, len(message)) firstTerminalError, handler := client.recordTerminalError(err) if firstTerminalError && handler != nil { handler(err) } return err } return nil } func (client *FirmataClient) writeSysex(command byte, data []byte) error { message := make([]byte, 0, len(data)+3) message = append(message, firmataStartSysex, command) message = append(message, data...) message = append(message, firmataEndSysex) return client.write(message) } func (client *FirmataClient) waitFor(ctx context.Context, match func(FirmataMessage) bool) (FirmataMessage, error) { for { select { case message := <-client.messages: if match(message) { return message, nil } case err := <-client.errors: return FirmataMessage{}, err case <-ctx.Done(): return FirmataMessage{}, ctx.Err() } } } func (client *FirmataClient) QueryFirmware(ctx context.Context) (FirmataFirmware, error) { client.requestMu.Lock() defer client.requestMu.Unlock() if err := client.writeSysex(firmataReportFirmware, nil); err != nil { return FirmataFirmware{}, err } message, err := client.waitFor(ctx, func(message FirmataMessage) bool { return message.Sysex && message.Command == firmataReportFirmware }) if err != nil { return FirmataFirmware{}, err } if len(message.Data) < 2 { return FirmataFirmware{}, errors.New("Firmata firmware response is missing version bytes") } name, err := DecodeFirmata7Bit(message.Data[2:]) if err != nil { return FirmataFirmware{}, fmt.Errorf("decode Firmata firmware name: %w", err) } return FirmataFirmware{Major: int(message.Data[0]), Minor: int(message.Data[1]), Name: string(name)}, nil } func (client *FirmataClient) QueryCapabilities(ctx context.Context) ([][]FirmataPinCapability, error) { client.requestMu.Lock() defer client.requestMu.Unlock() if err := client.writeSysex(firmataCapabilityQuery, nil); err != nil { return nil, err } message, err := client.waitFor(ctx, func(message FirmataMessage) bool { return message.Sysex && message.Command == firmataCapabilityReply }) if err != nil { return nil, err } return parseFirmataCapabilities(message.Data) } func parseFirmataCapabilities(data []byte) ([][]FirmataPinCapability, error) { var pins [][]FirmataPinCapability var pin []FirmataPinCapability for index := 0; index < len(data); { if data[index] == 0x7F { pins = append(pins, pin) pin = nil index++ continue } if index+1 >= len(data) { return nil, errors.New("Firmata capability response ends inside a mode pair") } pin = append(pin, FirmataPinCapability{Mode: data[index], Resolution: data[index+1]}) index += 2 } if pin != nil { return nil, errors.New("Firmata capability response is missing its final pin separator") } return pins, nil } func (client *FirmataClient) Describe(ctx context.Context) (PeripheralDescription, error) { client.requestMu.Lock() defer client.requestMu.Unlock() if err := client.writeSysex(firmataPeripheralFeature, []byte{firmataPeripheralDescribe}); err != nil { return PeripheralDescription{}, err } message, err := client.waitFor(ctx, func(message FirmataMessage) bool { return message.Sysex && message.Command == firmataPeripheralFeature && len(message.Data) > 0 && message.Data[0] == firmataPeripheralDescription }) if err != nil { return PeripheralDescription{}, err } raw, err := DecodeFirmata7Bit(message.Data[1:]) if err != nil { return PeripheralDescription{}, fmt.Errorf("decode peripheral description: %w", err) } var description PeripheralDescription if err := json.Unmarshal(raw, &description); err != nil { return PeripheralDescription{}, fmt.Errorf("parse peripheral description: %w", err) } if err := description.Validate(); err != nil { return PeripheralDescription{}, fmt.Errorf("validate peripheral description: %w", err) } return description, nil } func (client *FirmataClient) SetPinMode(pin, mode byte) error { return client.write([]byte{firmataSetPinMode, pin & 0x7F, mode & 0x7F}) } func (client *FirmataClient) SetDigitalPin(pin byte, enabled bool) error { value := byte(0) if enabled { value = 1 } return client.write([]byte{firmataSetDigitalPin, pin & 0x7F, value}) } func (client *FirmataClient) ExtendedAnalog(pin byte, value int) error { if value < 0 { return fmt.Errorf("Firmata analog value cannot be negative: %d", value) } payload := []byte{pin & 0x7F} // Firmata encodes integers as many seven-bit chunks as necessary. Zero // still needs one value byte so the receiver can distinguish it from a // message that contains only the pin. for { payload = append(payload, byte(value&0x7F)) value >>= 7 if value == 0 { break } } return client.writeSysex(firmataExtendedAnalog, payload) } func (client *FirmataClient) ConfigureServo(pin byte, minimumPulseMicroseconds, maximumPulseMicroseconds int) error { if minimumPulseMicroseconds <= 0 || maximumPulseMicroseconds <= minimumPulseMicroseconds { return errors.New("servo pulse range must be positive and increasing") } payload := []byte{ pin & 0x7F, byte(minimumPulseMicroseconds & 0x7F), byte((minimumPulseMicroseconds >> 7) & 0x7F), byte(maximumPulseMicroseconds & 0x7F), byte((maximumPulseMicroseconds >> 7) & 0x7F), } return client.writeSysex(firmataServoConfig, payload) } func (client *FirmataClient) SendPeripheralControl(controlID string, value any) error { payload, err := json.Marshal(struct { Control string `json:"control"` Value any `json:"value"` }{Control: controlID, Value: value}) if err != nil { return fmt.Errorf("encode peripheral control: %w", err) } data := append([]byte{firmataPeripheralControl}, EncodeFirmata7Bit(payload)...) // ConfigurableFirmata on ESP32 stores at most 252 bytes including the SysEx // feature byte. Refuse a value that the board would otherwise discard as an // incomplete frame; this is a transport constraint, not an application-level // text policy. if len(data)+1 > firmataMaximumSysexDataBytes { return fmt.Errorf("peripheral control needs %d SysEx data bytes; Firmata accepts at most %d", len(data)+1, firmataMaximumSysexDataBytes) } return client.writeSysex(firmataPeripheralFeature, data) }