evcc-io/util/homeassistant/connection.go

355 lines
9 KiB
Go

package homeassistant
import (
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/transport"
"github.com/samber/lo"
"golang.org/x/oauth2"
)
// Connection represents a Home Assistant API connection
type Connection struct {
*request.Helper
instance *proxyInstance
}
// NewConnection creates a new Home Assistant connection
func NewConnection(log *util.Logger, uri, home string, insecure bool) (*Connection, error) {
if home != "" {
log.WARN.Printf("using deprecated 'home' parameter '%s', please use 'uri' instead", home)
}
if uri == "" && home == "" {
return nil, errors.New("missing either uri or home")
}
c := &Connection{
Helper: request.NewHelper(log),
instance: &proxyInstance{
home: home,
uri: util.DefaultScheme(strings.TrimSuffix(uri, "/"), "http"),
insecure: insecure,
},
}
// override the transport to accept self-signed certificates
if insecure {
c.Client.Transport = request.NewTripper(log, transport.Insecure())
}
// Set up authentication headers
c.Client.Transport = &oauth2.Transport{
Base: c.Client.Transport,
Source: c.instance,
}
return c, nil
}
// URI returns the base URI of the Home Assistant instance
func (c *Connection) URI() string {
return c.instance.URI()
}
// GetStates retrieves the list of entities
func (c *Connection) GetStates() ([]StateResponse, error) {
var res []StateResponse
uri := fmt.Sprintf("%s/api/states", c.instance.URI())
err := c.GetJSON(uri, &res)
return res, err
}
// GetServices retrieves the list of callable services
func (c *Connection) GetServices() ([]ServiceDomainResponse, error) {
var res []ServiceDomainResponse
uri := fmt.Sprintf("%s/api/services", c.instance.URI())
err := c.GetJSON(uri, &res)
return res, err
}
// GetState retrieves the state of an entity
func (c *Connection) GetState(entity string) (StateResponse, error) {
var res StateResponse
uri := fmt.Sprintf("%s/api/states/%s", c.instance.URI(), url.PathEscape(entity))
if err := c.GetJSON(uri, &res); err != nil {
return res, err
}
if res.State == "unknown" || res.State == "unavailable" {
return res, api.ErrNotAvailable
}
return res, nil
}
// GetIntState retrieves the state of an entity as int64
func (c *Connection) GetIntState(entity string) (int64, error) {
state, err := c.GetState(entity)
if err != nil {
return 0, err
}
value, err := strconv.ParseInt(state.State, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid numeric state '%s' for entity %s: %w", state.State, entity, err)
}
return value, nil
}
// GetFloatState retrieves the state of an entity as float64
func (c *Connection) GetFloatState(entity string) (float64, error) {
// leading minus sign?
entity, invert := strings.CutPrefix(entity, "-")
state, err := c.GetState(entity)
if err != nil {
return 0, err
}
value, err := strconv.ParseFloat(state.State, 64)
if err != nil {
return 0, fmt.Errorf("invalid numeric state '%s' for entity %s: %w", state.State, entity, err)
}
scale, err := state.scale()
if err != nil {
return 0, fmt.Errorf("%w for entity %s", err, entity)
}
if invert {
value = -value
}
return scale * value, nil
}
// GetBoolState retrieves the state of an entity as boolean
func (c *Connection) GetBoolState(entity string) (bool, error) {
state, err := c.GetState(entity)
if err != nil {
return false, err
}
res := strings.ToLower(state.State)
switch res {
case "on", "true", "1", "active", "yes":
return true, nil
case "off", "false", "0", "inactive", "no":
return false, nil
default:
return false, fmt.Errorf("invalid boolean state '%s' for entity %s", state, entity)
}
}
// GetTimeState retrieves the state of an entity as time
func (c *Connection) GetTimeState(entity string) (time.Time, error) {
state, err := c.GetState(entity)
if err != nil {
return time.Time{}, err
}
if ts, err := strconv.ParseInt(state.State, 10, 64); err == nil {
return time.Unix(ts, 0), nil
}
return time.Parse(time.RFC3339, state.State)
}
// chargeStatusMap maps unambiguous Home Assistant states to evcc charge status.
// Vendor-specific states are configured per device, see NewStatusMap.
var chargeStatusMap = map[string]api.ChargeStatus{
"a": api.StatusA,
"disconnected": api.StatusA,
"not_plugged": api.StatusA,
"b": api.StatusB,
"connected": api.StatusB,
"plugged": api.StatusB,
"starting": api.StatusB,
"stopped": api.StatusB,
"paused": api.StatusB,
"complete": api.StatusB,
"charging_completed": api.StatusB,
"c": api.StatusC,
"charging": api.StatusC,
}
// StatusMap maps device-specific Home Assistant states to evcc charge status
type StatusMap map[string]api.ChargeStatus
// NewStatusMap creates a status map from comma-separated, case-insensitive lists
// of states. It extends the built-in mapping, overriding it only for states
// explicitly mapped to a different status.
func NewStatusMap(a, b, c string) (StatusMap, error) {
res := make(StatusMap)
for _, e := range []struct {
status api.ChargeStatus
states string
}{
{api.StatusA, a},
{api.StatusB, b},
{api.StatusC, c},
} {
for _, s := range strings.Split(e.states, ",") {
if s = strings.ToLower(strings.TrimSpace(s)); s != "" {
if status, ok := res[s]; ok {
return nil, fmt.Errorf("status %s: duplicate state '%s', already mapped to %s", e.status, s, status)
}
res[s] = e.status
}
}
}
return res, nil
}
// GetChargeStatus maps Home Assistant states to api.ChargeStatus. The
// device-specific status map extends the built-in mapping and takes precedence.
func (c *Connection) GetChargeStatus(entity string, states StatusMap) (api.ChargeStatus, error) {
state, err := c.GetState(entity)
if err != nil {
return api.StatusNone, err
}
s := strings.ToLower(strings.TrimSpace(state.State))
if status, ok := states[s]; ok {
return status, nil
}
if status, ok := chargeStatusMap[s]; ok {
return status, nil
}
return api.StatusNone, fmt.Errorf("unknown charge status '%s' for entity %s", state.State, entity)
}
// CallService calls a Home Assistant service
func (c *Connection) CallService(domain, service string, data map[string]any) error {
uri := fmt.Sprintf("%s/api/services/%s/%s", c.instance.URI(), domain, service)
req, err := request.New(http.MethodPost, uri, request.MarshalJSON(data), request.JSONEncoding)
if err != nil {
return err
}
_, err = c.DoBody(req)
return err
}
func domain(entity string) (string, error) {
domain, _, ok := strings.Cut(entity, ".")
if !ok {
return "", fmt.Errorf("invalid entity format: %s", entity)
}
return domain, nil
}
// CallSwitchService is a convenience method for switch-like services. The
// service name depends on the entity domain: stateless button domains expose
// only `press`, while switch-style domains use `turn_on` / `turn_off`.
func (c *Connection) CallSwitchService(entity string, turnOn bool) error {
domain, err := domain(entity)
if err != nil {
return err
}
var service string
switch domain {
case "button", "input_button":
// Buttons are stateless — they only have a press action.
if !turnOn {
return fmt.Errorf("entity %s has no off action", entity)
}
service = "press"
default:
service = "turn_off"
if turnOn {
service = "turn_on"
}
}
data := map[string]any{
"entity_id": entity,
}
return c.CallService(domain, service, data)
}
// CallNumberService is a convenience method for setting number entity values
func (c *Connection) CallNumberService(entity string, value float64) error {
domain, err := domain(entity)
if err != nil {
return err
}
data := map[string]any{
"entity_id": entity,
"value": value,
}
return c.CallService(domain, "set_value", data)
}
// CallSelectService is a convenience method for setting select entity options.
func (c *Connection) CallSelectService(entity, option string) error {
domain, err := domain(entity)
if err != nil {
return err
}
data := map[string]any{
"entity_id": entity,
"option": option,
}
return c.CallService(domain, "select_option", data)
}
// GetPhaseFloatStates retrieves three phase values (currents, voltages, etc.)
func (c *Connection) GetPhaseFloatStates(entities []string) (float64, float64, float64, error) {
if len(entities) != 3 {
return 0, 0, 0, errors.New("invalid phase entities")
}
var res [3]float64
for i := range res {
f, err := c.GetFloatState(entities[i])
if err != nil {
return 0, 0, 0, fmt.Errorf("phase L%d: %w", i+1, err)
}
res[i] = f
}
return res[0], res[1], res[2], nil
}
// ValidatePhaseEntities validates that phase entity arrays contain 1 or 3 entities
func ValidatePhaseEntities(phases []string) ([]string, error) {
entities := lo.FilterMap(phases, func(s string, _ int) (string, bool) {
t := strings.TrimSpace(s)
return t, t != ""
})
switch len(entities) {
case 0:
return nil, nil
case 3:
return entities, nil
default:
return nil, errors.New("invalid phase entities")
}
}