// 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 }