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.

246 lines
6.7 KiB

// Package greenled provides the canonical TERA Core-X48 PRO green-LED
// mapping and the kernel gpio-leds controller interface.
package greenled
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
const (
ChannelCount = 48
DefaultSysfsRoot = "/sys/class/leds"
MinPulseDuration = 100 * time.Millisecond
MaxPulseDuration = 5 * time.Second
)
// State exposes the serial-channel identity and its deterministic board LED
// mapping. A forced value of true means the green LED is held on. false
// releases the PCF8574 line back to RX activity.
type State struct {
Channel string `json:"channel"`
LEDName string `json:"led_name"`
I2CBus int `json:"i2c_bus"`
I2CAddress string `json:"i2c_address"`
GPIOOffset int `json:"gpio_offset"`
Available bool `json:"available"`
Forced bool `json:"forced"`
Message string `json:"message,omitempty"`
}
// Controller is the service-facing hardware boundary. Implementations must
// not use raw I2C while the kernel owns the PCF8574 gpio-leds clients.
type Controller interface {
States(ctx context.Context) ([]State, error)
SetForce(ctx context.Context, channel string, force bool) (State, error)
Pulse(ctx context.Context, channel string, dwell time.Duration) (State, error)
}
// UnavailableError means the board control path has not been deployed or is
// temporarily unavailable. It deliberately contains no raw host path.
type UnavailableError struct {
Reason string
}
func (e *UnavailableError) Error() string {
if e == nil || e.Reason == "" {
return "green LED controller unavailable"
}
return "green LED controller unavailable: " + e.Reason
}
func IsUnavailable(err error) bool {
var unavailable *UnavailableError
return errors.As(err, &unavailable)
}
// UnavailableController keeps API reads deterministic before the DTB and
// privileged hardware helper are deployed.
type UnavailableController struct {
Reason string
}
func (c UnavailableController) States(context.Context) ([]State, error) {
states := make([]State, 0, ChannelCount)
for channel := 1; channel <= ChannelCount; channel++ {
state, _ := stateForChannel(channel)
state.Message = c.reason()
states = append(states, state)
}
return states, nil
}
func (c UnavailableController) SetForce(context.Context, string, bool) (State, error) {
return State{}, &UnavailableError{Reason: c.reason()}
}
func (c UnavailableController) Pulse(context.Context, string, time.Duration) (State, error) {
return State{}, &UnavailableError{Reason: c.reason()}
}
func (c UnavailableController) reason() string {
if strings.TrimSpace(c.Reason) != "" {
return strings.TrimSpace(c.Reason)
}
return "tera-hwd is not connected"
}
// SysfsController controls only /sys/class/leds/chNN-green-led-force/
// brightness entries created by the active gpio-leds device tree fragment.
type SysfsController struct {
root string
mu sync.Mutex
}
func NewSysfsController(root string) *SysfsController {
if strings.TrimSpace(root) == "" {
root = DefaultSysfsRoot
}
return &SysfsController{root: filepath.Clean(root)}
}
func (c *SysfsController) States(ctx context.Context) ([]State, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
c.mu.Lock()
defer c.mu.Unlock()
states := make([]State, 0, ChannelCount)
for channel := 1; channel <= ChannelCount; channel++ {
state, _ := stateForChannel(channel)
states = append(states, c.readStateLocked(state))
}
return states, nil
}
func (c *SysfsController) SetForce(ctx context.Context, channel string, force bool) (State, error) {
if err := ctx.Err(); err != nil {
return State{}, err
}
state, err := stateForChannelName(channel)
if err != nil {
return State{}, err
}
c.mu.Lock()
defer c.mu.Unlock()
if err := c.writeForceLocked(state, force); err != nil {
return State{}, err
}
return c.readStateLocked(state), nil
}
func (c *SysfsController) Pulse(ctx context.Context, channel string, dwell time.Duration) (State, error) {
if dwell < MinPulseDuration || dwell > MaxPulseDuration {
return State{}, fmt.Errorf("dwell_ms must be between %d and %d", MinPulseDuration.Milliseconds(), MaxPulseDuration.Milliseconds())
}
if err := ctx.Err(); err != nil {
return State{}, err
}
state, err := stateForChannelName(channel)
if err != nil {
return State{}, err
}
c.mu.Lock()
defer c.mu.Unlock()
if err := c.writeForceLocked(state, true); err != nil {
return State{}, err
}
timer := time.NewTimer(dwell)
select {
case <-ctx.Done():
if releaseErr := c.writeForceLocked(state, false); releaseErr != nil {
return State{}, releaseErr
}
return State{}, ctx.Err()
case <-timer.C:
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
if err := c.writeForceLocked(state, false); err != nil {
return State{}, err
}
return c.readStateLocked(state), nil
}
func stateForChannelName(channel string) (State, error) {
normalized := strings.ToUpper(strings.TrimSpace(channel))
if len(normalized) != 4 || !strings.HasPrefix(normalized, "CH") {
return State{}, fmt.Errorf("channel must be CH01 through CH48")
}
number, err := strconv.Atoi(normalized[2:])
if err != nil {
return State{}, fmt.Errorf("channel must be CH01 through CH48")
}
return stateForChannel(number)
}
func stateForChannel(channel int) (State, error) {
if channel < 1 || channel > ChannelCount {
return State{}, fmt.Errorf("channel must be CH01 through CH48")
}
return State{
Channel: fmt.Sprintf("CH%02d", channel),
LEDName: fmt.Sprintf("ch%02d-green-led-force", channel),
I2CBus: 3,
I2CAddress: fmt.Sprintf("0x%02x", 0x20+(channel-1)/8),
GPIOOffset: (channel - 1) % 8,
}, nil
}
func (c *SysfsController) readStateLocked(state State) State {
value, err := os.ReadFile(c.brightnessPath(state))
if err != nil {
if os.IsNotExist(err) {
state.Message = "kernel gpio-leds entry is unavailable"
return state
}
state.Message = "kernel gpio-leds state cannot be read"
return state
}
state.Available = true
switch strings.TrimSpace(string(value)) {
case "0":
state.Forced = false
case "1":
state.Forced = true
default:
state.Message = "kernel gpio-leds brightness value is invalid"
}
return state
}
func (c *SysfsController) writeForceLocked(state State, force bool) error {
value := []byte("0\n")
if force {
value = []byte("1\n")
}
if err := os.WriteFile(c.brightnessPath(state), value, 0); err != nil {
if os.IsNotExist(err) {
return &UnavailableError{Reason: "kernel gpio-leds entry is unavailable"}
}
if os.IsPermission(err) {
return &UnavailableError{Reason: "green LED hardware helper lacks gpio-leds access"}
}
return fmt.Errorf("green LED gpio-leds write failed")
}
return nil
}
func (c *SysfsController) brightnessPath(state State) string {
return filepath.Join(c.root, state.LEDName, "brightness")
}