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