Add Fritz smarthome REST API support (FritzOS 8.2+) (#29013)

This commit is contained in:
andig 2026-04-19 13:55:54 +02:00 • committed by GitHub
parent 11ec83716d
commit 2f106579e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 516 additions and 110 deletions

View file

@ -1,20 +1,20 @@
package charger
import (
"errors"
"strconv"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/meter/fritzdect"
"github.com/evcc-io/evcc/meter/fritz"
"github.com/evcc-io/evcc/meter/fritz/aha"
"github.com/evcc-io/evcc/meter/fritz/smarthome"
"github.com/evcc-io/evcc/util"
)
// FRITZ! FritzBox AHA interface specifications:
// https://fritz.com/fileadmin/user_upload/Global/Service/Schnittstellen/AHA-HTTP-Interface.pdf
// https://fritz.support/resources/SmarthomeRestApiFRITZOS82.html (REST API for FritzOS 8.2+)
// FritzDECT charger implementation
type FritzDECT struct {
conn *fritzdect.Connection
conn fritz.Switch
*switchSocket
}
@ -25,9 +25,9 @@ func init() {
// NewFritzDECTFromConfig creates a fritzdect charger from generic config
func NewFritzDECTFromConfig(other map[string]any) (api.Charger, error) {
var cc struct {
embed `mapstructure:",squash"`
fritzdect.Settings `mapstructure:",squash"`
StandbyPower float64
embed `mapstructure:",squash"`
fritz.Settings `mapstructure:",squash"`
StandbyPower float64
}
if err := util.DecodeOther(other, &cc); err != nil {
@ -38,12 +38,20 @@ func NewFritzDECTFromConfig(other map[string]any) (api.Charger, error) {
return nil, api.ErrMissingCredentials
}
return NewFritzDECT(cc.embed, cc.URI, cc.AIN, cc.User, cc.Password, cc.StandbyPower)
return NewFritzDECT(cc.embed, cc.URI, cc.AIN, cc.User, cc.Password, cc.StandbyPower, cc.Firmware82)
}
// NewFritzDECT creates a new connection with standbypower for charger
func NewFritzDECT(embed embed, uri, ain, user, password string, standbypower float64) (*FritzDECT, error) {
conn, err := fritzdect.NewConnection(uri, ain, user, password)
func NewFritzDECT(embed embed, uri, ain, user, password string, standbypower float64, firmware82 bool) (*FritzDECT, error) {
var conn fritz.Switch
var err error
// Use new REST API if firmware82 is set, otherwise use legacy LUA API
if firmware82 {
conn, err = smarthome.NewConnection(uri, ain, user, password)
} else {
conn, err = aha.NewConnection(uri, ain, user, password)
}
if err != nil {
return nil, err
}
@ -59,18 +67,13 @@ func NewFritzDECT(embed embed, uri, ain, user, password string, standbypower flo
// Status implements the api.Charger interface
func (c *FritzDECT) Status() (api.ChargeStatus, error) {
resp, err := c.conn.ExecCmd("getswitchpresent")
present, err := c.conn.SwitchPresent()
if err != nil {
return api.StatusNone, err
}
present, err := strconv.ParseBool(resp)
if err == nil && !present {
err = api.ErrNotAvailable
}
if err != nil {
return api.StatusNone, err
if !present {
return api.StatusNone, api.ErrNotAvailable
}
return c.switchSocket.Status()
@ -78,33 +81,15 @@ func (c *FritzDECT) Status() (api.ChargeStatus, error) {
// Enabled implements the api.Charger interface
func (c *FritzDECT) Enabled() (bool, error) {
resp, err := c.conn.ExecCmd("getswitchstate")
if err != nil {
return false, err
}
return strconv.ParseBool(resp)
return c.conn.SwitchState()
}
// Enable implements the api.Charger interface
func (c *FritzDECT) Enable(enable bool) error {
cmd := "setswitchoff"
if enable {
cmd = "setswitchon"
return c.conn.SwitchOn()
}
// on 0/1 - DECT Switch state off/on (empty if unknown or error)
resp, err := c.conn.ExecCmd(cmd)
if err != nil {
return err
}
on, err := strconv.ParseBool(resp)
if err == nil && enable != on {
err = errors.New("switch failed")
}
return err
return c.conn.SwitchOff()
}
var _ api.MeterEnergy = (*FritzDECT)(nil)

View file

@ -1,9 +1,6 @@
package fritzdect
package aha
import (
"crypto/md5"
"encoding/hex"
"encoding/xml"
"errors"
"fmt"
"net/url"
@ -12,44 +9,24 @@ import (
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/meter/fritz"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/transport"
"golang.org/x/text/encoding/unicode"
)
// FRITZ! FritzBox AHA interface and authentication specifications:
// https://fritz.com/fileadmin/user_upload/Global/Service/Schnittstellen/AHA-HTTP-Interface.pdf
// https://fritz.com/fileadmin/user_upload/Global/Service/Schnittstellen/AVM_Technical_Note_-_Session_ID.pdf
// FritzDECT settings
type Settings struct {
URI, AIN, User, Password string
}
// FritzDECT connection
type Connection struct {
*request.Helper
*Settings
*fritz.Settings
SID string
updated time.Time
}
// https://fritz.com/fileadmin/user_upload/Global/Service/Schnittstellen/AVM_Technical_Note_-_Session_ID_english_2021-05-03.pdf
const sessionTimeout = 15 * time.Minute
// Devicestats structures getbasicdevicesstats command response (AHA-HTTP-Interface)
type Devicestats struct {
XMLName xml.Name `xml:"devicestats"`
Energy Energy `xml:"energy"`
}
// Energy structures getbasicdevicesstats command energy response (AHA-HTTP-Interface)
type Energy struct {
XMLName xml.Name `xml:"energy"`
Values []string `xml:"stats"`
}
// NewConnection creates FritzDECT connection
func NewConnection(uri, ain, user, password string) (*Connection, error) {
if uri == "" {
@ -60,7 +37,7 @@ func NewConnection(uri, ain, user, password string) (*Connection, error) {
return nil, errors.New("missing ain")
}
settings := &Settings{
settings := &fritz.Settings{
URI: strings.TrimRight(uri, "/"),
AIN: ain,
User: user,
@ -82,11 +59,13 @@ func NewConnection(uri, ain, user, password string) (*Connection, error) {
// ExecCmd execautes an FritzDECT AHA-HTTP-Interface command
func (c *Connection) ExecCmd(function string) (string, error) {
// refresh Fritzbox session id
if time.Since(c.updated) >= sessionTimeout {
if err := c.getSessionID(); err != nil {
if time.Since(c.updated) >= fritz.SessionTimeout {
sid, err := c.GetSessionID(c.Helper)
if err != nil {
return "", err
}
// update session timestamp
c.SID = sid
c.updated = time.Now()
}
@ -123,7 +102,7 @@ func (c *Connection) CurrentPower() (float64, error) {
var _ api.MeterEnergy = (*Connection)(nil)
// CurrentPower implements the api.MeterEnergy interface
// TotalEnergy implements the api.MeterEnergy interface
func (c *Connection) TotalEnergy() (float64, error) {
// Energy value in Wh (total switch energy, refresh approximately every 2 minutes)
resp, err := c.ExecCmd("getswitchenergy")
@ -136,53 +115,48 @@ func (c *Connection) TotalEnergy() (float64, error) {
return energy / 1000, err // Wh ==> KWh
}
// Fritzbox helpers (credits to https://github.com/rsdk/ahago)
// SwitchPresent checks if the device is connected
func (c *Connection) SwitchPresent() (bool, error) {
resp, err := c.ExecCmd("getswitchpresent")
if err != nil {
return false, err
}
return strconv.ParseBool(resp)
}
// getSessionID fetches a session-id based on the username and password in the connection struct
func (c *Connection) getSessionID() error {
uri := fmt.Sprintf("%s/login_sid.lua", c.URI)
body, err := c.GetBody(uri)
// SwitchState returns the current switch state
func (c *Connection) SwitchState() (bool, error) {
resp, err := c.ExecCmd("getswitchstate")
if err != nil {
return false, err
}
return strconv.ParseBool(resp)
}
// SwitchOn turns the switch on
func (c *Connection) SwitchOn() error {
resp, err := c.ExecCmd("setswitchon")
if err != nil {
return err
}
var v struct {
SID string
Challenge string
BlockTime string
on, err := strconv.ParseBool(resp)
if err == nil && !on {
err = errors.New("switch on failed")
}
if err = xml.Unmarshal(body, &v); err == nil && v.SID == "0000000000000000" {
var challresp string
if challresp, err = createChallengeResponse(v.Challenge, c.Password); err == nil {
params := url.Values{
"username": {c.User},
"response": {challresp},
}
if body, err = c.GetBody(uri + "?" + params.Encode()); err == nil {
err = xml.Unmarshal(body, &v)
if v.SID == "0000000000000000" {
return errors.New("invalid user or password")
}
c.SID = v.SID
}
}
}
return err
}
// createChallengeResponse creates the Fritzbox challenge response string
func createChallengeResponse(challenge, pass string) (string, error) {
encoder := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder()
utf16le, err := encoder.String(challenge + "-" + pass)
// SwitchOff turns the switch off
func (c *Connection) SwitchOff() error {
resp, err := c.ExecCmd("setswitchoff")
if err != nil {
return "", err
return err
}
hash := md5.Sum([]byte(utf16le))
md5hash := hex.EncodeToString(hash[:])
return challenge + "-" + md5hash, nil
off, err := strconv.ParseBool(resp)
if err == nil && off {
err = errors.New("switch off failed")
}
return err
}

15
meter/fritz/aha/types.go Normal file
View file

@ -0,0 +1,15 @@
package aha
import "encoding/xml"
// Devicestats structures getbasicdevicesstats command response (AHA-HTTP-Interface)
type Devicestats struct {
XMLName xml.Name `xml:"devicestats"`
Energy Energy `xml:"energy"`
}
// Energy structures getbasicdevicesstats command energy response (AHA-HTTP-Interface)
type Energy struct {
XMLName xml.Name `xml:"energy"`
Values []string `xml:"stats"`
}

16
meter/fritz/api.go Normal file
View file

@ -0,0 +1,16 @@
package fritz
// Meter defines the interface for Fritz connections (both legacy LUA and REST)
type Meter interface {
CurrentPower() (float64, error)
TotalEnergy() (float64, error)
}
// Switch extends Meter with switch control capabilities
type Switch interface {
Meter
SwitchPresent() (bool, error)
SwitchState() (bool, error)
SwitchOn() error
SwitchOff() error
}

View file

@ -0,0 +1,258 @@
package smarthome
import (
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/meter/fritz"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/transport"
)
// FRITZ! Smarthome REST API (FritzOS 8.2+)
// https://fritz.support/resources/SmarthomeRestApiFRITZOS82.html
// Connection implements the new REST API for Fritz smarthome devices
type Connection struct {
*request.Helper
*fritz.Settings
SID string
UID string // device UID (AIN with space)
updated time.Time
unitG util.Cacheable[Unit]
}
// NewConnection creates a new REST API connection
func NewConnection(uri, ain, user, password string) (*Connection, error) {
if uri == "" {
uri = "https://fritz.box"
}
if ain == "" {
return nil, errors.New("missing ain")
}
settings := &fritz.Settings{
URI: strings.TrimRight(uri, "/"),
AIN: ain,
User: user,
Password: password,
}
log := util.NewLogger("fritzsmarthome").Redact(password)
conn := &Connection{
Helper: request.NewHelper(log),
Settings: settings,
UID: ainToUID(ain),
}
conn.Client.Transport = request.NewTripper(log, transport.Insecure())
// cache unit data for 2 seconds to avoid excessive API calls
conn.unitG = util.ResettableCached(func() (Unit, error) {
return conn.getUnit()
}, 2*time.Second)
return conn, nil
}
// ainToUID converts AIN format to UID format by adding space
// AIN: "116300015376" -> UID: "11630 0015376"
func ainToUID(ain string) string {
// Remove any existing spaces first
ain = strings.ReplaceAll(ain, " ", "")
if len(ain) >= 5 {
return ain[:5] + " " + ain[5:]
}
return ain
}
// getUnit fetches unit data from REST API
func (c *Connection) getUnit() (Unit, error) {
if err := c.refreshSession(); err != nil {
return Unit{}, err
}
// Try to get the specific unit first
uri := fmt.Sprintf("%s/api/v0/smarthome/overview/units/%s", c.URI, url.PathEscape(c.UID))
req, _ := request.New("GET", uri, nil, map[string]string{
"Authorization": "AVM-SID " + c.SID,
}, request.AcceptJSON)
var unit Unit
if err := c.DoJSON(req, &unit); err != nil {
// Fall back to getting all units and finding ours
return c.findUnit()
}
if !unit.IsConnected {
return unit, api.ErrNotAvailable
}
return unit, nil
}
// findUnit searches for our unit in the list of all units
func (c *Connection) findUnit() (Unit, error) {
uri := fmt.Sprintf("%s/api/v0/smarthome/overview/units", c.URI)
req, err := request.New("GET", uri, nil, map[string]string{
"Authorization": "AVM-SID " + c.SID,
}, request.AcceptJSON)
if err != nil {
return Unit{}, err
}
var units []Unit
if err := c.DoJSON(req, &units); err != nil {
return Unit{}, err
}
// Search for matching unit by UID or AIN
for _, unit := range units {
unitAIN := strings.ReplaceAll(unit.UID, " ", "")
if unit.UID == c.UID || unitAIN == c.AIN {
if !unit.IsConnected {
return unit, api.ErrNotAvailable
}
return unit, nil
}
}
return Unit{}, fmt.Errorf("unit not found: %s", c.AIN)
}
// CurrentPower implements the api.Meter interface
func (c *Connection) CurrentPower() (float64, error) {
unit, err := c.unitG.Get()
if err != nil {
return 0, err
}
if unit.Statistics != nil && len(unit.Statistics.Powers) > 0 {
if stats := unit.Statistics.Powers[0].Values; len(stats) > 0 {
return (float64)(stats[0]) / 1000, nil
}
}
return 0, api.ErrNotAvailable
}
var _ api.MeterEnergy = (*Connection)(nil)
// TotalEnergy implements the api.MeterEnergy interface
func (c *Connection) TotalEnergy() (float64, error) {
unit, err := c.unitG.Get()
if err != nil {
return 0, err
}
if unit.Statistics != nil && len(unit.Statistics.Energies) > 0 {
if stats := unit.Statistics.Energies[len(unit.Statistics.Energies)-1].Values; len(stats) > 0 {
return (float64)(stats[0]) / 1000, nil
}
}
return 0, api.ErrNotAvailable
}
// SwitchPresent checks if the device is connected
func (c *Connection) SwitchPresent() (bool, error) {
unit, err := c.unitG.Get()
if err != nil {
if errors.Is(err, api.ErrNotAvailable) {
return false, nil
}
return false, err
}
return unit.IsConnected, nil
}
// SwitchState returns the current switch state
func (c *Connection) SwitchState() (bool, error) {
unit, err := c.unitG.Get()
if err != nil {
return false, err
}
if unit.Interfaces.OnOffInterface == nil {
return false, errors.New("device has no switch")
}
return unit.Interfaces.OnOffInterface.State == "on", nil
}
// SwitchOn turns the switch on
func (c *Connection) SwitchOn() error {
return c.setSwitch(true)
}
// SwitchOff turns the switch off
func (c *Connection) SwitchOff() error {
return c.setSwitch(false)
}
// setSwitch sets the switch state via REST API
func (c *Connection) setSwitch(on bool) error {
if err := c.refreshSession(); err != nil {
return err
}
state := "off"
if on {
state = "on"
}
uri := fmt.Sprintf("%s/api/v0/smarthome/overview/units/%s", c.URI, url.PathEscape(c.UID))
data := map[string]any{
"onOffInterface": map[string]string{
"state": state,
},
}
req, _ := request.New("PUT", uri, request.MarshalJSON(data), map[string]string{
"Authorization": "AVM-SID " + c.SID,
}, request.JSONEncoding)
var unit Unit
if err := c.DoJSON(req, &unit); err != nil {
return err
}
// Reset cache after state change
c.unitG.Reset()
// Verify state was changed
if unit.Interfaces.OnOffInterface != nil {
actualState := unit.Interfaces.OnOffInterface.State == "on"
if actualState != on {
return errors.New("switch state change failed")
}
}
return nil
}
// refreshSession ensures we have a valid session ID
func (c *Connection) refreshSession() error {
// refresh Fritzbox session id
if time.Since(c.updated) >= fritz.SessionTimeout {
sid, err := c.GetSessionID(c.Helper)
if err != nil {
return err
}
// update session timestamp
c.SID = sid
c.updated = time.Now()
}
return nil
}

View file

@ -0,0 +1,58 @@
package smarthome
// Unit represents a smarthome unit with its interfaces
type Unit struct {
GroupUID string `json:"groupUid,omitempty"`
UID string `json:"UID,omitempty"`
DeviceUID string `json:"deviceUid"`
UnitType string `json:"unitType"`
IsConnected bool `json:"isConnected"`
Statistics *Statistics `json:"statistics,omitempty"`
Interfaces *Interfaces `json:"interfaces,omitempty"`
}
type Interfaces struct {
MultimeterInterface *MultimeterInterface `json:"multimeterInterface,omitempty"`
OnOffInterface *OnOffInterface `json:"onOffInterface,omitempty"`
TemperatureInterface *TemperatureInterface `json:"temperatureInterface,omitempty"`
}
type Statistics struct {
Temperatures []ElementFloat `json:"temperatures,omitempty"`
Powers []Element `json:"powers,omitempty"`
Voltages []Element `json:"voltages,omitempty"`
Energies []Element `json:"energies,omitempty"`
}
type ElementFloat struct {
Interval int64 `json:"interval"`
StasticsState string `json:"statisticsState"`
Period string `json:"period"`
Values []float64 `json:"values,omitempty"`
}
type Element struct {
Interval int64 `json:"interval"`
StasticsState string `json:"statisticsState"`
Period string `json:"period"`
Values []int64 `json:"values,omitempty"`
}
// MultimeterInterface contains power/energy measurements
type MultimeterInterface struct {
State string `json:"state"`
Power float64 `json:"power"` // W
Voltage float64 `json:"voltage"` // V
Current float64 `json:"current"` // A
Energy float64 `json:"energy"` // Wh
}
// OnOffInterface contains switch state
type OnOffInterface struct {
State string `json:"state"` // "on" or "off"
}
type TemperatureInterface struct {
State string `json:"state"` // "on" or "off"
Celsius float64 `json:"celsius"`
}

71
meter/fritz/types.go Normal file
View file

@ -0,0 +1,71 @@
package fritz
import (
"crypto/md5"
"encoding/hex"
"encoding/xml"
"errors"
"fmt"
"net/url"
"time"
"github.com/evcc-io/evcc/util/request"
"golang.org/x/text/encoding/unicode"
)
// https://fritz.com/fileadmin/user_upload/Global/Service/Schnittstellen/AVM_Technical_Note_-_Session_ID_english_2021-05-03.pdf
const SessionTimeout = 15 * time.Minute
// FritzDECT settings
type Settings struct {
URI, AIN, User, Password string
Firmware82 bool // use new REST API (FritzOS 8.2+)
}
// Fritzbox helpers (credits to https://github.com/rsdk/ahago)
// getSessionID fetches a session-id based on the username and password in the connection struct
func (s Settings) GetSessionID(c *request.Helper) (string, error) {
uri := fmt.Sprintf("%s/login_sid.lua", s.URI)
body, err := c.GetBody(uri)
if err != nil {
return "", err
}
var v struct {
SID string
Challenge string
}
if err = xml.Unmarshal(body, &v); err == nil && v.SID == "0000000000000000" {
var challresp string
if challresp, err = s.createChallengeResponse(v.Challenge); err == nil {
params := url.Values{
"username": {s.User},
"response": {challresp},
}
if body, err = c.GetBody(uri + "?" + params.Encode()); err == nil {
if err = xml.Unmarshal(body, &v); err == nil && v.SID == "0000000000000000" {
return "", errors.New("invalid user or password")
}
}
}
}
return v.SID, err
}
// createChallengeResponse creates the Fritzbox challenge response string
func (s Settings) createChallengeResponse(challenge string) (string, error) {
encoder := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder()
utf16le, err := encoder.String(challenge + "-" + s.Password)
if err != nil {
return "", err
}
hash := md5.Sum([]byte(utf16le))
md5hash := hex.EncodeToString(hash[:])
return challenge + "-" + md5hash, nil
}

View file

@ -2,12 +2,15 @@ package meter
import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/meter/fritzdect"
"github.com/evcc-io/evcc/meter/fritz"
"github.com/evcc-io/evcc/meter/fritz/aha"
"github.com/evcc-io/evcc/meter/fritz/smarthome"
"github.com/evcc-io/evcc/util"
)
// AVM FritzBox AHA interface specifications:
// https://avm.de/fileadmin/user_upload/Global/Service/Schnittstellen/AHA-HTTP-Interface.pdf
// https://fritz.support/resources/SmarthomeRestApiFRITZOS82.html (REST API for FritzOS 8.2+)
func init() {
registry.Add("fritzdect", NewFritzDECTFromConfig)
@ -15,7 +18,7 @@ func init() {
// NewFritzDECTFromConfig creates a fritzdect meter from generic config
func NewFritzDECTFromConfig(other map[string]any) (api.Meter, error) {
var cc fritzdect.Settings
var cc fritz.Settings
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
@ -24,5 +27,10 @@ func NewFritzDECTFromConfig(other map[string]any) (api.Meter, error) {
return nil, api.ErrMissingCredentials
}
return fritzdect.NewConnection(cc.URI, cc.AIN, cc.User, cc.Password)
// Use new REST API if firmware82 is set, otherwise use legacy LUA API
if cc.Firmware82 {
return smarthome.NewConnection(cc.URI, cc.AIN, cc.User, cc.Password)
}
return aha.NewConnection(cc.URI, cc.AIN, cc.User, cc.Password)
}

View file

@ -25,6 +25,15 @@ params:
required: true
- name: ain
required: true
- name: firmware82
advanced: true
type: bool
description:
de: Neue REST-API verwenden (FritzOS 8.2+)
en: Use new REST API (FritzOS 8.2+)
help:
de: Verwende die neue REST-API für FritzOS ab Version 8.2
en: Use the new REST API for FritzOS version 8.2 and later
- preset: switchsocket
render: |
type: fritzdect
@ -32,4 +41,5 @@ render: |
user: {{ .user }}
password: {{ .password }}
ain: {{ .ain }} # switch actor identification number without blanks (see AIN number on switch sticker)
firmware82: {{ .firmware82 }}
{{ include "switchsocket" . }}

View file

@ -27,9 +27,19 @@ params:
required: true
- name: ain
required: true
- name: firmware82
advanced: true
type: bool
description:
de: Neue REST-API verwenden (FritzOS 8.2+)
en: Use new REST API (FritzOS 8.2+)
help:
de: Verwende die neue REST-API für FritzOS ab Version 8.2
en: Use the new REST API for FritzOS version 8.2 and later
render: |
type: fritzdect
uri: {{ .uri }}
user: {{ .user }}
password: {{ .password }}
ain: {{ .ain }} # switch actor identification number without blanks (see AIN number on switch sticker)
firmware82: {{ .firmware82 }}

View file

@ -20,3 +20,4 @@ render: |
user: {{ .user }}
password: {{ .password }}
ain: {{ .ain }} # switch actor identification number without blanks (see AIN number on switch sticker)
firmware82: true