Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion custom_components/foxess/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,36 @@
"""The Foxess cloud integration."""
"""The FoxESS Cloud integration."""
from __future__ import annotations

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant

from .const import DOMAIN

PLATFORMS = ["sensor"]


async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Set up the FoxESS integration from YAML."""
return True


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up FoxESS Cloud from a config entry."""
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = entry.data
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a FoxESS config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id, None)
return unload_ok


async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
170 changes: 170 additions & 0 deletions custom_components/foxess/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""Config flow for FoxESS Cloud integration."""
from __future__ import annotations

import hashlib
import time
from typing import Any

import aiohttp
import voluptuous as vol

from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow
from homeassistant.const import CONF_NAME
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession

from .const import (
CONF_APIKEY,
CONF_DEVICEID,
CONF_DEVICESN,
CONF_EVO,
CONF_EXTPV,
CONF_HAS_BATTERY,
DEFAULT_NAME,
DOMAIN,
ENDPOINT_OA_DEVICE_DETAIL,
ENDPOINT_OA_DOMAIN,
)

STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_APIKEY): str,
vol.Required(CONF_DEVICESN): str,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): str,
}
)


def _build_foxess_headers(api_key: str, path: str) -> dict[str, str]:
"""Build authentication headers for the FoxESS OpenAPI."""
timestamp = str(int(time.time() * 1000))
# Uses literal \r\n (raw string), matching GetAuth in sensor.py
signature_text = rf"{path}\r\n{api_key}\r\n{timestamp}"
signature = hashlib.md5(signature_text.encode()).hexdigest()
return {
"token": api_key,
"timestamp": timestamp,
"signature": signature,
"lang": "en",
"Content-Type": "application/json",
}


async def _validate_api(
session: aiohttp.ClientSession, api_key: str, device_sn: str
) -> dict[str, Any]:
"""Validate credentials by calling the FoxESS device detail endpoint."""
path = ENDPOINT_OA_DEVICE_DETAIL
url = f"{ENDPOINT_OA_DOMAIN}{path}?sn={device_sn}"
headers = _build_foxess_headers(api_key, path)

try:
async with session.get(url, headers=headers, ssl=False) as resp:
if resp.status == 401:
raise ValueError("invalid_auth")
if resp.status != 200:
raise ValueError("cannot_connect")
data = await resp.json()
except (aiohttp.ClientError, TimeoutError) as err:
raise ValueError("cannot_connect") from err

errno = data.get("errno", -1)
if errno != 0:
msg = data.get("msg", "").lower()
if errno in (41807, 41808, 41809, 40256) or "token" in msg or "sign" in msg:
raise ValueError("invalid_auth")
if errno in (41930, 40261, 40257) or "device" in msg:
raise ValueError("device_not_found")
if errno == 40400:
raise ValueError("cannot_connect")
raise ValueError("unknown")

result = data.get("result")
if result is None:
raise ValueError("unknown")
return result


class FoxESSConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for FoxESS Cloud."""

VERSION = 1

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
# Abort if YAML platform is already configured
for state in self.hass.states.async_all("sensor"):
if state.entity_id.startswith("sensor.foxess_"):
return self.async_abort(reason="yaml_in_use")

errors: dict[str, str] = {}

if user_input is not None:
api_key = user_input[CONF_APIKEY]
device_sn = user_input[CONF_DEVICESN]
name = user_input.get(CONF_NAME, DEFAULT_NAME)

session = async_get_clientsession(self.hass)
try:
result = await _validate_api(session, api_key, device_sn)
except ValueError as err:
errors["base"] = str(err)
else:
await self.async_set_unique_id(device_sn)
self._abort_if_unique_id_configured()

return self.async_create_entry(
title=f"{name} ({device_sn})",
data={
CONF_APIKEY: api_key,
CONF_DEVICESN: device_sn,
CONF_DEVICEID: device_sn,
CONF_NAME: name,
CONF_HAS_BATTERY: bool(result.get("hasBattery")),
},
options={
CONF_EXTPV: False,
CONF_EVO: False,
},
)

return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)

@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
return FoxESSOptionsFlow()


class FoxESSOptionsFlow(OptionsFlow):
"""Handle FoxESS options."""

async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
if user_input is not None:
return self.async_create_entry(title="", data=user_input)

options = self.config_entry.options
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Optional(
CONF_EXTPV,
default=options.get(CONF_EXTPV, False),
): bool,
vol.Optional(
CONF_EVO,
default=options.get(CONF_EVO, False),
): bool,
}
),
)
18 changes: 18 additions & 0 deletions custom_components/foxess/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Constants for the FoxESS Cloud integration."""

DOMAIN = "foxess"

ENDPOINT_OA_DOMAIN = "https://www.foxesscloud.com"
ENDPOINT_OA_DEVICE_DETAIL = "/op/v1/device/detail"

CONF_APIKEY = "apiKey"
CONF_DEVICESN = "deviceSN"
CONF_DEVICEID = "deviceID"
CONF_EXTPV = "extendPV"
CONF_XTZONE = "xtZone"
CONF_GET_VARIABLES = "Restrict"
CONF_V1_API = "Use_V1_Api"
CONF_EVO = "Evo"
CONF_HAS_BATTERY = "hasBattery"

DEFAULT_NAME = "FoxESS"
5 changes: 3 additions & 2 deletions custom_components/foxess/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
"domain": "foxess",
"name": "HA & FoxESSCloud integration",
"codeowners": ["@macxq","@r-amado","@fozzieuk"],
"config_flow": true,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🥳

"dependencies": ["rest"],
"documentation": "https://github.com/macxq/foxess-ha",
"iot_class": "local_polling",
"iot_class": "cloud_polling",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice one 👍

"issue_tracker":"https://github.com/macxq/foxess-ha/issues",
"version": "v0.4"
"version": "v0.4"
}
Loading