You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
204 lines
6.3 KiB
204 lines
6.3 KiB
|
2 months ago
|
// Package greenledsocket exposes the narrow local tera-hwd LED control
|
||
|
|
// protocol. It carries only gpio-leds state, force, and bounded pulse actions.
|
||
|
|
package greenledsocket
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bufio"
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"tera-clone/services/tera-api/internal/hardware/greenled"
|
||
|
|
)
|
||
|
|
|
||
|
|
const DefaultSocketPath = "/run/tera-hwd/green-leds.sock"
|
||
|
|
|
||
|
|
const maxRequestBytes = 4096
|
||
|
|
|
||
|
|
type request struct {
|
||
|
|
Action string `json:"action"`
|
||
|
|
Channel string `json:"channel,omitempty"`
|
||
|
|
Force *bool `json:"force,omitempty"`
|
||
|
|
DwellMS int `json:"dwell_ms,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type response struct {
|
||
|
|
States []greenled.State `json:"states,omitempty"`
|
||
|
|
State *greenled.State `json:"state,omitempty"`
|
||
|
|
Code string `json:"code,omitempty"`
|
||
|
|
Error string `json:"error,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// Server delegates all hardware operations to a Controller. The caller owns
|
||
|
|
// listener lifecycle; this keeps signal and systemd supervision outside the
|
||
|
|
// protocol library.
|
||
|
|
type Server struct {
|
||
|
|
Controller greenled.Controller
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s Server) Serve(listener net.Listener) error {
|
||
|
|
if s.Controller == nil {
|
||
|
|
return errors.New("green LED controller is required")
|
||
|
|
}
|
||
|
|
for {
|
||
|
|
connection, err := listener.Accept()
|
||
|
|
if err != nil {
|
||
|
|
if errors.Is(err, net.ErrClosed) {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
if temporary, ok := err.(interface{ Temporary() bool }); ok && temporary.Temporary() {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
go s.handleConnection(connection)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s Server) handleConnection(connection net.Conn) {
|
||
|
|
defer connection.Close()
|
||
|
|
_ = connection.SetDeadline(time.Now().Add(10 * time.Second))
|
||
|
|
payload, err := bufio.NewReader(io.LimitReader(connection, maxRequestBytes+1)).ReadBytes('\n')
|
||
|
|
if err != nil && !errors.Is(err, io.EOF) {
|
||
|
|
s.writeResponse(connection, response{Code: "validation_failed", Error: "invalid hardware control request"})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if len(payload) == 0 || len(payload) > maxRequestBytes || payload[len(payload)-1] != '\n' {
|
||
|
|
s.writeResponse(connection, response{Code: "validation_failed", Error: "invalid hardware control request"})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||
|
|
decoder.DisallowUnknownFields()
|
||
|
|
var command request
|
||
|
|
if err := decoder.Decode(&command); err != nil {
|
||
|
|
s.writeResponse(connection, response{Code: "validation_failed", Error: "invalid hardware control request"})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
var extra any
|
||
|
|
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
||
|
|
s.writeResponse(connection, response{Code: "validation_failed", Error: "hardware control request must contain a single JSON value"})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
switch command.Action {
|
||
|
|
case "states":
|
||
|
|
states, err := s.Controller.States(ctx)
|
||
|
|
if err != nil {
|
||
|
|
s.writeError(connection, err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
s.writeResponse(connection, response{States: states})
|
||
|
|
case "set_force":
|
||
|
|
if command.Force == nil {
|
||
|
|
s.writeResponse(connection, response{Code: "validation_failed", Error: "force must be provided"})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
state, err := s.Controller.SetForce(ctx, command.Channel, *command.Force)
|
||
|
|
if err != nil {
|
||
|
|
s.writeError(connection, err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
s.writeResponse(connection, response{State: &state})
|
||
|
|
case "pulse":
|
||
|
|
state, err := s.Controller.Pulse(ctx, command.Channel, time.Duration(command.DwellMS)*time.Millisecond)
|
||
|
|
if err != nil {
|
||
|
|
s.writeError(connection, err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
s.writeResponse(connection, response{State: &state})
|
||
|
|
default:
|
||
|
|
s.writeResponse(connection, response{Code: "validation_failed", Error: "hardware control action is not supported"})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s Server) writeError(connection net.Conn, err error) {
|
||
|
|
code := "hardware_failed"
|
||
|
|
if greenled.IsUnavailable(err) {
|
||
|
|
code = "hardware_unavailable"
|
||
|
|
}
|
||
|
|
s.writeResponse(connection, response{Code: code, Error: err.Error()})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s Server) writeResponse(connection net.Conn, value response) {
|
||
|
|
_ = json.NewEncoder(connection).Encode(value)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Client implements greenled.Controller for the unprivileged tera-api
|
||
|
|
// process. Unix-socket file permissions are the local process boundary.
|
||
|
|
type Client struct {
|
||
|
|
path string
|
||
|
|
timeout time.Duration
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewClient(path string) *Client {
|
||
|
|
if path == "" {
|
||
|
|
path = DefaultSocketPath
|
||
|
|
}
|
||
|
|
return &Client{path: path, timeout: 10 * time.Second}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *Client) States(ctx context.Context) ([]greenled.State, error) {
|
||
|
|
result, err := c.exchange(ctx, request{Action: "states"})
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return result.States, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *Client) SetForce(ctx context.Context, channel string, force bool) (greenled.State, error) {
|
||
|
|
result, err := c.exchange(ctx, request{Action: "set_force", Channel: channel, Force: &force})
|
||
|
|
if err != nil {
|
||
|
|
return greenled.State{}, err
|
||
|
|
}
|
||
|
|
if result.State == nil {
|
||
|
|
return greenled.State{}, errors.New("green LED hardware helper returned no state")
|
||
|
|
}
|
||
|
|
return *result.State, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *Client) Pulse(ctx context.Context, channel string, dwell time.Duration) (greenled.State, error) {
|
||
|
|
result, err := c.exchange(ctx, request{Action: "pulse", Channel: channel, DwellMS: int(dwell / time.Millisecond)})
|
||
|
|
if err != nil {
|
||
|
|
return greenled.State{}, err
|
||
|
|
}
|
||
|
|
if result.State == nil {
|
||
|
|
return greenled.State{}, errors.New("green LED hardware helper returned no state")
|
||
|
|
}
|
||
|
|
return *result.State, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *Client) exchange(ctx context.Context, command request) (response, error) {
|
||
|
|
dialer := net.Dialer{Timeout: c.timeout}
|
||
|
|
connection, err := dialer.DialContext(ctx, "unix", c.path)
|
||
|
|
if err != nil {
|
||
|
|
return response{}, &greenled.UnavailableError{Reason: "tera-hwd is not reachable"}
|
||
|
|
}
|
||
|
|
defer connection.Close()
|
||
|
|
deadline := time.Now().Add(c.timeout)
|
||
|
|
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
|
||
|
|
deadline = contextDeadline
|
||
|
|
}
|
||
|
|
_ = connection.SetDeadline(deadline)
|
||
|
|
if err := json.NewEncoder(connection).Encode(command); err != nil {
|
||
|
|
return response{}, fmt.Errorf("green LED hardware request failed")
|
||
|
|
}
|
||
|
|
var result response
|
||
|
|
if err := json.NewDecoder(io.LimitReader(connection, 65536)).Decode(&result); err != nil {
|
||
|
|
return response{}, &greenled.UnavailableError{Reason: "tera-hwd returned no response"}
|
||
|
|
}
|
||
|
|
if result.Error != "" {
|
||
|
|
if result.Code == "hardware_unavailable" {
|
||
|
|
return response{}, &greenled.UnavailableError{Reason: result.Error}
|
||
|
|
}
|
||
|
|
return response{}, errors.New(result.Error)
|
||
|
|
}
|
||
|
|
return result, nil
|
||
|
|
}
|