Add Fritz smarthome REST API support (FritzOS 8.2+) (#29013)
This commit is contained in:
parent
11ec83716d
commit
2f106579e7
11 changed files with 516 additions and 110 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
15
meter/fritz/aha/types.go
Normal 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
16
meter/fritz/api.go
Normal 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
|
||||
}
|
||||
258
meter/fritz/smarthome/smarthome.go
Normal file
258
meter/fritz/smarthome/smarthome.go
Normal 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
|
||||
}
|
||||
58
meter/fritz/smarthome/types.go
Normal file
58
meter/fritz/smarthome/types.go
Normal 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
71
meter/fritz/types.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" . }}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue