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