evcc-io/util/fritzdect/fritzdect.go

169 lines
4.1 KiB
Go

package fritzdect
import (
"crypto/md5"
"encoding/hex"
"encoding/xml"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"golang.org/x/text/encoding/unicode"
)
// AVM FritzBox AHA interface and authentification specifications:
// https://avm.de/fileadmin/user_upload/Global/Service/Schnittstellen/AHA-HTTP-Interface.pdf
// https://avm.de/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
SID string
Updated time.Time
}
// 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 == "" {
uri = "https://fritz.box"
}
if ain == "" {
return nil, errors.New("missing ain")
}
settings := &Settings{
URI: strings.TrimRight(uri, "/"),
AIN: ain,
User: user,
Password: password,
}
log := util.NewLogger("fritzdect").Redact(password)
fritzdect := &Connection{
Helper: request.NewHelper(log),
Settings: settings,
}
fritzdect.Client.Transport = request.NewTripper(log, request.InsecureTransport())
return fritzdect, nil
}
// ExecCmd execautes an FritzDECT AHA-HTTP-Interface command
func (fd *Connection) ExecCmd(function string) (string, error) {
// refresh Fritzbox session id
if time.Since(fd.Updated) >= 10*time.Minute {
err := fd.getSessionID()
if err != nil {
return "", err
}
// update session timestamp
fd.Updated = time.Now()
}
parameters := url.Values{
"sid": []string{fd.SID},
"ain": []string{fd.AIN},
"switchcmd": []string{function},
}
uri := fmt.Sprintf("%s/webservices/homeautoswitch.lua", fd.URI)
response, err := fd.GetBody(uri + "?" + parameters.Encode())
return strings.TrimSpace(string(response)), err
}
// CurrentPower implements the api interface
func (fd *Connection) CurrentPower() (float64, error) {
// power value in 0,001 W (current switch power, refresh approximately every 2 minutes)
resp, err := fd.ExecCmd("getswitchpower")
if err != nil {
return 0, err
}
if resp == "inval" {
return 0, api.ErrNotAvailable
}
power, err := strconv.ParseFloat(resp, 64)
return power / 1000, err // mW ==> W
}
// 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 (fd *Connection) getSessionID() error {
uri := fmt.Sprintf("%s/login_sid.lua", fd.URI)
body, err := fd.GetBody(uri)
if err != nil {
return err
}
v := struct {
SID string
Challenge string
BlockTime string
}{}
if err = xml.Unmarshal(body, &v); err == nil && v.SID == "0000000000000000" {
var challresp string
if challresp, err = createChallengeResponse(v.Challenge, fd.Password); err == nil {
params := url.Values{
"username": []string{fd.User},
"response": []string{challresp},
}
if body, err = fd.GetBody(uri + "?" + params.Encode()); err == nil {
err = xml.Unmarshal(body, &v)
if v.SID == "0000000000000000" {
return errors.New("invalid username (" + fd.User + ") or password")
}
fd.SID = v.SID
}
}
}
return err
}
// createChallengeResponse creates the Fritzbox challenge response string
func createChallengeResponse(challenge string, pass string) (string, error) {
encoder := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder()
utf16le, err := encoder.String(challenge + "-" + pass)
if err != nil {
return "", err
}
hash := md5.New()
if _, err = hash.Write([]byte(utf16le)); err != nil {
return "", err
}
md5hash := hex.EncodeToString(hash.Sum(nil))
return challenge + "-" + md5hash, nil
}