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.
245 lines
6.7 KiB
245 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")
|
|
}
|
|
|