internal/fritzbox/rest.go
Rama main · réplica de lectura del repositorio original.
package fritzbox
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"slices"
"strings"
"time"
)
// The Smart Home REST API, FRITZ!OS 8.20 and later. It uses the same
// login_sid.lua session as AHA, passed in a header instead of the query.
// Its specification is 0.9 under /api/v0 and reserves the right to rename
// things, which is why AHA stays the default.
const restOverview = "/api/v0/smarthome/overview"
type restList struct {
Devices []restDevice `json:"devices"`
Units []restUnit `json:"units"`
}
type restDevice struct {
UID string `json:"UID"`
Name string `json:"name"`
Product string `json:"productName"`
Firmware string `json:"firmwareVersion"`
Connected bool `json:"isConnected"`
BatteryState string `json:"batteryState"`
Battery *int64 `json:"batteryValue"`
BatteryLow *bool `json:"isBatteryLow"`
}
type restTemp struct {
Celsius *float64 `json:"celsius"`
Mode string `json:"mode"`
}
type restUnit struct {
AIN string `json:"ain"`
Name string `json:"name"`
DeviceUID string `json:"deviceUid"`
Connected bool `json:"isConnected"`
Group bool `json:"isGroupUnit"`
Type string `json:"unitType"`
Interfaces struct {
Multimeter *struct {
State string `json:"state"`
Power *int64 `json:"power"` // mW
Voltage *int64 `json:"voltage"` // mV
Energy *int64 `json:"energy"` // Wh
} `json:"multimeterInterface"`
OnOff *struct {
State string `json:"state"`
Active *bool `json:"active"`
} `json:"onOffInterface"`
Level *struct {
State string `json:"state"`
Level *int64 `json:"level"` // percent
} `json:"levelControlInterface"`
Color *struct {
State string `json:"state"`
Kelvin *int64 `json:"colorTemperature"`
} `json:"colorControlInterface"`
Temperature *struct {
State string `json:"state"`
Celsius *float64 `json:"celsius"`
} `json:"temperatureInterface"`
Humidity *struct {
State string `json:"state"`
Relative *int64 `json:"relativeHumidity"`
} `json:"humidityInterface"`
Alert *struct {
State string `json:"state"`
Alerts []string `json:"alerts"`
Since int64 `json:"lastAlertTime"`
} `json:"alertInterface"`
Thermostat *struct {
State string `json:"state"`
SetPoint restTemp `json:"setPointTemperature"`
Comfort restTemp `json:"comfortTemperature"`
Reduced restTemp `json:"reducedTemperature"`
Window struct {
Enabled bool `json:"enabled"`
} `json:"windowOpenMode"`
Boost struct {
Enabled bool `json:"enabled"`
} `json:"boost"`
Next *struct {
At int64 `json:"changeTime"`
Change restTemp `json:"temperatureChange"`
} `json:"nextChange"`
} `json:"thermostatInterface"`
} `json:"interfaces"`
}
// DevicesREST reads the same devices as Devices, over the REST API.
func (c *Client) DevicesREST(ctx context.Context) ([]Device, error) {
body, err := c.rest(ctx, restOverview)
if err != nil {
return nil, err
}
return parseREST(body)
}
func (c *Client) rest(ctx context.Context, path string) ([]byte, error) {
sid, err := c.sessionID(ctx)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "AVM-SID "+sid)
req.Header.Set("Accept", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("contacting %s: %w", c.BaseURL, err)
}
defer resp.Body.Close()
switch {
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden:
c.mu.Lock()
c.sid = ""
c.mu.Unlock()
return nil, errors.New("REST request refused: session expired, or the user lacks the Smart Home permission")
case resp.StatusCode == http.StatusNotFound:
return nil, fmt.Errorf("%s not found: the box needs FRITZ!OS 8.20 or later for the REST API", path)
case resp.StatusCode != http.StatusOK:
return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, path)
}
return io.ReadAll(io.LimitReader(resp.Body, 8<<20))
}
// parseREST gives the same Device values parseDevices does: one per unit, the
// unit's AIN, which matches AHA's, and the device's name, firmware and
// battery. A state other than "valid" is no reading, not a zero.
func parseREST(body []byte) ([]Device, error) {
var list restList
if err := json.Unmarshal(body, &list); err != nil {
return nil, fmt.Errorf("parsing REST overview: %w", err)
}
devices := map[string]restDevice{}
for _, d := range list.Devices {
devices[d.UID] = d
}
out := make([]Device, 0, len(list.Units))
for _, u := range list.Units {
if u.Group {
continue
}
dev, ok := devices[u.DeviceUID]
if !ok {
dev = restDevice{Name: u.Name, Connected: true}
}
d := Device{
AIN: collapse(u.AIN),
Name: strings.TrimSpace(dev.Name),
Product: strings.TrimSpace(dev.Product),
Firmware: strings.TrimSpace(dev.Firmware),
Present: u.Connected && dev.Connected,
}
in := u.Interfaces
if m := in.Multimeter; m != nil && m.State == "valid" {
d.EnergyKWh = scale(m.Energy, 1000)
// As with AHA: 0 V is no reading, see parseDevices.
if m.Voltage == nil || *m.Voltage != 0 {
d.PowerW = scale(m.Power, 1000)
d.VoltageV = scale(m.Voltage, 1000)
}
}
if o := in.OnOff; o != nil && o.State == "valid" && o.Active != nil {
on := *o.Active
d.SwitchOn = &on
}
if l := in.Level; l != nil && l.State == "valid" {
d.LevelPct = scale(l.Level, 1)
}
if c := in.Color; c != nil && c.State == "valid" {
d.ColorTempK = scale(c.Kelvin, 1)
}
if t := in.Temperature; t != nil && t.State == "valid" && t.Celsius != nil {
v := *t.Celsius
d.TempC = &v
}
if h := in.Humidity; h != nil && h.State == "valid" {
d.HumidityP = scale(h.Relative, 1)
}
if a := in.Alert; a != nil && a.State == "valid" &&
(u.Type == "doorOpenCloseDetector" || u.Type == "windowOpenCloseDetector") {
open, closed := slices.Contains(a.Alerts, "open"), slices.Contains(a.Alerts, "closed")
if open || closed {
c := &Contact{Door: u.Type == "doorOpenCloseDetector", Open: open}
if a.Since > 0 {
c.Since = time.Unix(a.Since, 0)
}
d.Contact = c
}
}
if t := in.Thermostat; t != nil && t.State == "valid" {
d.TargetC = t.SetPoint.degrees()
th := &Thermostat{
Off: t.SetPoint.Mode == "off",
FullOn: t.SetPoint.Mode == "on",
ComfortC: t.Comfort.degrees(),
EcoC: t.Reduced.degrees(),
WindowOpen: t.Window.Enabled,
Boost: t.Boost.Enabled,
}
if n := t.Next; n != nil && n.At > 0 {
th.NextAt = time.Unix(n.At, 0)
th.NextC = n.Change.degrees()
th.NextOff = n.Change.Mode == "off"
}
d.Thermostat = th
}
if dev.BatteryState == "known" {
d.BatteryPct = scale(dev.Battery, 1)
if dev.BatteryLow != nil {
low := *dev.BatteryLow
d.BatteryLow = &low
}
}
out = append(out, d)
}
return out, nil
}
// degrees is a temperature, or nil for the off and fully open modes, which
// are settings rather than temperatures.
func (t restTemp) degrees() *float64 {
if t.Mode != "" && t.Mode != "temperature" || t.Celsius == nil {
return nil
}
v := *t.Celsius
return &v
}
// Missing lists, per device, the readings one reading has and another lacks:
// what switching from AHA to REST would lose. Each entry reads
// "Meter: energy, power".
func Missing(have, other []Device) []string {
byAIN := map[string]Device{}
for _, d := range other {
byAIN[d.AIN] = d
}
var out []string
for _, h := range have {
o, ok := byAIN[h.AIN]
if !ok {
out = append(out, h.Name+": not listed")
continue
}
var lost []string
for _, f := range []struct {
name string
has, lack bool
}{
{"power", h.PowerW != nil, o.PowerW == nil},
{"energy", h.EnergyKWh != nil, o.EnergyKWh == nil},
{"voltage", h.VoltageV != nil, o.VoltageV == nil},
{"temperature", h.TempC != nil, o.TempC == nil},
{"humidity", h.HumidityP != nil, o.HumidityP == nil},
{"switch", h.SwitchOn != nil, o.SwitchOn == nil},
{"brightness", h.LevelPct != nil, o.LevelPct == nil},
{"battery", h.BatteryPct != nil, o.BatteryPct == nil},
{"contact", h.Contact != nil, o.Contact == nil},
{"thermostat", h.Thermostat != nil, o.Thermostat == nil},
} {
if f.has && f.lack {
lost = append(lost, f.name)
}
}
if len(lost) > 0 {
out = append(out, h.Name+": "+strings.Join(lost, ", "))
}
}
return out
}