-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapi.go
More file actions
257 lines (234 loc) · 7.22 KB
/
Copy pathapi.go
File metadata and controls
257 lines (234 loc) · 7.22 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
)
// Battery holds per-battery telemetry from a single battery_N/ topic family.
type Battery struct {
Index int // 1, 2, …
Voltage float64 // V
Current float64 // A
Power float64 // W (negative = charging)
StateOfCharge float64 // %
Temperature float64 // °C (0 if not reported)
}
// Stats holds the fetched values from Solar-Assistant.
type Stats struct {
BatterySOC float64 // percent (pre-aggregated total across all batteries)
BatteryPower float64 // watts (negative = discharging)
BatteryVoltage float64 // volts (inverter-side bus reading)
LoadPower float64 // watts
PVPower float64 // watts
GridPower float64 // watts (negative = exporting)
GridVoltage float64 // volts (0 = power cut)
DeviceMode string // e.g. "Solar", "Battery", "Grid"
InverterTemperature float64 // °C
OutputSourcePriority string
ChargerSourcePriority string
Batteries []Battery // sorted by Index
}
// metric is one entry in the /api/v1/metrics JSON array.
type metric struct {
Topic string `json:"topic"`
Value json.RawMessage `json:"value"`
Unit string `json:"unit"`
}
// FetchStats performs a single HTTP GET against the Solar-Assistant REST API
// and returns the populated Stats.
func FetchStats(host, username, password string, timeout time.Duration) (*Stats, error) {
body, err := httpGet(buildURL(host, "/api/v1/metrics", nil), username, password, timeout)
if err != nil {
return nil, err
}
var metrics []metric
if err := json.Unmarshal(body, &metrics); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
stats := &Stats{}
batteries := map[int]*Battery{}
for _, m := range metrics {
switch m.Topic {
case "total/battery_state_of_charge":
stats.BatterySOC = asFloat(m.Value)
case "total/battery_power":
stats.BatteryPower = asFloat(m.Value)
case "inverter_1/battery_voltage":
stats.BatteryVoltage = asFloat(m.Value)
case "total/load_power":
stats.LoadPower = asFloat(m.Value)
case "total/pv_power":
stats.PVPower = asFloat(m.Value)
case "total/grid_power":
stats.GridPower = asFloat(m.Value)
case "inverter_1/grid_voltage":
stats.GridVoltage = asFloat(m.Value)
case "inverter_1/device_mode":
stats.DeviceMode = asString(m.Value)
case "inverter_1/temperature":
stats.InverterTemperature = asFloat(m.Value)
case "inverter_1/output_source_priority":
stats.OutputSourcePriority = asString(m.Value)
case "inverter_1/charger_source_priority":
stats.ChargerSourcePriority = asString(m.Value)
default:
if idx, field, ok := parseBatteryTopic(m.Topic); ok {
b := batteries[idx]
if b == nil {
b = &Battery{Index: idx}
batteries[idx] = b
}
switch field {
case "voltage":
b.Voltage = asFloat(m.Value)
case "current":
b.Current = asFloat(m.Value)
case "power":
b.Power = asFloat(m.Value)
case "state_of_charge":
b.StateOfCharge = asFloat(m.Value)
case "temperature":
b.Temperature = asFloat(m.Value)
}
}
}
}
if len(batteries) > 0 {
stats.Batteries = make([]Battery, 0, len(batteries))
for _, b := range batteries {
stats.Batteries = append(stats.Batteries, *b)
}
sort.Slice(stats.Batteries, func(i, j int) bool {
return stats.Batteries[i].Index < stats.Batteries[j].Index
})
}
return stats, nil
}
// WatchStats polls the REST API every interval and calls onUpdate with each
// successful snapshot. It blocks until the caller cancels the process.
// Transient fetch errors are written to stderr and do not stop the loop.
func WatchStats(host, username, password string, interval time.Duration, onUpdate func(*Stats)) error {
// Use a per-request timeout slightly shorter than the polling interval so
// a slow response can't stack requests.
reqTimeout := interval
if reqTimeout < 2*time.Second {
reqTimeout = 2 * time.Second
}
fetch := func() {
s, err := FetchStats(host, username, password, reqTimeout)
if err != nil {
fmt.Fprintf(stderr, "fetch error: %v\n", err)
return
}
onUpdate(s)
}
fetch()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
fetch()
}
return nil
}
// FetchRaw returns the raw JSON body of GET /api/v1/metrics, suitable for
// piping through jq or similar tools.
func FetchRaw(host, username, password string, timeout time.Duration) ([]byte, error) {
return httpGet(buildURL(host, "/api/v1/metrics", nil), username, password, timeout)
}
// FetchMetric returns the plain-text value of a single metric topic via the
// /api/v1/metrics?topic=<topic>&value=1 endpoint.
func FetchMetric(host, username, password string, timeout time.Duration, topic string) (string, error) {
q := url.Values{}
q.Set("topic", topic)
q.Set("value", "1")
body, err := httpGet(buildURL(host, "/api/v1/metrics", q), username, password, timeout)
if err != nil {
return "", err
}
return strings.TrimRight(string(body), "\r\n"), nil
}
// httpGet performs an authenticated GET and returns the response body.
func httpGet(url, username, password string, timeout time.Duration) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
if username != "" || password != "" {
req.SetBasicAuth(username, password)
}
client := &http.Client{Timeout: timeout}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
return body, nil
}
// buildURL constructs an http://host/path?query URL.
func buildURL(host, path string, query url.Values) string {
u := url.URL{Scheme: "http", Host: host, Path: path}
if len(query) > 0 {
u.RawQuery = query.Encode()
}
return u.String()
}
// parseBatteryTopic returns (index, field, true) for topics like
// "battery_1/voltage", or (0, "", false) otherwise.
func parseBatteryTopic(topic string) (int, string, bool) {
const prefix = "battery_"
if !strings.HasPrefix(topic, prefix) {
return 0, "", false
}
rest := topic[len(prefix):]
slash := strings.IndexByte(rest, '/')
if slash <= 0 {
return 0, "", false
}
idx, err := strconv.Atoi(rest[:slash])
if err != nil || idx < 1 {
return 0, "", false
}
return idx, rest[slash+1:], true
}
// asFloat extracts a float64 from a JSON value that may be a number or a
// numeric string. Returns 0 if the value cannot be interpreted.
func asFloat(raw json.RawMessage) float64 {
if len(raw) == 0 {
return 0
}
var f float64
if err := json.Unmarshal(raw, &f); err == nil {
return f
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
var fs float64
fmt.Sscanf(s, "%g", &fs)
return fs
}
return 0
}
// asString extracts a string from a JSON value, tolerating non-string scalars.
func asString(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
return string(raw)
}