All checks were successful
continuous-integration/drone/push Build is passing
233 lines
6.5 KiB
Go
233 lines
6.5 KiB
Go
package mk2driver
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type WriterProfile string
|
|
|
|
const (
|
|
WriterProfileNormal WriterProfile = "normal"
|
|
WriterProfileMaintenance WriterProfile = "maintenance"
|
|
WriterProfileReadOnly WriterProfile = "read_only"
|
|
)
|
|
|
|
type WriterPolicy struct {
|
|
Profile WriterProfile
|
|
MaxCurrentLimitA *float64
|
|
ModeChangeMinInterval time.Duration
|
|
LockoutWindow time.Duration
|
|
AllowedPanelStates map[PanelSwitchState]struct{}
|
|
}
|
|
|
|
type CommandEvent struct {
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Source CommandSource `json:"source"`
|
|
Kind string `json:"kind"`
|
|
Allowed bool `json:"allowed"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type ManagedWriter struct {
|
|
writer SettingsWriter
|
|
policy WriterPolicy
|
|
|
|
mu sync.Mutex
|
|
lastModeChange time.Time
|
|
lockoutUntil time.Time
|
|
events []CommandEvent
|
|
}
|
|
|
|
var _ SettingsWriter = (*ManagedWriter)(nil)
|
|
var _ SourceAwareSettingsWriter = (*ManagedWriter)(nil)
|
|
|
|
func NewManagedWriter(writer SettingsWriter, policy WriterPolicy) *ManagedWriter {
|
|
if policy.Profile == "" {
|
|
policy.Profile = WriterProfileNormal
|
|
}
|
|
return &ManagedWriter{
|
|
writer: writer,
|
|
policy: policy,
|
|
events: make([]CommandEvent, 0, 100),
|
|
}
|
|
}
|
|
|
|
func (m *ManagedWriter) WriteRAMVar(id uint16, value int16) error {
|
|
return m.WriteRAMVarWithSource(CommandSourceUnknown, id, value)
|
|
}
|
|
|
|
func (m *ManagedWriter) WriteSetting(id uint16, value int16) error {
|
|
return m.WriteSettingWithSource(CommandSourceUnknown, id, value)
|
|
}
|
|
|
|
func (m *ManagedWriter) SetPanelState(switchState PanelSwitchState, currentLimitA *float64) error {
|
|
return m.SetPanelStateWithSource(CommandSourceUnknown, switchState, currentLimitA)
|
|
}
|
|
|
|
func (m *ManagedWriter) SetStandby(enabled bool) error {
|
|
return m.SetStandbyWithSource(CommandSourceUnknown, enabled)
|
|
}
|
|
|
|
func (m *ManagedWriter) WriteRAMVarWithSource(source CommandSource, id uint16, value int16) error {
|
|
return m.apply(source, "write_ram_var", func() error {
|
|
if err := m.ensureProfileAllows("write_ram_var"); err != nil {
|
|
return err
|
|
}
|
|
return m.baseWriter().WriteRAMVar(id, value)
|
|
})
|
|
}
|
|
|
|
func (m *ManagedWriter) WriteSettingWithSource(source CommandSource, id uint16, value int16) error {
|
|
return m.apply(source, "write_setting", func() error {
|
|
if err := m.ensureProfileAllows("write_setting"); err != nil {
|
|
return err
|
|
}
|
|
return m.baseWriter().WriteSetting(id, value)
|
|
})
|
|
}
|
|
|
|
func (m *ManagedWriter) SetPanelStateWithSource(source CommandSource, switchState PanelSwitchState, currentLimitA *float64) error {
|
|
return m.apply(source, "set_panel_state", func() error {
|
|
if err := m.ensureProfileAllows("set_panel_state"); err != nil {
|
|
return err
|
|
}
|
|
if len(m.policy.AllowedPanelStates) > 0 {
|
|
if _, ok := m.policy.AllowedPanelStates[switchState]; !ok {
|
|
return fmt.Errorf(
|
|
"panel switch mode %s denied by allowlist policy; allowed modes: %s",
|
|
panelStateLabel(switchState),
|
|
stringsForAllowedPanelStates(m.policy.AllowedPanelStates),
|
|
)
|
|
}
|
|
}
|
|
if m.policy.MaxCurrentLimitA != nil && currentLimitA != nil && *currentLimitA > *m.policy.MaxCurrentLimitA {
|
|
return fmt.Errorf("current limit %.2fA exceeds configured policy max %.2fA", *currentLimitA, *m.policy.MaxCurrentLimitA)
|
|
}
|
|
if m.policy.Profile == WriterProfileMaintenance && switchState != PanelSwitchOff {
|
|
return errors.New("maintenance profile only allows panel switch off")
|
|
}
|
|
if m.policy.ModeChangeMinInterval > 0 && !m.lastModeChange.IsZero() && time.Since(m.lastModeChange) < m.policy.ModeChangeMinInterval {
|
|
return fmt.Errorf("mode change denied due to rate limit; wait %s", m.policy.ModeChangeMinInterval-time.Since(m.lastModeChange))
|
|
}
|
|
if err := m.baseWriter().SetPanelState(switchState, currentLimitA); err != nil {
|
|
return err
|
|
}
|
|
m.lastModeChange = time.Now().UTC()
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (m *ManagedWriter) SetStandbyWithSource(source CommandSource, enabled bool) error {
|
|
return m.apply(source, "set_standby", func() error {
|
|
if err := m.ensureProfileAllows("set_standby"); err != nil {
|
|
return err
|
|
}
|
|
return m.baseWriter().SetStandby(enabled)
|
|
})
|
|
}
|
|
|
|
func (m *ManagedWriter) History(limit int) []CommandEvent {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
if limit <= 0 || limit > len(m.events) {
|
|
limit = len(m.events)
|
|
}
|
|
out := make([]CommandEvent, limit)
|
|
if limit > 0 {
|
|
copy(out, m.events[len(m.events)-limit:])
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (m *ManagedWriter) apply(source CommandSource, kind string, fn func() error) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
if m.writer == nil {
|
|
err := errors.New("settings writer is not available")
|
|
m.recordLocked(source, kind, false, err)
|
|
return err
|
|
}
|
|
if m.policy.LockoutWindow > 0 && time.Now().UTC().Before(m.lockoutUntil) {
|
|
err := fmt.Errorf("command denied during lockout window until %s", m.lockoutUntil.Format(time.RFC3339))
|
|
m.recordLocked(source, kind, false, err)
|
|
return err
|
|
}
|
|
|
|
if err := fn(); err != nil {
|
|
m.recordLocked(source, kind, false, err)
|
|
return err
|
|
}
|
|
if m.policy.LockoutWindow > 0 {
|
|
m.lockoutUntil = time.Now().UTC().Add(m.policy.LockoutWindow)
|
|
}
|
|
m.recordLocked(source, kind, true, nil)
|
|
return nil
|
|
}
|
|
|
|
func (m *ManagedWriter) ensureProfileAllows(kind string) error {
|
|
switch m.policy.Profile {
|
|
case WriterProfileReadOnly:
|
|
return errors.New("write denied by read-only profile")
|
|
case WriterProfileMaintenance:
|
|
if kind == "set_standby" || kind == "set_panel_state" {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("maintenance profile blocks %s", kind)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (m *ManagedWriter) recordLocked(source CommandSource, kind string, allowed bool, err error) {
|
|
event := CommandEvent{
|
|
Timestamp: time.Now().UTC(),
|
|
Source: source,
|
|
Kind: kind,
|
|
Allowed: allowed,
|
|
}
|
|
if err != nil {
|
|
event.Error = err.Error()
|
|
}
|
|
m.events = append(m.events, event)
|
|
if len(m.events) > 100 {
|
|
m.events = m.events[len(m.events)-100:]
|
|
}
|
|
}
|
|
|
|
func (m *ManagedWriter) baseWriter() SettingsWriter {
|
|
return m.writer
|
|
}
|
|
|
|
func panelStateLabel(state PanelSwitchState) string {
|
|
switch state {
|
|
case PanelSwitchOn:
|
|
return "on"
|
|
case PanelSwitchOff:
|
|
return "off"
|
|
case PanelSwitchChargerOnly:
|
|
return "charger_only"
|
|
case PanelSwitchInverterOnly:
|
|
return "inverter_only"
|
|
default:
|
|
return fmt.Sprintf("unknown(0x%02x)", byte(state))
|
|
}
|
|
}
|
|
|
|
func stringsForAllowedPanelStates(states map[PanelSwitchState]struct{}) string {
|
|
if len(states) == 0 {
|
|
return "<none>"
|
|
}
|
|
labels := make([]string, 0, len(states))
|
|
for state := range states {
|
|
labels = append(labels, panelStateLabel(state))
|
|
}
|
|
sort.Strings(labels)
|
|
return fmt.Sprintf("%v", labels)
|
|
}
|