From 5cc87c7fbe14cbb3b13f5434364af34f93bf8419 Mon Sep 17 00:00:00 2001 From: jonas loeffelholz Date: Fri, 19 Jun 2026 10:57:35 +0200 Subject: [PATCH] feat: add support for gude pdus *adds support for flexible pdu apis through backend interface pattern *moves intellinet (style) api interface to separate file *adds support for gude api Signed-off-by: jonas loeffelholz --- pkg/module/pdu/README.md | 15 +- pkg/module/pdu/gude.go | 254 +++++++++++ pkg/module/pdu/gude_test.go | 406 ++++++++++++++++++ pkg/module/pdu/intellinet.go | 132 ++++++ .../pdu/{pdu_test.go => intellinet_test.go} | 7 +- pkg/module/pdu/pdu-example-cfg.yml | 5 +- pkg/module/pdu/pdu.go | 149 ++----- 7 files changed, 850 insertions(+), 118 deletions(-) create mode 100644 pkg/module/pdu/gude.go create mode 100644 pkg/module/pdu/gude_test.go create mode 100644 pkg/module/pdu/intellinet.go rename pkg/module/pdu/{pdu_test.go => intellinet_test.go} (97%) diff --git a/pkg/module/pdu/README.md b/pkg/module/pdu/README.md index f01f6dfe..a89a49a5 100644 --- a/pkg/module/pdu/README.md +++ b/pkg/module/pdu/README.md @@ -23,13 +23,14 @@ pdu [on|off|toggle|status] If no command is provided, the module prints a usage message and exits. -See [pdu-example-cfg.yml](./pdu-example-cfg.yml) for examples. +See [pdu-example-cfg.yml](./pdu-example-cfg.yml) for examples. ## Configuration Options -| Option | Type | Description | -| ---------- | ------ | ---------------------------------------------- | -| `host` | string | Base URL of the PDU (e.g. `10.0.0.5`) | -| `user` | string | (Optional) Username for HTTP Basic Auth | -| `password` | string | (Optional) Password for HTTP Basic Auth | -| `outlet` | int | Outlet number to control (0-15, defaults to 0) | +| Option | Type | Description | +| ---------- | ------ | ----------------------------------------------------------- | +| `apistyle` | string | apiStyle of PDU either `gude` or `intellinet` default: 'intellinet' | +| `host` | string | Base URL of the PDU (e.g. `10.0.0.5`) | +| `user` | string | (Optional) Username for HTTP Basic Auth | +| `password` | string | (Optional) Password for HTTP Basic Auth | +| `outlet` | int | Outlet number to control (0-15, defaults to 0) | diff --git a/pkg/module/pdu/gude.go b/pkg/module/pdu/gude.go new file mode 100644 index 00000000..e17a2372 --- /dev/null +++ b/pkg/module/pdu/gude.go @@ -0,0 +1,254 @@ +// Copyright 2025 Blindspot Software +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package pdu + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/url" + "strconv" + "strings" + + "github.com/BlindspotSoftware/dutctl/pkg/module" +) + +type gudeCommands int + +const ( + gudeSwitchCommand gudeCommands = iota + gudeBatchModeCommand + gudeResetCommand +) + +func (g gudeCommands) String() string { + switch g { + case gudeSwitchCommand: + return "1" + case gudeBatchModeCommand: + return "2" + case gudeResetCommand: + return "12" + default: + return "" + } +} + +type gudeState int + +const ( + gudeStateOff gudeState = iota + gudeStateOn + gudeStateCount // used to do modulo to wrap around +) + +func (g gudeState) String() string { + switch g { + case gudeStateOff: + return off + case gudeStateOn: + return on + default: + return "" + } +} + +func (g gudeState) getAPIParameter() string { + switch g { + case gudeStateOff: + return "0" + case gudeStateOn: + return "1" + default: + return "" + } +} + +func newGudeStateFromInt(state int) (gudeState, error) { + switch state { + case 1: + return gudeStateOn, nil + case 0: + return gudeStateOff, nil + default: + return -1, fmt.Errorf("invalid state: %d", state) + } +} + +func newGudeStateFromString(state string) (gudeState, error) { + switch state { + case "on": + return gudeStateOn, nil + case "off": + return gudeStateOff, nil + default: + return -1, fmt.Errorf("invalid state: %s", state) + } +} + +// gudeStateResponse represents the JSON response from Gude PDU status endpoint. +type gudeStateResponse struct { + Outputs []gudeOutput `json:"outputs"` +} + +// gudeOutput represents a single power output in the Gude PDU. +type gudeOutput struct { + Name string `json:"name"` + State int `json:"state"` // 0 = off, 1 = on. + SwCnt int `json:"sw_cnt"` //nolint:tagliatelle // JSON field name is defined by device API. + Type int `json:"type"` + Batch []int `json:"batch"` + Wdog []any `json:"wdog"` +} + +type gude struct{} + +func (g gude) getOutletAPIParameter(pdu *PDU) string { + outlet := pdu.Outlet + 1 + + return strconv.Itoa(outlet) +} + +func (g gude) init(pdu *PDU) error { + controlURL, err := url.Parse(strings.TrimRight(pdu.Host, "/") + "/ov.html") + if err != nil { + return err + } + + pdu.controlURL = controlURL + + statusURL, err := url.Parse(strings.TrimRight(pdu.Host, "/") + "/statusjsn.js?components=1") + if err != nil { + return err + } + + pdu.statusURL = statusURL + + return nil +} + +func (g gude) setPower(ctx context.Context, s module.Session, pdu *PDU, state string) error { + var err error + + switch state { + case on, off: + err = g.switchPower(ctx, pdu, state) + case toggle: + err = g.togglePower(ctx, pdu) + } + + if err != nil { + return err + } + + pdu.printPowerSet(s, state) + + return nil +} + +func (g gude) fetchState(ctx context.Context, s module.Session, pdu *PDU) error { + state, err := g.fetchOutletState(ctx, pdu) + if err != nil { + return err + } + + pdu.printState(s, state.String()) + + return nil +} + +func (g gude) switchPower(ctx context.Context, pdu *PDU, newState string) error { + state, err := newGudeStateFromString(newState) + if err != nil { + return err + } + + q := pdu.controlURL.Query() + q.Set("cmd", gudeSwitchCommand.String()) + q.Set("p", g.getOutletAPIParameter(pdu)) + q.Set("s", state.getAPIParameter()) + + pdu.controlURL.RawQuery = q.Encode() + + resp, err := pdu.doRequest(ctx, pdu.controlURL.String()) + if err != nil { + return err + } + defer resp.Body.Close() + + return nil +} + +func (g gude) togglePower(ctx context.Context, pdu *PDU) error { + currentState, err := g.fetchOutletState(ctx, pdu) + if err != nil { + return err + } + + var nextState = ((currentState + 1) % gudeStateCount) + + q := pdu.controlURL.Query() + q.Set("cmd", gudeSwitchCommand.String()) + q.Set("p", g.getOutletAPIParameter(pdu)) + q.Set("s", nextState.getAPIParameter()) + + pdu.controlURL.RawQuery = q.Encode() + + resp, err := pdu.doRequest(ctx, pdu.controlURL.String()) + if err != nil { + return err + } + defer resp.Body.Close() + + return nil +} + +func (g gude) fetchOutletState(ctx context.Context, pdu *PDU) (gudeState, error) { + resp, err := pdu.doRequest(ctx, pdu.statusURL.String()) + if err != nil { + return -1, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return -1, err + } + + value, err := g.parseOutletStatus(pdu, body) + if err != nil { + return -1, err + } + + state, err := newGudeStateFromInt(value) + if err != nil { + return -1, err + } + + return state, nil +} + +// extract the outlet status from JSON response body. +func (g gude) parseOutletStatus(pdu *PDU, body []byte) (int, error) { + var status gudeStateResponse + + err := json.Unmarshal(body, &status) + if err != nil { + return -1, fmt.Errorf("failed to parse JSON response: %w", err) + } + + if len(status.Outputs) == 0 { + return -1, fmt.Errorf("no outputs found in PDU status response") + } + + if pdu.Outlet >= len(status.Outputs) { + return -1, fmt.Errorf("outlet %d not found in PDU status (only %d outlets available)", pdu.Outlet, len(status.Outputs)) + } + + output := status.Outputs[pdu.Outlet] + + return output.State, nil +} diff --git a/pkg/module/pdu/gude_test.go b/pkg/module/pdu/gude_test.go new file mode 100644 index 00000000..a740e7ec --- /dev/null +++ b/pkg/module/pdu/gude_test.go @@ -0,0 +1,406 @@ +// Copyright 2025 Blindspot Software +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package pdu + +import ( + "testing" +) + +func TestGudeCommandsString(t *testing.T) { + tests := []struct { + name string + cmd gudeCommands + expected string + }{ + { + name: "switch command", + cmd: gudeSwitchCommand, + expected: "1", + }, + { + name: "batch mode command", + cmd: gudeBatchModeCommand, + expected: "2", + }, + { + name: "reset command", + cmd: gudeResetCommand, + expected: "12", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.cmd.String() + if result != tt.expected { + t.Errorf("String() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestGudeStateString(t *testing.T) { + tests := []struct { + name string + state gudeState + expected string + }{ + { + name: "state off", + state: gudeStateOff, + expected: "off", + }, + { + name: "state on", + state: gudeStateOn, + expected: "on", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.state.String() + if result != tt.expected { + t.Errorf("String() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestGudeStateGetAPIParameter(t *testing.T) { + tests := []struct { + name string + state gudeState + expected string + }{ + { + name: "state off returns 0", + state: gudeStateOff, + expected: "0", + }, + { + name: "state on returns 1", + state: gudeStateOn, + expected: "1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.state.getAPIParameter() + if result != tt.expected { + t.Errorf("getAPIParameter() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestNewGudeStateFromInt(t *testing.T) { + tests := []struct { + name string + input int + expected gudeState + err bool + }{ + { + name: "0 returns state off", + input: 0, + expected: gudeStateOff, + err: false, + }, + { + name: "1 returns state on", + input: 1, + expected: gudeStateOn, + err: false, + }, + { + name: "2 returns error", + input: 2, + expected: -1, + err: true, + }, + { + name: "negative value returns error", + input: -1, + expected: -1, + err: true, + }, + { + name: "large value returns error", + input: 999, + expected: -1, + err: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := newGudeStateFromInt(tt.input) + + if tt.err { + if err == nil { + t.Errorf("newGudeStateFromInt() expected error but got none") + } + return + } + + if err != nil { + t.Errorf("newGudeStateFromInt() unexpected error: %v", err) + return + } + + if result != tt.expected { + t.Errorf("newGudeStateFromInt() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestNewGudeStateFromString(t *testing.T) { + tests := []struct { + name string + input string + expected gudeState + err bool + }{ + { + name: "on returns state on", + input: "on", + expected: gudeStateOn, + err: false, + }, + { + name: "off returns state off", + input: "off", + expected: gudeStateOff, + err: false, + }, + { + name: "invalid string returns error", + input: "invalid", + expected: -1, + err: true, + }, + { + name: "empty string returns error", + input: "", + expected: -1, + err: true, + }, + { + name: "toggle returns error", + input: "toggle", + expected: -1, + err: true, + }, + { + name: "On with capital returns error (case sensitive)", + input: "On", + expected: -1, + err: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := newGudeStateFromString(tt.input) + + if tt.err { + if err == nil { + t.Errorf("newGudeStateFromString() expected error but got none") + } + return + } + + if err != nil { + t.Errorf("newGudeStateFromString() unexpected error: %v", err) + return + } + + if result != tt.expected { + t.Errorf("newGudeStateFromString() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestGudeParseOutletStatus(t *testing.T) { + tests := []struct { + name string + outlet int + jsonBody string + expected int + err bool + }{ + { + name: "outlet 0 off", + outlet: 0, + jsonBody: `{ + "outputs": [ + {"name": "Power Port", "state": 0, "sw_cnt": 8, "type": 1, "batch": [0,0,0,0,0,0], "wdog": [0,3,null,32]}, + {"name": "Power Port", "state": 1, "sw_cnt": 0, "type": 1, "batch": [0,0,0,0,0,0], "wdog": [0,3,null,32]} + ] + }`, + expected: 0, + err: false, + }, + { + name: "outlet 1 on", + outlet: 1, + jsonBody: `{ + "outputs": [ + {"name": "Power Port", "state": 0, "sw_cnt": 8, "type": 1, "batch": [0,0,0,0,0,0], "wdog": [0,3,null,32]}, + {"name": "Power Port", "state": 1, "sw_cnt": 0, "type": 1, "batch": [0,0,0,0,0,0], "wdog": [0,3,null,32]} + ] + }`, + expected: 1, + err: false, + }, + { + name: "real PDU response - 4 outlets", + outlet: 2, + jsonBody: `{ + "outputs": [ + {"name": "Power Port", "state": 0, "sw_cnt": 8, "type": 1, "batch": [0,0,0,0,0,0], "wdog": [0,3,null,32]}, + {"name": "Power Port", "state": 0, "sw_cnt": 0, "type": 1, "batch": [0,0,0,0,0,0], "wdog": [0,3,null,32]}, + {"name": "Power Port", "state": 1, "sw_cnt": 0, "type": 1, "batch": [0,0,0,0,0,0], "wdog": [0,3,null,32]}, + {"name": "Power Port", "state": 0, "sw_cnt": 0, "type": 1, "batch": [0,0,0,0,0,0], "wdog": [0,3,null,32]} + ] + }`, + expected: 1, + err: false, + }, + { + name: "outlet not found - out of range", + outlet: 5, + jsonBody: `{ + "outputs": [ + {"name": "Power Port", "state": 0, "sw_cnt": 8, "type": 1, "batch": [0,0,0,0,0,0], "wdog": [0,3,null,32]}, + {"name": "Power Port", "state": 1, "sw_cnt": 0, "type": 1, "batch": [0,0,0,0,0,0], "wdog": [0,3,null,32]} + ] + }`, + expected: -1, + err: true, + }, + { + name: "malformed JSON - missing closing brace", + outlet: 0, + jsonBody: `{"outputs": [{"name": "Power Port", "state": 0}`, + expected: -1, + err: true, + }, + { + name: "empty outputs array", + outlet: 0, + jsonBody: `{"outputs": []}`, + expected: -1, + err: true, + }, + { + name: "empty JSON", + outlet: 0, + jsonBody: ``, + expected: -1, + err: true, + }, + { + name: "invalid JSON - not an object", + outlet: 0, + jsonBody: `null`, + expected: -1, + err: true, + }, + { + name: "missing outputs field", + outlet: 0, + jsonBody: `{ + "other_field": "value" + }`, + expected: -1, + err: true, + }, + { + name: "outlet at exact boundary - last outlet", + outlet: 3, + jsonBody: `{ + "outputs": [ + {"name": "Port 1", "state": 0, "sw_cnt": 0, "type": 1, "batch": [0,0,0,0,0,0], "wdog": [0,3,null,32]}, + {"name": "Port 2", "state": 1, "sw_cnt": 0, "type": 1, "batch": [0,0,0,0,0,0], "wdog": [0,3,null,32]}, + {"name": "Port 3", "state": 0, "sw_cnt": 0, "type": 1, "batch": [0,0,0,0,0,0], "wdog": [0,3,null,32]}, + {"name": "Port 4", "state": 1, "sw_cnt": 0, "type": 1, "batch": [0,0,0,0,0,0], "wdog": [0,3,null,32]} + ] + }`, + expected: 1, + err: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := gude{} + p := &PDU{Outlet: tt.outlet} + + result, err := g.parseOutletStatus(p, []byte(tt.jsonBody)) + + if tt.err { + if err == nil { + t.Errorf("parseOutletStatus() expected error but got none") + } + return + } + + if err != nil { + t.Errorf("parseOutletStatus() unexpected error: %v", err) + return + } + + if result != tt.expected { + t.Errorf("parseOutletStatus() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestGudeGetOutletAPIParameter(t *testing.T) { + tests := []struct { + name string + outlet int + expected string + }{ + { + name: "outlet 0 converts to 1", + outlet: 0, + expected: "1", + }, + { + name: "outlet 1 converts to 2", + outlet: 1, + expected: "2", + }, + { + name: "outlet 5 converts to 6", + outlet: 5, + expected: "6", + }, + { + name: "outlet 99 converts to 100", + outlet: 99, + expected: "100", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := gude{} + p := &PDU{Outlet: tt.outlet} + + result := g.getOutletAPIParameter(p) + if result != tt.expected { + t.Errorf("getOutletAPIParameter() = %v, expected %v", result, tt.expected) + } + }) + } +} diff --git a/pkg/module/pdu/intellinet.go b/pkg/module/pdu/intellinet.go new file mode 100644 index 00000000..b59d3102 --- /dev/null +++ b/pkg/module/pdu/intellinet.go @@ -0,0 +1,132 @@ +// Copyright 2025 Blindspot Software +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package pdu + +import ( + "context" + "fmt" + "io" + "net/url" + "strings" + + "github.com/BlindspotSoftware/dutctl/pkg/module" +) + +type intellinet struct{} + +func (i intellinet) init(pdu *PDU) error { + controlURL, err := url.Parse(strings.TrimRight(pdu.Host, "/") + "/control_outlet.htm") + if err != nil { + return err + } + + pdu.controlURL = controlURL + + statusURL, err := url.Parse(strings.TrimRight(pdu.Host, "/") + "/status.xml") + if err != nil { + return err + } + + pdu.statusURL = statusURL + + return nil +} + +func (i intellinet) setPower(ctx context.Context, s module.Session, pdu *PDU, state string) error { + opState, err := parseOp(state) + if err != nil { + return err + } + + q := pdu.controlURL.Query() + q.Set(fmt.Sprintf("outlet%d", pdu.Outlet), "1") + q.Set("op", opState.String()) + pdu.controlURL.RawQuery = q.Encode() + + resp, err := pdu.doRequest(ctx, pdu.controlURL.String()) + if err != nil { + return err + } + defer resp.Body.Close() + + pdu.printPowerSet(s, state) + + return nil +} + +func (i intellinet) fetchState(ctx context.Context, s module.Session, pdu *PDU) error { + resp, err := pdu.doRequest(ctx, pdu.statusURL.String()) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + outletValue, err := i.parseOutletStatus(pdu, body) + if err != nil { + return err + } + + pdu.printState(s, outletValue) + + return nil +} + +// parseOutletStatus extracts the outlet status from XML response body. +func (i intellinet) parseOutletStatus(pdu *PDU, body []byte) (string, error) { + bodyStr := string(body) + + outletTag := fmt.Sprintf("", pdu.Outlet) + outletEndTag := fmt.Sprintf("", pdu.Outlet) + + startIdx := strings.Index(bodyStr, outletTag) + if startIdx == -1 { + return "", fmt.Errorf("outlet %d not found in PDU status", pdu.Outlet) + } + + startIdx += len(outletTag) + + endIdx := strings.Index(bodyStr[startIdx:], outletEndTag) + if endIdx == -1 { + return "", fmt.Errorf("malformed XML for outlet %d", pdu.Outlet) + } + + outletValue := strings.TrimSpace(bodyStr[startIdx : startIdx+endIdx]) + + if outletValue != on && outletValue != off { + return "", fmt.Errorf("unexpected outlet state '%s' for outlet %d", outletValue, pdu.Outlet) + } + + return outletValue, nil +} + +type op string + +const ( + opOn op = "0" + opOff op = "1" + opToggle op = "2" +) + +func (o op) String() string { + return string(o) +} + +func parseOp(state string) (op, error) { + switch state { + case on: + return opOn, nil + case off: + return opOff, nil + case toggle: + return opToggle, nil + default: + return "", fmt.Errorf("invalid PDU operation: %s", state) + } +} diff --git a/pkg/module/pdu/pdu_test.go b/pkg/module/pdu/intellinet_test.go similarity index 97% rename from pkg/module/pdu/pdu_test.go rename to pkg/module/pdu/intellinet_test.go index 02c24177..f18297ad 100644 --- a/pkg/module/pdu/pdu_test.go +++ b/pkg/module/pdu/intellinet_test.go @@ -116,11 +116,10 @@ func TestParseOutletStatus(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - p := &PDU{ - Outlet: tt.outlet, - } + i := intellinet{} + p := &PDU{Outlet: tt.outlet} - result, err := p.parseOutletStatus([]byte(tt.xmlBody)) + result, err := i.parseOutletStatus(p, []byte(tt.xmlBody)) if tt.err { if err == nil { diff --git a/pkg/module/pdu/pdu-example-cfg.yml b/pkg/module/pdu/pdu-example-cfg.yml index 56a3aa10..99c55669 100644 --- a/pkg/module/pdu/pdu-example-cfg.yml +++ b/pkg/module/pdu/pdu-example-cfg.yml @@ -3,7 +3,7 @@ devices: fancy-server: desc: "A server with power control via PDU" cmds: - power-on: + power: desc: "Turn the power ON via PDU" uses: - module: pdu @@ -12,4 +12,5 @@ devices: host: http://192.168.1.100 user: admin password: admin - outlet: 6 + outlet: 1 + pdutype: intellinet diff --git a/pkg/module/pdu/pdu.go b/pkg/module/pdu/pdu.go index 5e3f1364..74ac8da2 100644 --- a/pkg/module/pdu/pdu.go +++ b/pkg/module/pdu/pdu.go @@ -27,9 +27,41 @@ func init() { }) } +type apiStyle string + +const ( + intellinetAPI apiStyle = "intellinet" + gudeAPI apiStyle = "gude" +) + +//nolint:ireturn // factory intentionally returns different flavors of the same interface for backend polymorphism +//nolint:ireturn // factory intentionally returns interface for backend polymorphism +func newPDUBackend(style apiStyle) pdu { + switch style { + case gudeAPI: + return gude{} + case intellinetAPI: + return intellinet{} + default: // Legacy configs dont contain PDUType and are meant for Intillinet (style) PDUs + return intellinet{} + } +} + +// This interface allows flexibility across different PDU API styles. +// Any PDU that exposes an HTTP interface for power switching and outlet +// status can be adapted to work with this module by implementing it. +type pdu interface { + setPower(ctx context.Context, s module.Session, p *PDU, state string) error + fetchState(ctx context.Context, s module.Session, p *PDU) error + init(p *PDU) error +} + // PDU is a module that provides basic power management functions for a PDU (Power Distribution Unit). -// NOTE: This implementation currently supports only Intellinet ATM PDUs. +// NOTE: This implementation currently supports Intellinet style (e.g. Intellinet 163682, LogiLink PDU8P01) and Gude PDUs. type PDU struct { + apiStyle // Flavor of pdu used, currently `intellinet` and 'gude' are supported api styles. + pdu + Host string // Host is the address of the PDU User string // User is used for authentication, if supported by the PDU Password string // Password is used for authentication, if supported by the PDU @@ -70,28 +102,20 @@ const ( ) func (p *PDU) Init() error { - log.Printf("pdu module: Init called - Host: %s, User: %s, Outlet: %d", p.Host, p.User, p.Outlet) + log.Printf("pdu module: Init called - Host: %s, User: %s, Outlet: %d, Type: %s", p.Host, p.User, p.Outlet, p.apiStyle) if p.Outlet < 0 { return fmt.Errorf("invalid outlet number %d: outlet must be 0 or greater", p.Outlet) } p.client = &http.Client{Timeout: defaultTimeout} + p.pdu = newPDUBackend(p.apiStyle) - controlURL, err := url.Parse(strings.TrimRight(p.Host, "/") + "/control_outlet.htm") - if err != nil { - return err - } - - p.controlURL = controlURL - - statusURL, err := url.Parse(strings.TrimRight(p.Host, "/") + "/status.xml") + err := p.init(p) if err != nil { return err } - p.statusURL = statusURL - log.Printf("pdu module: Init completed - controlURL: %s, statusURL: %s", p.controlURL.String(), p.statusURL.String()) return nil @@ -122,9 +146,9 @@ func (p *PDU) Run(ctx context.Context, s module.Session, args ...string) error { switch cmd { case on, off, toggle: - return p.setPower(ctx, s, cmd) + return p.setPower(ctx, s, p, cmd) case status: - return p.status(ctx, s) + return p.fetchState(ctx, s, p) default: s.Println("Unknown command: " + cmd) s.Println("Available commands: on, off, toggle, status") @@ -159,99 +183,14 @@ func (p *PDU) doRequest(ctx context.Context, url string) (*http.Response, error) return resp, nil } -func (p *PDU) setPower(ctx context.Context, s module.Session, state string) error { - opState, err := parseOp(state) - if err != nil { - return err - } - - q := p.controlURL.Query() - q.Set(fmt.Sprintf("outlet%d", p.Outlet), "1") - q.Set("op", opState.String()) - p.controlURL.RawQuery = q.Encode() - - resp, err := p.doRequest(ctx, p.controlURL.String()) - if err != nil { - return err - } - defer resp.Body.Close() - - s.Printf("PDU outlet%d power set to '%s' successfully\n", p.Outlet, state) - - return nil +func (p *PDU) outletLabel() string { + return fmt.Sprintf("outlet%d", p.Outlet) } -func (p *PDU) status(ctx context.Context, s module.Session) error { - resp, err := p.doRequest(ctx, p.statusURL.String()) - if err != nil { - return err - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - - outletValue, err := p.parseOutletStatus(body) - if err != nil { - return err - } - - s.Printf("PDU outlet%d state: %s\n", p.Outlet, outletValue) - - return nil -} - -// parseOutletStatus extracts the outlet status from XML response body. -func (p *PDU) parseOutletStatus(body []byte) (string, error) { - bodyStr := string(body) - - outletTag := fmt.Sprintf("", p.Outlet) - outletEndTag := fmt.Sprintf("", p.Outlet) - - startIdx := strings.Index(bodyStr, outletTag) - if startIdx == -1 { - return "", fmt.Errorf("outlet %d not found in PDU status", p.Outlet) - } - - startIdx += len(outletTag) - - endIdx := strings.Index(bodyStr[startIdx:], outletEndTag) - if endIdx == -1 { - return "", fmt.Errorf("malformed XML for outlet %d", p.Outlet) - } - - outletValue := strings.TrimSpace(bodyStr[startIdx : startIdx+endIdx]) - - if outletValue != on && outletValue != off { - return "", fmt.Errorf("unexpected outlet state '%s' for outlet %d", outletValue, p.Outlet) - } - - return outletValue, nil +func (p *PDU) printPowerSet(s module.Session, state string) { + s.Printf("PDU %s power set to '%s' successfully\n", p.outletLabel(), state) } -type op string - -const ( - opOn op = "0" - opOff op = "1" - opToggle op = "2" -) - -func (o op) String() string { - return string(o) -} - -func parseOp(state string) (op, error) { - switch state { - case on: - return opOn, nil - case off: - return opOff, nil - case toggle: - return opToggle, nil - default: - return "", fmt.Errorf("invalid PDU operation: %s", state) - } +func (p *PDU) printState(s module.Session, state string) { + s.Printf("PDU %s state: %s\n", p.outletLabel(), state) }