Skip to content

Commit 077bcce

Browse files
feat: add support for gude pdu
Signed-off-by: jonas loeffelholz <jonas.loeffelholz@9elements.com>
1 parent c12ae0a commit 077bcce

3 files changed

Lines changed: 673 additions & 0 deletions

File tree

pkg/module/pdu/gude.go

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
// Copyright 2025 Blindspot Software
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
// Package pdu provides a dutagent module that allows power control of a PDU via HTTP requests.
6+
package pdu
7+
8+
import (
9+
"context"
10+
"encoding/json"
11+
"fmt"
12+
"io"
13+
"net/url"
14+
"strconv"
15+
"strings"
16+
17+
"github.com/BlindspotSoftware/dutctl/pkg/module"
18+
)
19+
20+
type gudeCommands int
21+
22+
const (
23+
gudeSwitchCommand gudeCommands = iota
24+
gudeBatchModeCommand
25+
gudeResetCommand
26+
)
27+
28+
var gudecommandString = map[gudeCommands]string{
29+
gudeSwitchCommand: "1",
30+
gudeBatchModeCommand: "2",
31+
gudeResetCommand: "12",
32+
}
33+
34+
func (g gudeCommands) String() string {
35+
return gudecommandString[g]
36+
}
37+
38+
type gudeState int
39+
40+
const (
41+
gudeStateOff gudeState = 0
42+
gudeStateOn gudeState = 1
43+
)
44+
45+
var gudeStateString = map[gudeState]string{
46+
gudeStateOff: off,
47+
gudeStateOn: on,
48+
}
49+
50+
func (g gudeState) String() string {
51+
return gudeStateString[g]
52+
}
53+
54+
var gudeStateParameter = map[gudeState]string{
55+
gudeStateOff: "0",
56+
gudeStateOn: "1",
57+
}
58+
59+
func (g gudeState) getAPIParameter() string {
60+
return gudeStateParameter[g]
61+
}
62+
63+
func newGudeStateFromInt(state int) (gudeState, error) {
64+
switch state {
65+
case 1:
66+
return gudeStateOn, nil
67+
case 0:
68+
return gudeStateOff, nil
69+
default:
70+
return -1, fmt.Errorf("invalid state: %d", state)
71+
}
72+
}
73+
74+
func newGudeStateFromString(state string) (gudeState, error) {
75+
switch state {
76+
case "on":
77+
return gudeStateOn, nil
78+
case "off":
79+
return gudeStateOff, nil
80+
default:
81+
return -1, fmt.Errorf("invalid state: %s", state)
82+
}
83+
}
84+
85+
// gudeStateResponse represents the JSON response from Gude PDU status endpoint
86+
type gudeStateResponse struct {
87+
Outputs []gudeOutput `json:"outputs"`
88+
}
89+
90+
// gudeOutput represents a single power output in the Gude PDU
91+
type gudeOutput struct {
92+
Name string `json:"name"`
93+
State int `json:"state"` // 0 = off, 1 = on
94+
SwCnt int `json:"sw_cnt"`
95+
Type int `json:"type"`
96+
Batch []int `json:"batch"`
97+
Wdog []any `json:"wdog"`
98+
}
99+
100+
type gude struct {
101+
pdu *PDU
102+
}
103+
104+
func (g *gude) getOutletAPIParameter() string {
105+
outlet := g.pdu.Outlet + 1
106+
return strconv.Itoa(outlet)
107+
}
108+
109+
func (g *gude) init() error {
110+
p := g.pdu
111+
112+
controlURL, err := url.Parse(strings.TrimRight(p.Host, "/") + "/ov.html")
113+
if err != nil {
114+
return err
115+
}
116+
117+
p.controlURL = controlURL
118+
119+
statusURL, err := url.Parse(strings.TrimRight(p.Host, "/") + "/statusjsn.js?components=1")
120+
if err != nil {
121+
return err
122+
}
123+
124+
p.statusURL = statusURL
125+
126+
return nil
127+
}
128+
129+
func (g *gude) setPower(ctx context.Context, s module.Session, state string) error {
130+
p := g.pdu
131+
132+
var err error
133+
134+
switch state {
135+
case on, off:
136+
err = g.switchPower(ctx, s, state)
137+
case toggle:
138+
err = g.togglePower(ctx, s, state)
139+
}
140+
141+
if err != nil {
142+
return err
143+
}
144+
145+
s.Printf("PDU outlet%d power set to '%s' successfully\n", p.Outlet, state)
146+
147+
return nil
148+
}
149+
150+
func (g *gude) getState(ctx context.Context, s module.Session) error {
151+
p := g.pdu
152+
153+
state, err := g.fetchOutletState(ctx)
154+
if err != nil {
155+
return err
156+
}
157+
158+
s.Printf("PDU outlet %d state: %s\n", p.Outlet, state.String())
159+
160+
return nil
161+
}
162+
163+
func (g *gude) switchPower(ctx context.Context, s module.Session, newState string) error {
164+
p := g.pdu
165+
166+
state, err := newGudeStateFromString(newState)
167+
if err != nil {
168+
return err
169+
}
170+
171+
q := p.controlURL.Query()
172+
q.Set("cmd", gudeSwitchCommand.String())
173+
q.Set("p", g.getOutletAPIParameter())
174+
q.Set("s", state.getAPIParameter())
175+
176+
p.controlURL.RawQuery = q.Encode()
177+
178+
resp, err := doRequest(p, ctx, p.controlURL.String())
179+
if err != nil {
180+
return err
181+
}
182+
defer resp.Body.Close()
183+
184+
return nil
185+
}
186+
187+
func (g *gude) togglePower(ctx context.Context, s module.Session, state string) error {
188+
p := g.pdu
189+
190+
currentState, err := g.fetchOutletState(ctx)
191+
if err != nil {
192+
return err
193+
}
194+
195+
var nextState gudeState
196+
nextState = gudeState((currentState + 1) % 2)
197+
198+
q := p.controlURL.Query()
199+
q.Set("cmd", gudeSwitchCommand.String())
200+
q.Set("p", g.getOutletAPIParameter())
201+
q.Set("s", nextState.getAPIParameter())
202+
203+
p.controlURL.RawQuery = q.Encode()
204+
205+
resp, err := doRequest(p, ctx, p.controlURL.String())
206+
if err != nil {
207+
return err
208+
}
209+
defer resp.Body.Close()
210+
211+
return nil
212+
}
213+
214+
func (g *gude) fetchOutletState(ctx context.Context) (gudeState, error) {
215+
resp, err := doRequest(g.pdu, ctx, g.pdu.statusURL.String())
216+
if err != nil {
217+
return -1, err
218+
}
219+
defer resp.Body.Close()
220+
221+
body, err := io.ReadAll(resp.Body)
222+
if err != nil {
223+
return -1, err
224+
}
225+
226+
value, err := g.parseOutletStatus(body)
227+
if err != nil {
228+
return -1, err
229+
}
230+
231+
state, err := newGudeStateFromInt(value)
232+
if err != nil {
233+
return -1, err
234+
}
235+
236+
return state, nil
237+
}
238+
239+
// extract the outlet status from JSON response body.
240+
func (g *gude) parseOutletStatus(body []byte) (int, error) {
241+
p := g.pdu
242+
243+
var status gudeStateResponse
244+
if err := json.Unmarshal(body, &status); err != nil {
245+
return -1, fmt.Errorf("failed to parse JSON response: %w", err)
246+
}
247+
248+
if len(status.Outputs) == 0 {
249+
return -1, fmt.Errorf("no outputs found in PDU status response")
250+
}
251+
252+
if p.Outlet >= len(status.Outputs) {
253+
return -1, fmt.Errorf("outlet %d not found in PDU status (only %d outlets available)", p.Outlet, len(status.Outputs))
254+
}
255+
256+
output := status.Outputs[p.Outlet]
257+
258+
return output.State, nil
259+
}

0 commit comments

Comments
 (0)