From 1436cadc6f745842d2bd3e8ff2c7aca5c04f3906 Mon Sep 17 00:00:00 2001 From: Joshua Leaper Date: Tue, 4 Aug 2026 20:29:41 +0930 Subject: [PATCH] Document the full API, add the periodic charge/discharge endpoints, fix error handling Supersedes the work on feature/time-charge-endpoints, whose RETURN_CODES table, success-detection helper and tests are carried over here. Documentation ------------- The developer portal needs registration to read, so mirror it in docs/: - docs/API.md - all 19 endpoints, each with what it needs, what it returns (every field, type and unit), a captured live response, and the matching library method. - docs/RETURN_CODES.md - the complete return code table (both pages of the portal's paginated list), grouped by cause. Verified endpoint by endpoint against the live API, which turned up four places where the official documentation is wrong: - getVerificationCode is GET, not POST (POST returns HTTP 405) - bindSn is POST, not GET as the bundled Postman collection has it - getOneDayPowerBySn returns cbat/pchargingPile, not cobat/pChargingPile - return code 6017 "No operation permissions" exists but is unpublished New endpoints ------------- getTimeChargeBySn and setTimeChargeBySn - the periodic (weekly) scheduler. Up to 6 periods per day, per-weekday selection and a power setpoint per period, where updateChargeConfigInfo only offers two daily periods. Exposed on getdata() behind get_timecharge=False. The flag is appended after self_delay so existing positional callers are unaffected. Not every system is entitled to it - systems without the feature answer 6017, which is handled as "feature unavailable" rather than an error. Fixes ----- - getVerificationCode used POST, which the API rejects with a 405. It could never have worked. Now GET. - Wrapper methods caught exceptions from api_get()/api_post(), logged them and returned None without re-raising, so a transport failure reached the caller indistinguishable from an empty result. homeassistant-alphaESS relies on the exception surfacing to mark an update failed and retry with backoff, so entities could sit unavailable long after the network recovered. All 19 wrappers now re-raise. Fixes #26. - api_post() logged an error on success-with-payload and returned None silently on genuine API errors - the two branches were inverted, so a failed write was invisible. Return values are unchanged. - Success detection now accepts code 200, "msg" or "info". The portal documents the periodic endpoints as using "info" while the live API returns "msg" for all 19. - Failed responses now log the decoded return code description, so "6017" reads as "(No operation permissions)" instead of a bare number. Tests ----- 34 tests, no network access - the aiohttp session is mocked. Cover the new endpoints, success detection via each of the three signals, that transport errors re-raise from all 19 wrappers, that API-level errors still return None, that getVerificationCode issues a GET, and that getdata keeps self_delay positional. --- README.md | 68 ++- alphaess/alphaess.py | 150 ++++- docs/API.md | 1047 ++++++++++++++++++++++++++++++++++ docs/RETURN_CODES.md | 221 +++++++ pytest.ini | 7 + setup.py | 2 +- tests/__init__.py | 0 tests/test_error_handling.py | 148 +++++ tests/test_time_charge.py | 211 +++++++ 9 files changed, 1827 insertions(+), 27 deletions(-) create mode 100644 docs/API.md create mode 100644 docs/RETURN_CODES.md create mode 100644 pytest.ini create mode 100644 tests/__init__.py create mode 100644 tests/test_error_handling.py create mode 100644 tests/test_time_charge.py diff --git a/README.md b/README.md index ae9189f..ee25608 100644 --- a/README.md +++ b/README.md @@ -12,22 +12,52 @@ Once registered, add your battery/inverter to the developer account via the web To be good internet citizens, it is advised that your polling frequency for any AlphaCloud endpoints are 10 seconds at a minimum. +# API documentation + +The developer portal requires registration to read, so the documentation is mirrored here — +transcribed from the portal and verified endpoint by endpoint against the live API: + ++ **[docs/API.md](docs/API.md)** — all 19 endpoints. For each one: what you send, what comes + back (with every field, its type and its unit), a real captured response, and the matching + library method. ++ **[docs/RETURN_CODES.md](docs/RETURN_CODES.md)** — the complete return code table (both pages + of the portal's paginated list), grouped by cause, plus codes the portal does not publish. + +Four things the official documentation gets wrong are corrected in +[docs/API.md](docs/API.md#corrections-to-the-official-documentation). + # Methods There are public methods in this module that duplicate the AlphaESS OpenAPI and provide wrappers for +all 19 documented endpoints: -+ https://openapi.alphaess.com/api/getEssList -+ https://openapi.alphaess.com/api/getLastPowerData -+ https://openapi.alphaess.com/api/getOneDayPowerBySn -+ https://openapi.alphaess.com/api/getOneDateEnergyBySn -+ https://openapi.alphaess.com/api/getChargeConfigInfo -+ https://openapi.alphaess.com/api/updateChargeConfigInfo -+ https://openapi.alphaess.com/api/getDisChargeConfigInfo -+ https://openapi.alphaess.com/api/updateDisChargeConfigInfo +| Endpoint | Method | +| --- | --- | +| https://openapi.alphaess.com/api/getEssList | `getESSList()` | +| https://openapi.alphaess.com/api/getLastPowerData | `getLastPowerData(sysSn)` | +| https://openapi.alphaess.com/api/getOneDayPowerBySn | `getOneDayPowerBySn(sysSn, queryDate=None)` | +| https://openapi.alphaess.com/api/getOneDateEnergyBySn | `getOneDateEnergyBySn(sysSn, queryDate=None)` | +| https://openapi.alphaess.com/api/getSumDataForCustomer | `getSumDataForCustomer(sysSn)` | +| https://openapi.alphaess.com/api/getChargeConfigInfo | `getChargeConfigInfo(sysSn)` | +| https://openapi.alphaess.com/api/updateChargeConfigInfo | `updateChargeConfigInfo(...)` | +| https://openapi.alphaess.com/api/getDisChargeConfigInfo | `getDisChargeConfigInfo(sysSn)` | +| https://openapi.alphaess.com/api/updateDisChargeConfigInfo | `updateDisChargeConfigInfo(...)` | +| https://openapi.alphaess.com/api/getTimeChargeBySn | `getTimeChargeBySn(sysSn)` | +| https://openapi.alphaess.com/api/setTimeChargeBySn | `setTimeChargeBySn(...)` | +| https://openapi.alphaess.com/api/getVerificationCode | `getVerificationCode(sysSn, checkCode)` | +| https://openapi.alphaess.com/api/bindSn | `bindSn(sysSn, code)` | +| https://openapi.alphaess.com/api/unBindSn | `unBindSn(sysSn)` | +| https://openapi.alphaess.com/api/getEvChargerConfigList | `getEvChargerConfigList(sysSn)` | +| https://openapi.alphaess.com/api/getEvChargerCurrentsBySn | `getEvChargerCurrentsBySn(sysSn)` | +| https://openapi.alphaess.com/api/setEvChargerCurrentsBySn | `setEvChargerCurrentsBySn(sysSn, currentsetting)` | +| https://openapi.alphaess.com/api/getEvChargerStatusBySn | `getEvChargerStatusBySn(sysSn, evchargerSn)` | +| https://openapi.alphaess.com/api/remoteControlEvCharger | `remoteControlEvCharger(sysSn, evchargerSn, controlMode)` | All of the above are documented at https://open.alphaess.com/developmentManagement/apiList (Registration required) -+ getdata() - Attempts to get statistical energy data for use in Home Assistant for all registered Alpha ESS systems - will return None if there are issues retrieving data from the Alpha ESS API. +## Convenience methods + ++ getdata(get_power=False, get_ev=False, self_delay=0, get_timecharge=False) - Attempts to get statistical energy data for use in Home Assistant for all registered Alpha ESS systems - will return None if there are issues retrieving data from the Alpha ESS API. + authenticate - Attempts to use https://openapi.alphaess.com/api/getEssList to validate authentication to the ALpha ESS API - will return True or False. + setbatterycharge (serial, enabled, dp1start, dp1end, dp2start, dp2end, chargecutoffsoc) **Parameters:** @@ -39,6 +69,26 @@ All of the above are documented at https://open.alphaess.com/developmentManageme - `dp2end` (`datetime.time`) The end time of charging period 2 (the minutes must be one of :00, :15, :30, :45) - `serial` (str) The serial number of the battery/inverter. ++ setTimeChargeBySn (sysSn, executeCycleType, chargeTimeList, dischargeTimeList, gridChargeCycle=None, ctrDisCycle=None) + +The periodic (weekly) scheduling API. Unlike `setbatterycharge`/`setbatterydischarge` it supports up to six periods per day, per-weekday selection, and a power setpoint per period. Not every system is entitled to it — systems without the feature return code `6017` (`No operation permissions`). + +**Parameters:** +- `sysSn` (str) The serial number of the battery/inverter. +- `executeCycleType` (int) 0 - daily, 1 - weekly +- `chargeTimeList` / `dischargeTimeList` (list of dict) Each period is `{"beginTime": "HH:mm", "endTime": "HH:mm", "chargeLimit": 10-100}`, plus optional `weeks` (a list of 1-7 for Monday-Sunday, required when weekly) and `chargePower`. Maximum 6 periods per day / 28 per week; charge and discharge periods must not overlap. +- `gridChargeCycle` (int) 0 - periodic charging disabled, 1 - enabled +- `ctrDisCycle` (int) 0 - periodic discharging disabled, 1 - enabled + +```python +await client.setTimeChargeBySn( + serial, 1, + chargeTimeList=[{"beginTime": "01:00", "endTime": "05:00", "weeks": [1, 2, 3, 4, 5], "chargeLimit": 90}], + dischargeTimeList=[{"beginTime": "17:00", "endTime": "21:00", "weeks": [1, 2, 3, 4, 5], "chargeLimit": 20}], + gridChargeCycle=1, ctrDisCycle=1, +) +``` + + setbatterydischarge (serial, enabled, dp1start, dp1end, dp2start, dp2end, dischargecutoffsoc) **Parameters:** - `dischargecutoffsoc` (float) % to stop discharging from the battery at diff --git a/alphaess/alphaess.py b/alphaess/alphaess.py index 55158f7..ec07bff 100644 --- a/alphaess/alphaess.py +++ b/alphaess/alphaess.py @@ -10,6 +10,35 @@ BASEURL = "https://openapi.alphaess.com/api" +# Return codes as published on the developer portal, see docs/RETURN_CODES.md +RETURN_CODES = { + 6001: "Parameter error", + 6002: "The SN is not bound to the user", + 6003: "You have bound this SN", + 6004: "CheckCode error", + 6005: "This appId is not bound to the SN", + 6006: "Timestamp error", + 6007: "Sign verification error", + 6008: "Set failed", + 6009: "Whitelist verification failed", + 6010: "Sign is empty", + 6011: "timestamp is empty", + 6012: "AppId is empty", + 6016: "Data does not exist or has been deleted", + 6026: "internal error", + 6029: "operation failed", + 6038: "system sn does not exist", + 6042: "system offline", + 6046: "Verification code error", + 6053: "The request was too fast, please try again later", +} + +# Codes the API returns but the portal does not publish. Kept separate so +# RETURN_CODES stays a faithful copy of the documented table. +UNDOCUMENTED_RETURN_CODES = { + 6017: "No operation permissions", +} + class alphaess: """Class for Alpha ESS.""" @@ -55,6 +84,26 @@ def __headers(self): "timeStamp": timestamp } + @staticmethod + def __is_success(json_response) -> bool: + """Check whether a json response indicates success. + + Most endpoints report the status as "msg", the periodic charge/discharge + endpoints are documented as reporting it as "info". + """ + return ( + json_response.get("code") == 200 + or json_response.get("msg") == "Success" + or json_response.get("info") == "Success" + ) + + @staticmethod + def __return_code_description(json_response) -> str: + """Return a formatted description for the response code, if known""" + code = json_response.get("code") + description = RETURN_CODES.get(code) or UNDOCUMENTED_RETURN_CODES.get(code) + return f" ({description})" if description else "" + async def getESSList(self) -> Optional(list): """According to SN to get system list data""" try: @@ -66,6 +115,7 @@ async def getESSList(self) -> Optional(list): except Exception as e: logger.error(f"Error: {e} when calling {resource}") + raise async def getLastPowerData(self, sysSn) -> Optional(list): """According SN to get real-time power data""" @@ -78,6 +128,7 @@ async def getLastPowerData(self, sysSn) -> Optional(list): except Exception as e: logger.error(f"Error: {e} when calling {resource}") + raise async def getOneDayPowerBySn(self, sysSn, queryDate=None) -> Optional(list): """According SN to get system power data""" @@ -92,6 +143,7 @@ async def getOneDayPowerBySn(self, sysSn, queryDate=None) -> Optional(list): except Exception as e: logger.error(f"Error: {e} when calling {resource}") + raise async def getSumDataForCustomer(self, sysSn) -> Optional(list): """According SN to get System Summary data""" @@ -104,6 +156,7 @@ async def getSumDataForCustomer(self, sysSn) -> Optional(list): except Exception as e: logger.error(f"Error: {e} when calling {resource}") + raise async def getOneDateEnergyBySn(self, sysSn, queryDate=None) -> Optional(list): """According SN to get System Energy Data""" @@ -118,6 +171,7 @@ async def getOneDateEnergyBySn(self, sysSn, queryDate=None) -> Optional(list): except Exception as e: logger.error(f"Error: {e} when calling {resource}") + raise async def getChargeConfigInfo(self, sysSn) -> Optional(list): """According SN to get charging setting information""" @@ -130,6 +184,7 @@ async def getChargeConfigInfo(self, sysSn) -> Optional(list): except Exception as e: logger.error(f"Error: {e} when calling {resource}") + raise async def getDisChargeConfigInfo(self, sysSn) -> Optional(list): """According to SN discharge setting information""" @@ -142,6 +197,7 @@ async def getDisChargeConfigInfo(self, sysSn) -> Optional(list): except Exception as e: logger.error(f"Error: {e} when calling {resource}") + raise async def getEvChargerConfigList(self, sysSn) -> Optional(list): """According to SN get Ev Charger Config List""" @@ -154,6 +210,7 @@ async def getEvChargerConfigList(self, sysSn) -> Optional(list): except Exception as e: logger.error(f"Error: {e} when calling {resource}") + raise async def setEvChargerCurrentsBySn(self, sysSn, currentsetting) -> Optional(list): """According to SN set Ev Charger Currents""" @@ -171,6 +228,7 @@ async def setEvChargerCurrentsBySn(self, sysSn, currentsetting) -> Optional(list except Exception as e: logger.error(f"Error: {e} when calling {resource}") + raise async def getEvChargerCurrentsBySn(self, sysSn) -> Optional(list): """According to SN get Ev Charger Currents""" @@ -183,6 +241,7 @@ async def getEvChargerCurrentsBySn(self, sysSn) -> Optional(list): except Exception as e: logger.error(f"Error: {e} when calling {resource}") + raise async def getEvChargerStatusBySn(self, sysSn, evchargerSn) -> Optional(list): """According to SN get Ev Charger Status""" @@ -195,6 +254,7 @@ async def getEvChargerStatusBySn(self, sysSn, evchargerSn) -> Optional(list): except Exception as e: logger.error(f"Error: {e} when calling {resource}") + raise async def remoteControlEvCharger(self, sysSn, evchargerSn, controlMode) -> Optional(dict): """According SN to Remote Control Ev Charger""" @@ -213,6 +273,7 @@ async def remoteControlEvCharger(self, sysSn, evchargerSn, controlMode) -> Optio except Exception as e: logger.error(f"Error: {e} when calling {resource}") + raise async def bindSn(self, sysSn, code) -> Optional(dict): """According to SN to Bind SN""" @@ -230,23 +291,20 @@ async def bindSn(self, sysSn, code) -> Optional(dict): except Exception as e: logger.error(f"Error: {e} when calling {resource}") + raise async def getVerificationCode(self, sysSn, checkCode) -> Optional(dict): """According SN to Get Verification Code""" try: - resource = f"{BASEURL}/getVerificationCode" - - settings = { - "sysSn": sysSn, - "checkCode": checkCode - } + resource = f"{BASEURL}/getVerificationCode?sysSn={sysSn}&checkCode={checkCode}" - logger.debug(f"Trying to call {resource} with settings {settings}") + logger.debug(f"Trying to call {resource}") - return await self.api_post(resource, settings) + return await self.api_get(resource) except Exception as e: logger.error(f"Error: {e} when calling {resource}") + raise async def unBindSn(self, sysSn) -> Optional(dict): """According SN to UnBind SN""" @@ -263,6 +321,7 @@ async def unBindSn(self, sysSn) -> Optional(dict): except Exception as e: logger.error(f"Error: {e} when calling {resource}") + raise async def updateChargeConfigInfo(self, sysSn, batHighCap, gridCharge, timeChae1, timeChae2, timeChaf1, timeChaf2) -> Optional(dict): @@ -286,6 +345,7 @@ async def updateChargeConfigInfo(self, sysSn, batHighCap, gridCharge, timeChae1, except Exception as e: logger.error(f"Error: {e} when calling {resource}") + raise async def updateDisChargeConfigInfo(self, sysSn, batUseCap, ctrDis, timeDise1, timeDise2, timeDisf1, timeDisf2) -> Optional(dict): @@ -309,6 +369,55 @@ async def updateDisChargeConfigInfo(self, sysSn, batUseCap, ctrDis, timeDise1, t except Exception as e: logger.error(f"Error: {e} when calling {resource}") + raise + + async def getTimeChargeBySn(self, sysSn) -> Optional(dict): + """According SN to get periodic charge/discharge settings""" + try: + resource = f"{BASEURL}/getTimeChargeBySn?sysSn={sysSn}" + + logger.debug(f"Trying to call {resource}") + + return await self.api_get(resource) + + except Exception as e: + logger.error(f"Error: {e} when calling {resource}") + raise + + async def setTimeChargeBySn(self, sysSn, executeCycleType, chargeTimeList, dischargeTimeList, + gridChargeCycle=None, ctrDisCycle=None) -> Optional(dict): + """According SN to set periodic charge/discharge settings + + executeCycleType: 0 - daily, 1 - weekly + chargeTimeList / dischargeTimeList: lists of periods, each a dict of + beginTime (HH:mm), endTime (HH:mm), chargeLimit (cutoff SOC, 10-100) and + optionally weeks ([1..7], Monday to Sunday, required when weekly) and chargePower. + Maximum 6 periods per day / 28 per week, charge and discharge must not overlap. + gridChargeCycle / ctrDisCycle: 0 - disabled, 1 - enabled + """ + try: + resource = f"{BASEURL}/setTimeChargeBySn" + + settings = { + "sysSn": sysSn, + "executeCycleType": executeCycleType, + "chargeTimeList": chargeTimeList, + "dischargeTimeList": dischargeTimeList + } + + if gridChargeCycle is not None: + settings["gridChargeCycle"] = int(gridChargeCycle) + + if ctrDisCycle is not None: + settings["ctrDisCycle"] = int(ctrDisCycle) + + logger.debug(f"Trying to call {resource} with settings {settings}") + + return await self.api_post(resource, settings) + + except Exception as e: + logger.error(f"Error: {e} when calling {resource}") + raise async def getIPData(self) -> Optional(dict): ENDPOINTS = { @@ -355,8 +464,10 @@ async def api_get(self, path, json=None) -> Optional(list): else: logger.error(f"Unexpected response received: {response.status} when calling {path}") - if ("msg" in json_response and json_response["msg"] != "Success") or ("msg" not in json_response): - logger.error(f"Unexpected json_response : {json_response} when calling {path}") + if not self.__is_success(json_response): + logger.error( + f"Unexpected json_response : {json_response}" + f"{self.__return_code_description(json_response)} when calling {path}") return None else: if json_response["data"] is not None: @@ -389,18 +500,19 @@ async def api_post(self, path, json) -> Optional(dict): else: logger.error(f"Unexpected response received: {response.status} when calling {path}") - if "msg" in json_response and json_response["msg"] == "Success": - if json_response["data"] is None: - return json_response["data"] - else: - logger.error(f"Unexpected json_response : {json_response} when calling {path}") - return json_response["data"] + if self.__is_success(json_response): + return json_response["data"] + + logger.error( + f"Unexpected json_response : {json_response}" + f"{self.__return_code_description(json_response)} when calling {path}") + return None except Exception as e: logger.error(e) raise - async def getdata(self, get_power=False, get_ev=False, self_delay=0) -> Optional(list): + async def getdata(self, get_power=False, get_ev=False, self_delay=0, get_timecharge=False) -> Optional(list): """Get All Data For All serial numbers from Alpha ESS""" try: alldata = [] @@ -447,6 +559,10 @@ async def getdata(self, get_power=False, get_ev=False, self_delay=0) -> Optional await asyncio.sleep(self_delay) unit['OneDayPower'] = await self.getOneDayPowerBySn(serial, time.strftime("%Y-%m-%d")) + if get_timecharge: + await asyncio.sleep(self_delay) + unit['TimeCharge'] = await self.getTimeChargeBySn(serial) + if get_ev: await asyncio.sleep(self_delay) unit['EVData'] = await self.getEvChargerConfigList(serial) diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..c2340a6 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,1047 @@ +# AlphaESS Open API Reference + +Complete reference for the AlphaESS Open API, transcribed from the developer portal at + (**Development Management → API List**) and verified endpoint by +endpoint against the live API at `https://openapi.alphaess.com/api`. Where the portal +documentation disagrees with the live service, the observed behaviour is called out. + +Return codes have their own file: **[RETURN_CODES.md](RETURN_CODES.md)**. + +- **Base URL:** `https://openapi.alphaess.com/api` +- **Portal documentation:** (registration required) +- **Endpoints documented:** 19 — all 19 are wrapped by this library + +Every endpoint below is documented with four things: **Needs** (what you send), **Returns** +(what comes back), **Example** (a real captured response), and **Library** (the Python wrapper +and what it hands you). + +--- + +## Contents + +- [Authentication](#authentication) +- [Response envelope](#response-envelope) +- [Endpoint summary](#endpoint-summary) +- [Corrections to the official documentation](#corrections-to-the-official-documentation) +- **System** + - [getEssList](#getesslist) · [getSumDataForCustomer](#getsumdataforcustomer) +- **Power & energy** + - [getLastPowerData](#getlastpowerdata) · [getOneDayPowerBySn](#getonedaypowerbysn) · [getOneDateEnergyBySn](#getonedateenergybysn) +- **Charge / discharge configuration** + - [getChargeConfigInfo](#getchargeconfiginfo) · [updateChargeConfigInfo](#updatechargeconfiginfo) · [getDisChargeConfigInfo](#getdischargeconfiginfo) · [updateDisChargeConfigInfo](#updatedischargeconfiginfo) +- **Periodic (weekly) charge / discharge** + - [getTimeChargeBySn](#gettimechargebysn) · [setTimeChargeBySn](#settimechargebysn) +- **System binding** + - [getVerificationCode](#getverificationcode) · [bindSn](#bindsn) · [unBindSn](#unbindsn) +- **EV charger** + - [getEvChargerConfigList](#getevchargerconfiglist) · [getEvChargerCurrentsBySn](#getevchargercurrentsbysn) · [setEvChargerCurrentsBySn](#setevchargercurrentsbysn) · [getEvChargerStatusBySn](#getevchargerstatusbysn) · [remoteControlEvCharger](#remotecontrolevcharger) +- [Units and conventions](#units-and-conventions) +- [Rate limits](#rate-limits) +- [Library coverage](#library-coverage) + +--- + +## Authentication + +There is no login, no token exchange and no session. **Every** request carries the same three +headers — including the `GET` endpoints. + +| Header | Required | Type | Description | +|:--|:--|:--|:--| +| `appId` | Yes | string | Developer ID. Portal → *Development Management* → *Developer Information* → "Developer ID (AppID)". | +| `timeStamp` | Yes | long | Unix timestamp in **seconds** (10 digits). Rejected if it deviates from server time by more than **300 seconds**. | +| `sign` | Yes | string | `SHA512(appId + appSecret + timeStamp)`, lower-case hex. | + +`Content-Type: application/json` is required on POST. + +Worked example, straight from the portal: + +``` +appId = alphaef7900ee81dbbce9 +appSecret = c2d2ef6c047c49678e2c332fb2d74c3c +timeStamp = 1676353875 + +pre-image = alphaef7900ee81dbbce9c2d2ef6c047c49678e2c332fb2d74c3c1676353875 +sign = 0f023c2287b8f6b21b0994947465f8e9de0e1542567b1735bdc6c427336b9b64 + 06285cd94f9215c3e9af958df37fb11c2c9fe792713d8afbdb8c463359a1add8 +``` + +```python +import hashlib, time + +timestamp = str(int(time.time())) +sign = hashlib.sha512(f"{appId}{appSecret}{timestamp}".encode("ascii")).hexdigest() +headers = { + "appId": appId, + "timeStamp": timestamp, + "sign": sign, + "Content-Type": "application/json", +} +``` + +The `sign` must be computed from the **same** timestamp you send in the header — generate the +timestamp once and reuse it. A mismatch gives `6007`; a stale clock gives `6006`. + +An optional IP allow-list can be enabled per developer account in the portal. When it is on, +calls from any other address fail with `6009`. + +--- + +## Response envelope + +Identical for every endpoint: + +```json +{ + "code": 200, + "msg": "Success", + "expMsg": null, + "data": { }, + "extra": null +} +``` + +| Field | Type | Description | +|:--|:--|:--| +| `code` | int | `200` on success, otherwise a [return code](RETURN_CODES.md). | +| `msg` | string | Human-readable message. **Localised** — can come back in Chinese or German on an English account. Branch on `code`, never on `msg`. | +| `expMsg` | string | Exception detail. Undocumented in the portal but always present; normally `null`. | +| `data` | object / array / null | Payload. `null` on every error, and also `null` on most successful writes. | +| `extra` | any | Undocumented in the portal but always present; observed as `null`. | + +The portal documents the message field as `msg` for most endpoints and as `info` for the two +periodic charge/discharge endpoints. **The live API returns `msg` for all 19**; `info` is what +the portal's own internal management API uses. This library accepts either. + +`code: 200` is not a promise of a payload — `data` is `null` for most writes and `[]` for +`getEvChargerConfigList` on a system with no EV charger. Check `data` separately. + +--- + +## Endpoint summary + +| # | Method | Endpoint | Needs | Returns | +|:--|:--|:--|:--|:--| +| 1 | GET | [`getLastPowerData`](#getlastpowerdata) | `sysSn` | object — real-time power | +| 2 | GET | [`getEssList`](#getesslist) | — | array — systems on the account | +| 3 | GET | [`getOneDayPowerBySn`](#getonedaypowerbysn) | `sysSn`, `queryDate` | array — power time series | +| 4 | GET | [`getOneDateEnergyBySn`](#getonedateenergybysn) | `sysSn`, `queryDate` | object — daily energy totals | +| 5 | GET | [`getChargeConfigInfo`](#getchargeconfiginfo) | `sysSn` | object — grid-charge settings | +| 6 | POST | [`updateChargeConfigInfo`](#updatechargeconfiginfo) | 7 fields | `null` | +| 7 | GET | [`getDisChargeConfigInfo`](#getdischargeconfiginfo) | `sysSn` | object — discharge settings | +| 8 | POST | [`updateDisChargeConfigInfo`](#updatedischargeconfiginfo) | 7 fields | `null` | +| 9 | GET | [`getVerificationCode`](#getverificationcode) | `sysSn`, `checkCode` | `null` — emails the owner | +| 10 | POST | [`bindSn`](#bindsn) | `sysSn`, `code` | `null` | +| 11 | POST | [`unBindSn`](#unbindsn) | `sysSn` | `null` | +| 12 | GET | [`getSumDataForCustomer`](#getsumdataforcustomer) | `sysSn` | object — summary totals | +| 13 | GET | [`getEvChargerConfigList`](#getevchargerconfiglist) | `sysSn` | array — EV chargers | +| 14 | GET | [`getEvChargerCurrentsBySn`](#getevchargercurrentsbysn) | `sysSn` | object — current limit | +| 15 | POST | [`setEvChargerCurrentsBySn`](#setevchargercurrentsbysn) | `sysSn`, `currentsetting` | `null` | +| 16 | GET | [`getEvChargerStatusBySn`](#getevchargerstatusbysn) | `sysSn`, `evchargerSn` | object — charger status | +| 17 | POST | [`remoteControlEvCharger`](#remotecontrolevcharger) | `sysSn`, `evchargerSn`, `controlMode` | `null` | +| 18 | GET | [`getTimeChargeBySn`](#gettimechargebysn) | `sysSn` | object — periodic schedule | +| 19 | POST | [`setTimeChargeBySn`](#settimechargebysn) | 4–6 fields | `null` | + +Interfaces 1–17 are numbered as the portal numbers them (`interface_id` 1–17); the periodic +charge/discharge pair carries portal ids `110000000000000` and `110000000000001`. + +**The method is enforced.** Calling an endpoint with the other verb returns a plain HTTP `405` +with no `code` field. + +--- + +## Corrections to the official documentation + +Four points where the portal (or the bundled Postman collection) is wrong, each confirmed +against the live API: + +| Source says | Actual behaviour | How it was confirmed | +|:--|:--|:--| +| `getVerificationCode` takes a JSON body ("request parameter (Json)") | **GET only**, query-string parameters | POST returns HTTP `405 Method Not Allowed` | +| `bindSn` is a GET with query parameters *(Postman collection)* | **POST only**, JSON body | GET returns HTTP `405 Method Not Allowed` | +| `getOneDayPowerBySn` returns `cobat` and `pChargingPile` | returns **`cbat`** and **`pchargingPile`** | live response inspection | +| 19 return codes exist | at least one more — **`6017 No operation permissions`** | returned by `getTimeChargeBySn` | + +--- + +## System + +### getEssList + +> According to SN to get system list data + +Lists every system bound to your AppID. Usually the first call you make — it gives you the +`sysSn` values every other endpoint needs. + +- **`GET /api/getEssList`** + +**Needs:** authentication headers only. No parameters. + +**Returns:** `data` is an **array** of system objects. + +| Field | Type | Unit | Description | +|:--|:--|:--|:--| +| `sysSn` | string | — | System serial number. The key for every other endpoint. | +| `cobat` | decimal | kWh | Battery capacity | +| `mbat` | string | — | Battery model | +| `minv` | string | — | Inverter model | +| `poinv` | decimal | kW | Inverter nominal power | +| `popv` | decimal | kW | PV nominal power | +| `surplusCobat` | decimal | kWh | Battery capacity remaining | +| `usCapacity` | decimal | % | Battery available percentage | +| `emsStatus` | string | — | EMS status, e.g. `Normal` | + +**Example** (live, two systems on one account): + +```json +{"code":200,"msg":"Success","expMsg":null,"extra":null,"data":[ + {"sysSn":"AL70110230306xx","popv":9.0,"minv":"SMILE5-INV","poinv":5.0, + "cobat":13.34,"mbat":"SMILE-BAT-13.3P","surplusCobat":13.34, + "usCapacity":100.0,"emsStatus":"Normal"}, + {"sysSn":"AL70110230302xx","popv":5.0,"minv":"SMILE5-INV","poinv":5.0, + "cobat":10.1,"mbat":"SMILE-BAT-10.1P","surplusCobat":9.09, + "usCapacity":90.0,"emsStatus":"Normal"}]} +``` + +**Library:** `await client.getESSList()` → `list[dict]`, or `None` on error. +Also used by `authenticate()`, which returns `True`/`False` by checking that at least one +returned entry has a `sysSn`. + +**Common codes:** `6007` (bad sign), `6009` (not on IP allow-list). Returns an empty list if the +AppID is valid but has no systems bound. + +--- + +### getSumDataForCustomer + +> According SN to get System Summary data + +Today's totals plus lifetime figures and the environmental/financial vanity metrics. + +- **`GET /api/getSumDataForCustomer`** + +**Needs:** + +| Parameter | Required | Type | Description | +|:--|:--|:--|:--| +| `sysSn` | Yes | string | System S/N | + +**Returns:** `data` is an **object**. + +| Field | Type | Unit | Description | +|:--|:--|:--|:--| +| `epvtoday` | decimal | kWh | Today's generation | +| `epvtotal` | decimal | kWh | Total generation (lifetime) | +| `eload` | decimal | kWh | Today's load | +| `eoutput` | decimal | kWh | Today's feed-in | +| `einput` | decimal | kWh | Today's consumed from grid | +| `echarge` | decimal | kWh | Today's charged | +| `edischarge` | decimal | kWh | Today's discharged | +| `todayIncome` | decimal | currency | Today's income | +| `totalIncome` | decimal | currency | Total profit | +| `eselfConsumption` | decimal | % | Self-consumption | +| `eselfSufficiency` | decimal | % | Self-sufficiency | +| `treeNum` | decimal | — | Trees planted equivalent | +| `carbonNum` | decimal | kg | CO₂ reduction | +| `moneyType` | string | — | Currency code | + +> **Nullability:** many of these depend on a configured tariff and come back `null` without one. +> On a live test account `epvtotal`, `todayIncome`, `totalIncome`, `eselfConsumption`, +> `eselfSufficiency`, `treeNum`, `carbonNum` and `moneyType` were **all** `null`. Treat every +> field except the `e*` daily totals as optional. + +**Example** (live): + +```json +{"code":200,"msg":"Success","expMsg":null,"extra":null,"data":{ + "epvtoday":10.6,"epvtotal":null,"eload":19.49,"eoutput":0.42,"einput":14.41, + "echarge":12.2,"edischarge":7.1,"todayIncome":null,"totalIncome":null, + "eselfConsumption":null,"eselfSufficiency":null,"treeNum":null, + "carbonNum":null,"moneyType":null}} +``` + +**Library:** `await client.getSumDataForCustomer(sysSn)` → `dict`, or `None` on error. +Included in `getdata()` output as the `SumData` key. + +--- + +## Power & energy + +### getLastPowerData + +> According SN to get real-time power data + +The live snapshot — the endpoint you poll for a dashboard. Everything is instantaneous **power +in watts**, not energy. + +- **`GET /api/getLastPowerData`** + +**Needs:** + +| Parameter | Required | Type | Description | +|:--|:--|:--|:--| +| `sysSn` | Yes | string | System S/N | + +**Returns:** `data` is an **object** with three nested detail objects. + +| Field | | Type | Unit | Description | +|:--|:--|:--|:--|:--| +| `ppv` | | decimal | W | PV total power | +| `ppvDetail` | | object | — | Per-string detail | +| | `ppv1`–`ppv4` | decimal | W | Per-string PV power | +| | `pmeterDc` | decimal | W | DC meter power | +| `pload` | | decimal | W | Load | +| `soc` | | decimal | % | Battery state of charge | +| `pgrid` | | decimal | W | Grid power. **Positive = importing from grid, negative = exporting** | +| `pgridDetail` | | object | — | Per-phase grid detail | +| | `pmeterL1`–`pmeterL3` | decimal | W | Per-phase grid power | +| `pbat` | | decimal | W | Battery power | +| `prealL1`–`prealL3` | | decimal | W | Per-phase inverter output | +| `pev` | | decimal | W | Total EV charger power | +| `pevDetail` | | object | — | Per-charger detail | +| | `ev1Power`–`ev4Power` | decimal | W | Per-charger power. `null` when no charger is fitted | + +**Example** (live, evening, battery discharging to cover load): + +```json +{"code":200,"msg":"Success","expMsg":null,"extra":null,"data":{ + "ppv":0.0,"ppvDetail":{"ppv1":0.0,"ppv2":0.0,"ppv3":0.0,"ppv4":0.0,"pmeterDc":0.0}, + "soc":56.0, + "pev":0,"pevDetail":{"ev1Power":null,"ev2Power":null,"ev3Power":null,"ev4Power":null}, + "prealL1":1159.0,"prealL2":0.0,"prealL3":0.0, + "pgrid":11.0,"pgridDetail":{"pmeterL1":11.0,"pmeterL2":0.0,"pmeterL3":0.0}, + "pload":1275.0,"pbat":1264.0}} +``` + +**Library:** `await client.getLastPowerData(sysSn)` → `dict`, or `None` on error. +Included in `getdata()` output as the `LastPower` key. + +**Common codes:** `6042` (system offline) is routine here — a system that has dropped off returns +`6042` rather than stale figures. + +--- + +### getOneDayPowerBySn + +> According SN to get system power data + +The power time series for one day. Note this returns a **large array** — roughly one sample every +five minutes, ~288 for a full day — so it is not something to poll frequently. + +- **`GET /api/getOneDayPowerBySn`** + +**Needs:** + +| Parameter | Required | Type | Description | +|:--|:--|:--|:--| +| `sysSn` | Yes | string | System S/N | +| `queryDate` | Yes | string | Date, format `yyyy-MM-dd` | + +**Returns:** `data` is an **array** of samples. + +| Field | Type | Unit | Description | +|:--|:--|:--|:--| +| `sysSn` | string | — | System S/N | +| `uploadTime` | datetime | — | Sample timestamp, `yyyy-MM-dd HH:mm:ss` | +| `ppv` | decimal | W | PV power | +| `load` | decimal | W | Load | +| `cbat` | decimal | % | Battery SOC at the sample | +| `feedIn` | decimal | W | Feed-in power | +| `gridCharge` | decimal | W | Grid purchase real-time power | +| `pchargingPile` | decimal | W | EV charger power | + +> **Naming discrepancy:** the portal lists these as `cobat` and `pChargingPile`. The live API +> returns **`cbat`** and **`pchargingPile`** (lower-case `p`). Use the live names — the portal +> names will silently give you `None`. + +**Example** (live, first of 243 records for a partial day): + +```json +{"code":200,"msg":"Success","expMsg":null,"extra":null,"data":[ + {"sysSn":"AL70110230306xx","uploadTime":"2026-08-04 20:09:04","ppv":0.0, + "load":1218.0,"cbat":56.8,"feedIn":0.0,"gridCharge":2.0,"pchargingPile":0}, + ...]} +``` + +**Library:** `await client.getOneDayPowerBySn(sysSn, queryDate=None)` → `list[dict]`, or `None` +on error. `queryDate` defaults to today. Included in `getdata(get_power=True)` output as the +`OneDayPower` key — **off by default** because of the payload size. + +--- + +### getOneDateEnergyBySn + +> According SN to get System Energy Data + +Energy totals for one day, in kWh. This is the counterpart to `getOneDayPowerBySn` — totals +rather than a series. + +- **`GET /api/getOneDateEnergyBySn`** + +**Needs:** + +| Parameter | Required | Type | Description | +|:--|:--|:--|:--| +| `sysSn` | Yes | string | System S/N | +| `queryDate` | Yes | string | Date, format `yyyy-MM-dd` | + +**Returns:** `data` is an **object**. + +| Field | Type | Unit | Description | +|:--|:--|:--|:--| +| `sysSn` | string | — | System S/N | +| `theDate` | string | — | Date | +| `epv` | decimal | kWh | PV generation | +| `eCharge` | decimal | kWh | Total energy charged to battery | +| `eDischarge` | decimal | kWh | Discharge | +| `eGridCharge` | decimal | kWh | Grid charge | +| `eInput` | decimal | kWh | Grid consumption | +| `eOutput` | decimal | kWh | Feed-in | +| `eChargingPile` | decimal | kWh | Total energy consumed by EV chargers | + +Note the inconsistent casing — `epv` is lower-case while everything else is camelCase. + +**Example** (live): + +```json +{"code":200,"msg":"Success","expMsg":null,"extra":null,"data":{ + "sysSn":"AL70110230306xx","theDate":"2026-08-04","eCharge":12.2,"epv":10.6, + "eOutput":0.42,"eInput":14.41,"eGridCharge":9.1,"eDischarge":7.1, + "eChargingPile":0.0}} +``` + +**Library:** `await client.getOneDateEnergyBySn(sysSn, queryDate=None)` → `dict`, or `None` on +error. `queryDate` defaults to today. Included in `getdata()` output as the `OneDateEnergy` key. + +--- + +## Charge / discharge configuration + +The *simple* two-period daily schedule — the same settings exposed in the AlphaESS app. For +weekday-aware scheduling see [Periodic charge / discharge](#periodic-weekly-charge--discharge). + +**Time format for all four endpoints:** `HH:mm`, minimum `00:00`, maximum `23:45`, in +**15-minute steps** (`:00`, `:15`, `:30`, `:45`). Values off the grid are silently ignored by the +inverter — the API accepts them, the device does not act on them. + +**Disabling a period:** set its start and end to the same value, conventionally `00:00`. + +### getChargeConfigInfo + +> According SN to get charging setting information + +- **`GET /api/getChargeConfigInfo`** + +**Needs:** + +| Parameter | Required | Type | Description | +|:--|:--|:--|:--| +| `sysSn` | Yes | string | System S/N | + +**Returns:** `data` is an **object**. + +| Field | Type | Unit | Description | +|:--|:--|:--|:--| +| `batHighCap` | decimal | % | Charging stops at this SOC | +| `gridCharge` | int | — | Enable grid charging: `0` disabled, `1` enabled | +| `timeChaf1` | string | `HH:mm` | Charging period 1 **start** | +| `timeChae1` | string | `HH:mm` | Charging period 1 **end** | +| `timeChaf2` | string | `HH:mm` | Charging period 2 **start** | +| `timeChae2` | string | `HH:mm` | Charging period 2 **end** | + +> Mnemonic for the confusing names: **`f` = from** (start), **`e` = end**. `timeChaf1` is the +> start of charging period 1, `timeChae1` is its end. + +**Example** (live — grid charging disabled, period 1 configured 11:00–14:00): + +```json +{"code":200,"msg":"Success","expMsg":null,"extra":null,"data":{ + "gridCharge":0,"timeChaf1":"11:00","timeChae1":"14:00", + "timeChaf2":"00:00","timeChae2":"00:00","batHighCap":100}} +``` + +**Library:** `await client.getChargeConfigInfo(sysSn)` → `dict`, or `None` on error. +Included in `getdata()` output as the `ChargeConfig` key. + +--- + +### updateChargeConfigInfo + +> According SN to Set charging information. Setting frequency 24 hours, set once a day + +- **`POST /api/updateChargeConfigInfo`** — JSON body + +**Needs:** all seven fields. This is a **full replacement, not a patch** — read the current values +with `getChargeConfigInfo` first if you only want to change one of them, or you will silently +reset the others. + +| Field | Required | Type | Unit | Description | +|:--|:--|:--|:--|:--| +| `sysSn` | Yes | string | — | System S/N | +| `batHighCap` | Yes | decimal | % | Charging stops at this SOC | +| `gridCharge` | Yes | int | — | `0` disabled, `1` enabled | +| `timeChaf1` | Yes | string | `HH:mm` | Charging period 1 start | +| `timeChae1` | Yes | string | `HH:mm` | Charging period 1 end | +| `timeChaf2` | Yes | string | `HH:mm` | Charging period 2 start | +| `timeChae2` | Yes | string | `HH:mm` | Charging period 2 end | + +**Returns:** `data` is `null`. Success is `code: 200`. + +**Example request:** + +```json +{"sysSn":"AL70110230306xx","batHighCap":100,"gridCharge":1, + "timeChaf1":"01:00","timeChae1":"05:00","timeChaf2":"00:00","timeChae2":"00:00"} +``` + +**Library:** two wrappers for the same endpoint. + +```python +# Positional, mirrors the API field order +await client.updateChargeConfigInfo(sysSn, batHighCap, gridCharge, + timeChae1, timeChae2, timeChaf1, timeChaf2) + +# Friendlier ordering, casts enabled/SOC for you +await client.setbatterycharge(serial, enabled, cp1start, cp1end, + cp2start, cp2end, chargestopsoc) +``` + +Both return `None` — which is also what they return on failure. To confirm a write landed, read +it back with `getChargeConfigInfo` or watch the logger. + +> **Rate limit:** documented as writable **once per 24 hours**. + +**Common codes:** `6008` (set failed — check the 15-minute grid), `6042` (system offline), +`6053` (too fast). + +--- + +### getDisChargeConfigInfo + +> According to SN discharge setting information + +- **`GET /api/getDisChargeConfigInfo`** + +**Needs:** + +| Parameter | Required | Type | Description | +|:--|:--|:--|:--| +| `sysSn` | Yes | string | System S/N | + +**Returns:** `data` is an **object**. + +| Field | Type | Unit | Description | +|:--|:--|:--|:--| +| `batUseCap` | decimal | % | Discharging cut-off SOC | +| `ctrDis` | int | — | Enable battery discharge time control: `0` disabled, `1` enabled | +| `timeDisf1` | string | `HH:mm` | Discharging period 1 **start** | +| `timeDise1` | string | `HH:mm` | Discharging period 1 **end** | +| `timeDisf2` | string | `HH:mm` | Discharging period 2 **start** | +| `timeDise2` | string | `HH:mm` | Discharging period 2 **end** | + +Same `f` = from, `e` = end convention as the charge endpoints. + +**Example** (live — time control disabled, cut-off SOC 5%): + +```json +{"code":200,"msg":"Success","expMsg":null,"extra":null,"data":{ + "ctrDis":0,"timeDisf1":"00:00","timeDise1":"00:00", + "timeDisf2":"00:00","timeDise2":"00:00","batUseCap":5}} +``` + +**Library:** `await client.getDisChargeConfigInfo(sysSn)` → `dict`, or `None` on error. +Included in `getdata()` output as the `DisChargeConfig` key. + +--- + +### updateDisChargeConfigInfo + +> According to SN Set discharge information. Setting frequency 24 hours, set once a day + +- **`POST /api/updateDisChargeConfigInfo`** — JSON body + +**Needs:** all seven fields; full replacement, same caveat as the charge endpoint. + +| Field | Required | Type | Unit | Description | +|:--|:--|:--|:--|:--| +| `sysSn` | Yes | string | — | System S/N | +| `batUseCap` | Yes | decimal | % | Discharging cut-off SOC | +| `ctrDis` | Yes | int | — | `0` disabled, `1` enabled | +| `timeDisf1` | Yes | string | `HH:mm` | Discharging period 1 start | +| `timeDise1` | Yes | string | `HH:mm` | Discharging period 1 end | +| `timeDisf2` | Yes | string | `HH:mm` | Discharging period 2 start | +| `timeDise2` | Yes | string | `HH:mm` | Discharging period 2 end | + +**Returns:** `data` is `null`. Success is `code: 200`. + +**Library:** + +```python +await client.updateDisChargeConfigInfo(sysSn, batUseCap, ctrDis, + timeDise1, timeDise2, timeDisf1, timeDisf2) + +await client.setbatterydischarge(serial, enabled, dp1start, dp1end, + dp2start, dp2end, dischargecutoffsoc) +``` + +> **Rate limit:** documented as writable **once per 24 hours**. + +--- + +## Periodic (weekly) charge / discharge + +The newer scheduling API, and the more capable one. Where `updateChargeConfigInfo` gives you two +daily periods, this gives you up to six periods per day, per-weekday selection, and a power +setpoint per period. + +> **Entitlement:** not every system can use these. See the availability note under +> [`getTimeChargeBySn`](#gettimechargebysn). + +### getTimeChargeBySn + +> Get periodic charge/discharge settings by SN + +- **`GET /api/getTimeChargeBySn`** + +**Needs:** + +| Parameter | Required | Type | Description | +|:--|:--|:--|:--| +| `sysSn` | Yes | string | System S/N | + +**Returns:** `data` is an **object** containing two arrays. + +| Field | Type | Description | +|:--|:--|:--| +| `sysSn` | string | System S/N | +| `executeCycleType` | int | `0` daily, `1` weekly | +| `gridChargeCycle` | int | Periodic charging: `0` disabled, `1` enabled | +| `ctrDisCycle` | int | Periodic discharging: `0` disabled, `1` enabled | +| `chargeTimeList` | array | Charge periods — see element table | +| `dischargeTimeList` | array | Discharge periods — see element table | + +**`chargeTimeList` / `dischargeTimeList` element:** + +| Field | Type | Unit | Description | +|:--|:--|:--|:--| +| `executeCycleType` | int | — | `0` daily, `1` weekly | +| `strategyType` | int | — | `0` charge, `1` discharge | +| `beginTime` | string | `HH:mm` | Start time | +| `endTime` | string | `HH:mm` | End time | +| `weeks` | array<int> | — | `1`–`7` = Monday–Sunday | +| `sort` | int | — | Sort order | +| `chargePower` | int | W | Power setting | +| `chargeLimit` | decimal | % | Battery cut-off SOC | + +Note the read model returns `executeCycleType`, `strategyType` and `sort` **per element**, which +the write model does not accept — see [`setTimeChargeBySn`](#settimechargebysn). + +> **Availability — read this before relying on the endpoint.** It is not enabled for every +> system. On a live SMILE5 account with two bound systems it returned +> **`6017 — No operation permissions`** for both, while the same call with an *unbound* SN +> returned `6005`. That ordering proves the endpoint is live and the SN binding check passes — +> what fails is an entitlement check on the account tier or the hardware. Treat `6017` as +> "feature unavailable for this system", not as a transient error to retry. + +**Library:** `await client.getTimeChargeBySn(sysSn)` → `dict`, or `None` on error (including +`6017`). Included in `getdata(get_timecharge=True)` output as the `TimeCharge` key — off by +default, since most systems return `6017`. + +--- + +### setTimeChargeBySn + +> Set periodic charge/discharge settings by SN + +- **`POST /api/setTimeChargeBySn`** — JSON body + +**Needs:** + +| Field | Required | Type | Description | +|:--|:--|:--|:--| +| `sysSn` | Yes | string | System S/N | +| `executeCycleType` | Yes | int | `0` daily, `1` weekly. Range `[0,1]` | +| `gridChargeCycle` | No | int | Periodic charging: `0` disabled, `1` enabled | +| `ctrDisCycle` | No | int | Periodic discharging: `0` disabled, `1` enabled | +| `chargeTimeList` | Yes | array | Charge periods — see element table | +| `dischargeTimeList` | Yes | array | Discharge periods — see element table | + +**`chargeTimeList` / `dischargeTimeList` element:** + +| Field | Required | Type | Unit | Description | +|:--|:--|:--|:--|:--| +| `beginTime` | Yes | string | `HH:mm` | Start time | +| `endTime` | Yes | string | `HH:mm` | End time | +| `weeks` | No | array<int> | — | `1`–`7` = Monday–Sunday. **Required when `executeCycleType` is `1`** | +| `chargePower` | No | int | W | Power setting | +| `chargeLimit` | Yes | decimal | % | Battery cut-off SOC. Range `[10,100]` | + +**Constraints:** + +- Maximum **6 groups per day**, maximum **28 groups per week**. +- Charge and discharge periods **must not overlap**. +- Both lists are required even if empty — send `[]`, not `null`. + +**Returns:** `data` is `null`. Success is `code: 200`. + +**Example request** (weekday-only overnight charge, evening discharge): + +```json +{ + "sysSn": "AL70110230306xx", + "executeCycleType": 1, + "gridChargeCycle": 1, + "ctrDisCycle": 1, + "chargeTimeList": [ + {"beginTime":"01:00","endTime":"05:00","weeks":[1,2,3,4,5], + "chargePower":5000,"chargeLimit":90} + ], + "dischargeTimeList": [ + {"beginTime":"17:00","endTime":"21:00","weeks":[1,2,3,4,5], + "chargePower":5000,"chargeLimit":20} + ] +} +``` + +**Library:** + +```python +await client.setTimeChargeBySn( + sysSn, executeCycleType, chargeTimeList, dischargeTimeList, + gridChargeCycle=None, ctrDisCycle=None, +) +``` + +`gridChargeCycle` and `ctrDisCycle` are omitted from the request body entirely when left as +`None`. Returns `None`. + +**Common codes:** `6017` (not entitled), `6001` (parameter out of range — check `chargeLimit` is +`[10,100]`), `6008` (set failed — check for overlapping periods). + +--- + +## System binding + +Binding an AppID to a system is a two-step flow. It can also be done through the portal UI, which +is usually easier. + +``` +getVerificationCode(sysSn, checkCode) → emails a code to the system owner +bindSn(sysSn, code) → binds the system to your AppID +``` + +### getVerificationCode + +> According to SN get the check code according to SN + +Triggers an email containing a verification code to the **end user's registered email address** +for that SN. It does not return the code. + +- **`GET /api/getVerificationCode`** + +**Needs:** + +| Parameter | Required | Type | Description | +|:--|:--|:--|:--| +| `sysSn` | Yes | string | System S/N | +| `checkCode` | Yes | string | The system's CheckCode, from the device label or the installer | + +**Returns:** `data` is `null`. Success is `code: 200`, and the side effect is the email. + +> **Method discrepancy:** the portal describes the payload as "request parameter (Json)", which +> reads like a POST. It is **GET** with query-string parameters — a POST returns HTTP +> `405 Method Not Allowed`. This library had it wrong until it was verified against the live API. + +**Library:** `await client.getVerificationCode(sysSn, checkCode)` → `None`. + +**Common codes:** `6002` (SN not bound to any user), `6004` (wrong CheckCode), `6038` (SN unknown +to the platform). + +--- + +### bindSn + +> According to SN and check code Bind the system + +- **`POST /api/bindSn`** — JSON body + +**Needs:** + +| Field | Required | Type | Description | +|:--|:--|:--|:--| +| `sysSn` | Yes | string | System S/N | +| `code` | Yes | string | Verification code from the email triggered by `getVerificationCode` | + +**Returns:** `data` is `null`. Success is `code: 200`. + +> **Method discrepancy:** the bundled Postman collection issues this as a **GET** with query +> parameters `sysSn` and `Code`. That is wrong — it is **POST** with a JSON body, and a GET +> returns HTTP `405 Method Not Allowed`. + +**Library:** `await client.bindSn(sysSn, code)` → `None`. + +**Common codes:** `6046` (code wrong or expired — live message is "The verification code is +incorrect or expired"), `6003` (already bound — effectively a success). + +--- + +### unBindSn + +> According to SN Unbind the system + +- **`POST /api/unBindSn`** — JSON body + +**Needs:** + +| Field | Required | Type | Description | +|:--|:--|:--|:--| +| `sysSn` | Yes | string | System S/N | + +**Returns:** `data` is `null`. Success is `code: 200`. + +**Library:** `await client.unBindSn(sysSn)` → `None`. + +**Common codes:** `6005` (AppID was not bound to that SN in the first place). + +--- + +## EV charger + +All five EV charger endpoints work against systems with an AlphaESS charging pile fitted. On a +system without one, `getEvChargerConfigList` returns an empty array and the rest have no +`evchargerSn` to address. + +### getEvChargerConfigList + +> Obtain the SN of the charging pile according to the SN, and set the model + +Discovery call — gives you the `evchargerSn` that `getEvChargerStatusBySn` and +`remoteControlEvCharger` need. + +- **`GET /api/getEvChargerConfigList`** + +**Needs:** + +| Parameter | Required | Type | Description | +|:--|:--|:--|:--| +| `sysSn` | Yes | string | System S/N | + +**Returns:** `data` is an **array**. + +| Field | Type | Description | +|:--|:--|:--| +| `evchargerSn` | string | EV charger S/N | +| `evchargerModel` | string | EV charger model | + +**Example** (live, system with no charger fitted — note `code: 200` with an empty array, not an +error): + +```json +{"code":200,"msg":"Success","expMsg":null,"extra":null,"data":[]} +``` + +**Library:** `await client.getEvChargerConfigList(sysSn)` → `list[dict]`, or `None` on error. +Included in `getdata(get_ev=True)` output as the `EVData` key. + +--- + +### getEvChargerCurrentsBySn + +> Obtain the current setting of charging pile household according to SN + +- **`GET /api/getEvChargerCurrentsBySn`** + +**Needs:** + +| Parameter | Required | Type | Description | +|:--|:--|:--|:--| +| `sysSn` | Yes | string | System S/N | + +**Returns:** `data` is an **object**. + +| Field | Type | Unit | Description | +|:--|:--|:--|:--| +| `currentsetting` | decimal | A | Household current setting | + +**Example** (live): + +```json +{"code":200,"msg":"Success","expMsg":null,"extra":null,"data":{"currentsetting":32.0}} +``` + +**Library:** `await client.getEvChargerCurrentsBySn(sysSn)` → `dict`, or `None` on error. +Included in `getdata(get_ev=True)` output as the `EVCurrent` key. + +--- + +### setEvChargerCurrentsBySn + +> Set charging pile household current setting according to SN + +- **`POST /api/setEvChargerCurrentsBySn`** — JSON body + +**Needs:** + +| Field | Required | Type | Unit | Description | +|:--|:--|:--|:--|:--| +| `sysSn` | Yes | string | — | System S/N | +| `currentsetting` | Yes | decimal | A | Household current setting | + +The field name is **all lower-case** — `currentsetting`, not `currentSetting`. + +**Returns:** `data` is `null`. Success is `code: 200`. + +**Library:** `await client.setEvChargerCurrentsBySn(sysSn, currentsetting)` → `None`. + +--- + +### getEvChargerStatusBySn + +> Obtain charging pile status according to SN + charging pile SN + +- **`GET /api/getEvChargerStatusBySn`** + +**Needs:** + +| Parameter | Required | Type | Description | +|:--|:--|:--|:--| +| `sysSn` | Yes | string | System S/N | +| `evchargerSn` | Yes | string | EV charger S/N, from `getEvChargerConfigList` | + +**Returns:** `data` is an **object**. + +| Field | Type | Description | +|:--|:--|:--| +| `evchargerStatus` | int | See the state table below | + +| Value | State | Meaning | +|:--|:--|:--| +| `1` | Available | Not plugged in | +| `2` | Preparing | Plugged in, not activated | +| `3` | Charging | Charging with power output | +| `4` | SuspendedEVSE | Suspended at the charger — started, but no available power | +| `5` | SuspendedEV | Suspended at the vehicle — power available, waiting for the car to respond | +| `6` | Finishing | Charging ended (card swipe, or EMS stop control) | +| `9` | Faulted | Charger fault | + +Values `7` and `8` are not documented. The portal describes `data` as `List` but the field +table describes a single object — treat the shape defensively. + +**Library:** `await client.getEvChargerStatusBySn(sysSn, evchargerSn)` → `dict`, or `None` on +error. Included in `getdata(get_ev=True)` output as the `EVStatus` key. + +--- + +### remoteControlEvCharger + +> According to SN + charging pile SN remote control charging pile to start/stop charging + +- **`POST /api/remoteControlEvCharger`** — JSON body + +**Needs:** + +| Field | Required | Type | Description | +|:--|:--|:--|:--| +| `sysSn` | Yes | string | System S/N | +| `evchargerSn` | Yes | string | EV charger S/N | +| `controlMode` | Yes | int | `0` stop charging, `1` start charging | + +**Returns:** `data` is `null`. Success is `code: 200`. + +**Library:** `await client.remoteControlEvCharger(sysSn, evchargerSn, controlMode)` → `None`. + +--- + +## Units and conventions + +| Concept | Convention | +|:--|:--| +| Power | **Watts (W)** — every `p*` field in `getLastPowerData` and `getOneDayPowerBySn` | +| Energy | **Kilowatt-hours (kWh)** — every `e*` field in `getOneDateEnergyBySn` and `getSumDataForCustomer` | +| Nominal ratings | **Kilowatts (kW)** — `poinv`, `popv` in `getEssList` | +| SOC / percentages | **Percent (%)** — `soc`, `batHighCap`, `batUseCap`, `chargeLimit`, `usCapacity`, `cbat` | +| Current | **Amps (A)** — `currentsetting` | +| Grid power sign | `pgrid` **positive = importing**, **negative = exporting** | +| Times of day | `HH:mm`, 15-minute grid for the config endpoints | +| Dates | `yyyy-MM-dd` | +| Timestamps | `yyyy-MM-dd HH:mm:ss` in payloads; Unix **seconds** in the `timeStamp` header | +| Days of week | `1`–`7` = **Monday**–Sunday (not Sunday-first) | +| Booleans | Integers `0` / `1`, never JSON `true` / `false` | + +--- + +## Rate limits + +| Scope | Limit | +|:--|:--| +| General polling | AlphaESS advise a **minimum 10-second** interval. Exceeding it returns `6053`. | +| `updateChargeConfigInfo` | Documented as **once per 24 hours**. | +| `updateDisChargeConfigInfo` | Documented as **once per 24 hours**. | +| Signature validity | The `timeStamp` must be within **300 seconds** of server time. | + +`getdata()` accepts a `self_delay` parameter that sleeps between each underlying call, for +callers polling several systems in a loop. + +--- + +## Library coverage + +All 19 documented endpoints are wrapped in [`alphaess/alphaess.py`](../alphaess/alphaess.py). + +| Endpoint | Library method | Returns | +|:--|:--|:--| +| `getEssList` | `getESSList()` | `list[dict]` | +| `getLastPowerData` | `getLastPowerData(sysSn)` | `dict` | +| `getOneDayPowerBySn` | `getOneDayPowerBySn(sysSn, queryDate=None)` | `list[dict]` | +| `getOneDateEnergyBySn` | `getOneDateEnergyBySn(sysSn, queryDate=None)` | `dict` | +| `getSumDataForCustomer` | `getSumDataForCustomer(sysSn)` | `dict` | +| `getChargeConfigInfo` | `getChargeConfigInfo(sysSn)` | `dict` | +| `updateChargeConfigInfo` | `updateChargeConfigInfo(...)` / `setbatterycharge(...)` | `None` | +| `getDisChargeConfigInfo` | `getDisChargeConfigInfo(sysSn)` | `dict` | +| `updateDisChargeConfigInfo` | `updateDisChargeConfigInfo(...)` / `setbatterydischarge(...)` | `None` | +| `getTimeChargeBySn` | `getTimeChargeBySn(sysSn)` | `dict` | +| `setTimeChargeBySn` | `setTimeChargeBySn(...)` | `None` | +| `getVerificationCode` | `getVerificationCode(sysSn, checkCode)` | `None` | +| `bindSn` | `bindSn(sysSn, code)` | `None` | +| `unBindSn` | `unBindSn(sysSn)` | `None` | +| `getEvChargerConfigList` | `getEvChargerConfigList(sysSn)` | `list[dict]` | +| `getEvChargerCurrentsBySn` | `getEvChargerCurrentsBySn(sysSn)` | `dict` | +| `setEvChargerCurrentsBySn` | `setEvChargerCurrentsBySn(sysSn, currentsetting)` | `None` | +| `getEvChargerStatusBySn` | `getEvChargerStatusBySn(sysSn, evchargerSn)` | `dict` | +| `remoteControlEvCharger` | `remoteControlEvCharger(sysSn, evchargerSn, controlMode)` | `None` | + +**The two failure modes differ.** When the API answers with a return code, the wrapper logs it +and returns `None`. When the transport fails — connection reset, DNS, timeout, non-2xx HTTP — the +wrapper logs it and **re-raises**, so the exception reaches your code and a caller like Home +Assistant's `DataUpdateCoordinator` can retry with backoff. See +[RETURN_CODES.md](RETURN_CODES.md#handling-in-this-library) for both paths — in particular, `None` +from a write is ambiguous between "succeeded, no payload" and "failed". + +### The aggregate call + +`getdata()` walks every bound system and assembles one dict per system: + +```python +data = await client.getdata(get_power=False, get_ev=False, + self_delay=0, get_timecharge=False) +``` + +| Key | Always present | Source | +|:--|:--|:--| +| `sysSn`, `cobat`, `mbat`, `minv`, `poinv`, `popv`, `surplusCobat`, `usCapacity`, `emsStatus` | Yes | `getEssList` | +| `SumData` | Yes | `getSumDataForCustomer` | +| `OneDateEnergy` | Yes | `getOneDateEnergyBySn` (today) | +| `LastPower` | Yes | `getLastPowerData` | +| `ChargeConfig` | Yes | `getChargeConfigInfo` | +| `DisChargeConfig` | Yes | `getDisChargeConfigInfo` | +| `OneDayPower` | `get_power=True` | `getOneDayPowerBySn` (today) | +| `TimeCharge` | `get_timecharge=True` | `getTimeChargeBySn` | +| `EVData` | `get_ev=True` | `getEvChargerConfigList` | +| `EVStatus` | `get_ev=True` **and** a charger was found | `getEvChargerStatusBySn` | +| `EVCurrent` | `get_ev=True` **and** a charger was found | `getEvChargerCurrentsBySn` | +| `LocalIPData` | `ipaddress` set | Local HTTP polling, **first system only** | + +`EVStatus` and `EVCurrent` are skipped silently when `EVData` comes back empty, so check for the +keys rather than assuming them. + +`self_delay` sleeps that many seconds between each underlying call. diff --git a/docs/RETURN_CODES.md b/docs/RETURN_CODES.md new file mode 100644 index 0000000..123ebfd --- /dev/null +++ b/docs/RETURN_CODES.md @@ -0,0 +1,221 @@ +# AlphaESS Open API — Return Codes + +Complete reference for the `code` field returned by every AlphaESS Open API endpoint. + +Transcribed from the developer portal's **Development Management → Return Code Description** +page (), which paginates the table across two pages — both are +reproduced here in full. Verified against the live API at `https://openapi.alphaess.com/api`. + +See [API.md](API.md) for the endpoint reference. + +--- + +## Where the code appears + +Every endpoint returns the same envelope. `code` is the only field you should branch on: + +```json +{ + "code": 200, + "msg": "Success", + "expMsg": null, + "data": { }, + "extra": null +} +``` + +| Field | Type | Description | +|:--|:--|:--| +| `code` | int | `200` on success. Anything else is an error from the table below. | +| `msg` | string | Human-readable message. **Localised** to the developer account's language. | +| `expMsg` | string | Exception detail. Undocumented in the portal, always present, normally `null`. | +| `data` | object / array / null | Payload. Always `null` on error. | +| `extra` | any | Undocumented in the portal, always present, observed as `null`. | + +> **Never match on `msg`.** It is localised to whatever language the developer account is set to, +> and it is not consistently translated. On an English-language account a live call to +> `updateChargeConfigInfo` returned `6005` with the message `此appId未绑定该SN`, while the very +> same code from `unBindSn` returned `This appId is not bound to the SN`. Branch on `code`. + +--- + +## Success + +| Code | Description | +|:--|:--| +| `200` | Success | + +Note that `code: 200` does not guarantee a payload. Several endpoints return `code: 200` with +`data: null` (most writes) or `data: []` (e.g. `getEvChargerConfigList` on a system with no EV +charger). Check `data` separately from `code`. + +--- + +## Return code table — page 1 + +| Code | Description | +|:--|:--| +| `6001` | Parameter error | +| `6002` | The SN is not bound to the user | +| `6003` | You have bound this SN | +| `6004` | CheckCode error | +| `6005` | This appId is not bound to the SN | +| `6006` | Timestamp error | +| `6007` | Sign verification error | +| `6008` | Set failed | +| `6009` | Whitelist verification failed | +| `6010` | Sign is empty | + +## Return code table — page 2 + +| Code | Description | +|:--|:--| +| `6011` | timestamp is empty | +| `6012` | AppId is empty | +| `6016` | Data does not exist or has been deleted | +| `6026` | internal error | +| `6029` | operation failed | +| `6038` | system sn does not exist | +| `6042` | system offline | +| `6046` | Verification code error | +| `6053` | The request was too fast, please try again later | + +The gaps in the sequence (`6013`–`6015`, `6017`–`6025`, `6027`–`6028`, `6030`–`6037`, `6039`–`6041`, +`6043`–`6045`, `6047`–`6052`) are codes the platform uses internally but does not publish. At least +one of them is reachable from the public API — see [Undocumented codes](#undocumented-codes). + +--- + +## Codes by cause + +### Authentication and signing + +These indicate a problem with your `appId` / `timeStamp` / `sign` headers. They are permanent +until you fix the request — **do not retry**. + +| Code | Description | Cause and fix | +|:--|:--|:--| +| `6006` | Timestamp error | Your `timeStamp` deviates from server time by more than 300 seconds. Sync the system clock. Must be **seconds** (10 digits), not milliseconds. | +| `6007` | Sign verification error | `sign` does not match. It is `SHA512(appId + appSecret + timeStamp)` as lower-case hex, using the **same** timestamp you sent in the header. | +| `6009` | Whitelist verification failed | The developer account has the IP allow-list enabled and your source address is not on it. Portal → *Development Management* → *Developer Information* → *IP White List*. | +| `6010` | Sign is empty | `sign` header missing or blank. | +| `6011` | timestamp is empty | `timeStamp` header missing or blank. | +| `6012` | AppId is empty | `appId` header missing or blank. | + +### System binding and ownership + +| Code | Description | Cause and fix | +|:--|:--|:--| +| `6002` | The SN is not bound to the user | The SN is not registered to the end-user account. Returned by `getVerificationCode` for an unknown SN. | +| `6003` | You have bound this SN | `bindSn` called for an SN already bound to this AppID. Treat as already-succeeded. | +| `6004` | CheckCode error | The `checkCode` passed to `getVerificationCode` is wrong. It comes from the device label or the installer. | +| `6005` | This appId is not bound to the SN | The most common error in practice. Your AppID has not been bound to this SN — run the [binding flow](API.md#system-binding), or add the system in the portal. | +| `6038` | system sn does not exist | The SN is not known to the platform at all (as opposed to `6005`, known but not bound to you). Check for typos. | +| `6046` | Verification code error | The code passed to `bindSn` is wrong or has expired. Live message is more specific than the portal's: **"The verification code is incorrect or expired"**. Request a fresh code with `getVerificationCode`. | + +### Request content + +| Code | Description | Cause and fix | +|:--|:--|:--| +| `6001` | Parameter error | A required parameter is missing, or a value is out of range — e.g. `chargeLimit` outside `[10,100]`, or `executeCycleType` outside `[0,1]`. | +| `6016` | Data does not exist or has been deleted | The referenced record is gone. | + +### Device and operation + +| Code | Description | Cause and fix | +|:--|:--|:--| +| `6008` | Set failed | The write was rejected by the device. For the charge/discharge config endpoints, check the times are on the 15-minute grid and that periods do not overlap. | +| `6029` | operation failed | Generic operation failure. | +| `6042` | system offline | The inverter is not currently reachable by the cloud. **Transient — safe to retry later.** Expect this routinely; a system that drops off overnight will return `6042` rather than stale data. | +| `6026` | internal error | Server-side fault. Transient; retry with backoff. | + +### Rate limiting + +| Code | Description | Cause and fix | +|:--|:--|:--| +| `6053` | The request was too fast, please try again later | You are polling too aggressively. Back off. AlphaESS advise a **minimum 10-second** interval between calls. `updateChargeConfigInfo` and `updateDisChargeConfigInfo` are separately documented as writable **once per 24 hours**. | + +--- + +## Undocumented codes + +Observed in production but absent from the portal's Return Code Description page. + +| Code | Message | Cause | +|:--|:--|:--| +| `6017` | `No operation permissions` | Your AppID is bound to the SN, but the account tier or the hardware is not entitled to this endpoint. Confirmed on `getTimeChargeBySn` against two bound SMILE5 systems; the same call with an unbound SN returned `6005` instead, proving the binding check passes first and the entitlement check fails second. Handle it as "feature unavailable for this system", not as an error to retry. | + +--- + +## Transport-level errors + +These are **not** API return codes. They come from the HTTP layer, have no `code` field, and +your parser will fall over if it assumes the standard envelope. + +### Wrong HTTP verb — `405` + +Every endpoint accepts exactly one method. Using the other one returns a Spring-style error body: + +```json +{"timestamp":"2026-08-04T10:43:44.424+00:00","status":405, + "error":"Method Not Allowed","path":"/api/getVerificationCode"} +``` + +This matters because the portal documentation is misleading in two places — it describes +`getVerificationCode`'s parameters as "request parameter (Json)" when the endpoint is **GET**, +and the bundled Postman collection issues `bindSn` as a GET when it is **POST**. See +[API.md](API.md#endpoint-summary) for the verified method of every endpoint. + +### Other + +Standard HTTP failures (timeouts, `5xx`, TLS errors) surface as transport exceptions, not as +return codes. + +--- + +## Handling in this library + +The two failure modes behave differently, and the difference matters when you are deciding +whether to retry. + +### API-level errors → the wrapper returns `None` + +`api_get()` and `api_post()` in [`alphaess/alphaess.py`](../alphaess/alphaess.py) treat any +non-`Success` response as a failure: the full JSON response is written to the logger at `ERROR` +level and the wrapper returns `None`. + +``` +LOG ERROR: Unexpected json_response : {'code': 6017, 'msg': 'No operation permissions', +'expMsg': None, 'data': None, 'extra': None} when calling +https://openapi.alphaess.com/api/getTimeChargeBySn?sysSn=AL70110230306xx +``` + +Two consequences worth knowing: + +- **The return code is not surfaced to the caller.** Wrappers return `None` on failure, so + `6042 system offline` (retry later), `6005 not bound` (fix your config) and `6017 no + permission` (feature unavailable) are indistinguishable from the return value alone. Enable + logging on the `alphaess.alphaess` logger to see which one you got. +- **`None` from a write is ambiguous.** Most writes return `code: 200` with `data: null` on + success, and the wrappers also return `None` on failure. To confirm a write landed, either + watch the log or read the value back with the corresponding `get` endpoint. + +### Transport-level errors → the exception propagates + +Connection resets, DNS failures, timeouts and non-2xx HTTP statuses (`raise_for_status` is set, +so a `405` or a `5xx` counts) are **not** collapsed into `None`. Every wrapper logs the error and +then re-raises, so the exception reaches your code: + +```python +try: + data = await client.getLastPowerData(sysSn) +except aiohttp.ClientError: + ... # transport failure — back off and retry +if data is None: + ... # API returned a return code — check the log for which +``` + +This matters for consumers like `homeassistant-alphaESS`, whose coordinator relies on the +exception surfacing so Home Assistant can mark the update failed and retry with backoff. A +swallowed exception leaves entities stuck `unavailable` until the config entry is reloaded by +hand. diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..aa76874 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +asyncio_mode = auto +testpaths = tests +# The homeassistant pytest plugin (from pytest_homeassistant_custom_component, +# installed in the shared integration venv) imports fcntl at collection time and +# crashes on Windows. This library has no HA dependency, so disable that plugin. +addopts = -p no:homeassistant diff --git a/setup.py b/setup.py index 4bdd182..848d4c1 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name="alphaessopenapi", - version="0.0.19", + version="0.0.20", author="Charles Gillanders", author_email="charles@charlesgillanders.com", description="A python library to retrieve energy statistics from your Alpha ESS inverter by polling the Official Alpha ESS Open API.", diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py new file mode 100644 index 0000000..17c71dd --- /dev/null +++ b/tests/test_error_handling.py @@ -0,0 +1,148 @@ +"""Tests for the two failure modes and the getVerificationCode HTTP verb. + +The aiohttp session is fully mocked with unittest.mock -- no network access. +""" +import logging +from unittest.mock import AsyncMock, MagicMock + +import aiohttp +import pytest + +from alphaess.alphaess import alphaess, UNDOCUMENTED_RETURN_CODES + +from .test_time_charge import _GetContextManager, _make_response + + +def _client_raising(exc): + """Client whose session raises ``exc`` for both get and post.""" + session = MagicMock() + session.get = MagicMock(side_effect=exc) + session.post = AsyncMock(side_effect=exc) + return alphaess("appid", "appsecret", session=session) + + +# -------------------------------------------------------------------------- +# getVerificationCode is GET, not POST +# +# The endpoint returns HTTP 405 for POST, so the previous implementation could +# never have worked. Guard the verb and the query string. +# -------------------------------------------------------------------------- + +async def test_get_verification_code_uses_get_with_query_params(): + session = MagicMock() + session.get = MagicMock( + return_value=_GetContextManager(_make_response({"code": 200, "msg": "Success", "data": None})) + ) + session.post = AsyncMock() + client = alphaess("appid", "appsecret", session=session) + + await client.getVerificationCode("ALPHA123", "CHECK456") + + assert session.post.await_count == 0, "getVerificationCode must not POST" + assert session.get.call_args.args[0] == ( + "https://openapi.alphaess.com/api/getVerificationCode" + "?sysSn=ALPHA123&checkCode=CHECK456" + ) + + +# -------------------------------------------------------------------------- +# Transport failures propagate (fixes #26) +# +# A swallowed exception is indistinguishable from an empty result, which left +# homeassistant-alphaESS entities unavailable after the network recovered. +# -------------------------------------------------------------------------- + +@pytest.mark.parametrize("method,args", [ + ("getESSList", ()), + ("getLastPowerData", ("SN",)), + ("getOneDayPowerBySn", ("SN", "2026-01-01")), + ("getOneDateEnergyBySn", ("SN", "2026-01-01")), + ("getSumDataForCustomer", ("SN",)), + ("getChargeConfigInfo", ("SN",)), + ("getDisChargeConfigInfo", ("SN",)), + ("getTimeChargeBySn", ("SN",)), + ("getEvChargerConfigList", ("SN",)), + ("getEvChargerCurrentsBySn", ("SN",)), + ("getEvChargerStatusBySn", ("SN", "EV")), + ("getVerificationCode", ("SN", "CHECK")), +]) +async def test_get_wrappers_reraise_transport_errors(method, args): + client = _client_raising(aiohttp.ClientConnectionError("boom")) + with pytest.raises(aiohttp.ClientConnectionError): + await getattr(client, method)(*args) + + +@pytest.mark.parametrize("method,args", [ + ("setTimeChargeBySn", ("SN", 0, [], [])), + ("setEvChargerCurrentsBySn", ("SN", 16)), + ("remoteControlEvCharger", ("SN", "EV", 1)), + ("bindSn", ("SN", "CODE")), + ("unBindSn", ("SN",)), + ("updateChargeConfigInfo", ("SN", 100, 0, "00:00", "00:00", "00:00", "00:00")), + ("updateDisChargeConfigInfo", ("SN", 10, 0, "00:00", "00:00", "00:00", "00:00")), +]) +async def test_post_wrappers_reraise_transport_errors(method, args): + client = _client_raising(aiohttp.ClientConnectionError("boom")) + with pytest.raises(aiohttp.ClientConnectionError): + await getattr(client, method)(*args) + + +# -------------------------------------------------------------------------- +# API-level errors do NOT raise -- they return None +# +# The two failure modes must stay distinguishable: a return code means "the +# service answered", a transport error means "retry with backoff". +# -------------------------------------------------------------------------- + +async def test_api_level_error_returns_none_rather_than_raising(): + session = MagicMock() + session.get = MagicMock( + return_value=_GetContextManager(_make_response({"code": 6042, "msg": "system offline", "data": None})) + ) + client = alphaess("appid", "appsecret", session=session) + + assert await client.getLastPowerData("SN") is None + + +async def test_undocumented_return_code_is_described_in_the_log(caplog): + session = MagicMock() + session.get = MagicMock( + return_value=_GetContextManager( + _make_response({"code": 6017, "msg": "No operation permissions", "data": None}) + ) + ) + client = alphaess("appid", "appsecret", session=session) + + with caplog.at_level(logging.ERROR, logger="alphaess.alphaess"): + result = await client.getTimeChargeBySn("SN") + + assert result is None + assert UNDOCUMENTED_RETURN_CODES[6017] in caplog.text + + +# -------------------------------------------------------------------------- +# getdata gates the periodic schedule behind an opt-in flag +# -------------------------------------------------------------------------- + +async def test_getdata_omits_timecharge_by_default(): + client = alphaess("appid", "appsecret", session=MagicMock()) + client.getESSList = AsyncMock(return_value=[{"sysSn": "SN"}]) + for name in ("getSumDataForCustomer", "getOneDateEnergyBySn", "getLastPowerData", + "getChargeConfigInfo", "getDisChargeConfigInfo"): + setattr(client, name, AsyncMock(return_value={})) + client.getTimeChargeBySn = AsyncMock(return_value={"executeCycleType": 0}) + + default = await client.getdata() + assert "TimeCharge" not in default[0] + assert client.getTimeChargeBySn.await_count == 0 + + opted_in = await client.getdata(get_timecharge=True) + assert opted_in[0]["TimeCharge"] == {"executeCycleType": 0} + + +async def test_getdata_keeps_self_delay_positional(): + """get_timecharge is appended after self_delay so existing positional + callers -- getdata(True, True, 5) -- keep working.""" + import inspect + params = list(inspect.signature(alphaess.getdata).parameters) + assert params == ["self", "get_power", "get_ev", "self_delay", "get_timecharge"] diff --git a/tests/test_time_charge.py b/tests/test_time_charge.py new file mode 100644 index 0000000..2f9964a --- /dev/null +++ b/tests/test_time_charge.py @@ -0,0 +1,211 @@ +"""Tests for the periodic charge/discharge endpoints and the shared +api_get / api_post success-detection helpers. + +The aiohttp session is fully mocked with unittest.mock -- no network access. +""" +import logging +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from alphaess.alphaess import alphaess, RETURN_CODES + + +class _GetContextManager: + """Async context manager returned by a mocked session.get() call. + + api_get uses ``async with self.session.get(...) as response:`` so the + return value of ``session.get`` must support the async-context-manager + protocol (it is NOT awaited). + """ + + def __init__(self, response): + self._response = response + + async def __aenter__(self): + return self._response + + async def __aexit__(self, *args): + return False + + +def _make_response(json_body, status=200): + """Build a mock aiohttp response yielding ``json_body``.""" + response = MagicMock() + response.status = status + response.json = AsyncMock(return_value=json_body) + response.raise_for_status = MagicMock() + return response + + +def _make_client(get_body=None, post_body=None): + """Create an alphaess client backed by a mocked aiohttp session. + + ``session.get`` returns an async context manager (matching api_get); + ``session.post`` is an AsyncMock (api_post awaits it directly). + """ + session = MagicMock() + + if get_body is not None: + session.get = MagicMock(return_value=_GetContextManager(_make_response(get_body))) + if post_body is not None: + session.post = AsyncMock(return_value=_make_response(post_body)) + + client = alphaess("appid", "appsecret", session=session) + return client, session + + +# -------------------------------------------------------------------------- +# getTimeChargeBySn +# -------------------------------------------------------------------------- + +async def test_get_time_charge_by_sn_happy_path(): + data = { + "sysSn": "ALPHA123", + "executeCycleType": 1, + "gridChargeCycle": 1, + "ctrDisCycle": 0, + "chargeTimeList": [ + {"beginTime": "01:00", "endTime": "05:00", "weeks": [1, 2, 3], + "chargePower": 3000, "chargeLimit": 90}, + ], + "dischargeTimeList": [ + {"beginTime": "18:00", "endTime": "21:00", "weeks": [1, 2, 3], + "chargeLimit": 20}, + ], + } + client, session = _make_client(get_body={"code": 200, "info": "Success", "data": data}) + + result = await client.getTimeChargeBySn("ALPHA123") + + assert result == data + # Correct URL with the sysSn query parameter. + called_url = session.get.call_args.args[0] + assert called_url == "https://openapi.alphaess.com/api/getTimeChargeBySn?sysSn=ALPHA123" + + +# -------------------------------------------------------------------------- +# setTimeChargeBySn +# -------------------------------------------------------------------------- + +async def test_set_time_charge_by_sn_happy_path(): + charge_list = [{"beginTime": "01:00", "endTime": "05:00", "chargeLimit": 90}] + discharge_list = [{"beginTime": "18:00", "endTime": "21:00", "chargeLimit": 20}] + + # data non-None -> api_post returns the data object. + client, session = _make_client( + post_body={"code": 200, "info": "Success", "data": {"result": "ok"}} + ) + + result = await client.setTimeChargeBySn( + "ALPHA123", 0, charge_list, discharge_list + ) + + assert result == {"result": "ok"} + called_url = session.post.call_args.args[0] + assert called_url == "https://openapi.alphaess.com/api/setTimeChargeBySn" + + +async def test_set_time_charge_by_sn_omits_optional_params(): + charge_list = [{"beginTime": "01:00", "endTime": "05:00", "chargeLimit": 90}] + discharge_list = [{"beginTime": "18:00", "endTime": "21:00", "chargeLimit": 20}] + + client, session = _make_client(post_body={"code": 200, "info": "Success", "data": None}) + + await client.setTimeChargeBySn("ALPHA123", 0, charge_list, discharge_list) + + body = session.post.call_args.kwargs["json"] + assert body == { + "sysSn": "ALPHA123", + "executeCycleType": 0, + "chargeTimeList": charge_list, + "dischargeTimeList": discharge_list, + } + # Optional params must be absent entirely when None. + assert "gridChargeCycle" not in body + assert "ctrDisCycle" not in body + + +async def test_set_time_charge_by_sn_includes_optional_params(): + charge_list = [{"beginTime": "01:00", "endTime": "05:00", "weeks": [1, 2], "chargeLimit": 90}] + discharge_list = [{"beginTime": "18:00", "endTime": "21:00", "weeks": [1, 2], "chargeLimit": 20}] + + client, session = _make_client(post_body={"code": 200, "info": "Success", "data": None}) + + await client.setTimeChargeBySn( + "ALPHA123", 1, charge_list, discharge_list, + gridChargeCycle=1, ctrDisCycle=0, + ) + + body = session.post.call_args.kwargs["json"] + assert body == { + "sysSn": "ALPHA123", + "executeCycleType": 1, + "chargeTimeList": charge_list, + "dischargeTimeList": discharge_list, + "gridChargeCycle": 1, + "ctrDisCycle": 0, + } + + +# -------------------------------------------------------------------------- +# api_get success detection +# -------------------------------------------------------------------------- + +async def test_api_get_success_via_msg(): + client, _ = _make_client(get_body={"msg": "Success", "data": {"x": 1}}) + result = await client.api_get("https://openapi.alphaess.com/api/getEssList") + assert result == {"x": 1} + + +async def test_api_get_success_via_info(): + client, _ = _make_client(get_body={"info": "Success", "data": {"y": 2}}) + result = await client.api_get("https://openapi.alphaess.com/api/getTimeChargeBySn") + assert result == {"y": 2} + + +async def test_api_get_success_via_code_200(): + # No msg and no info -- success detected purely from code == 200. + client, _ = _make_client(get_body={"code": 200, "data": {"z": 3}}) + result = await client.api_get("https://openapi.alphaess.com/api/getEssList") + assert result == {"z": 3} + + +async def test_api_get_failure_returns_none_and_logs_return_code(caplog): + client, _ = _make_client( + get_body={"code": 6053, "info": "fail", "data": None} + ) + + with caplog.at_level(logging.ERROR, logger="alphaess.alphaess"): + result = await client.api_get("https://openapi.alphaess.com/api/getTimeChargeBySn") + + assert result is None + assert RETURN_CODES[6053] in caplog.text + assert RETURN_CODES[6053] == "The request was too fast, please try again later" + + +# -------------------------------------------------------------------------- +# api_post success detection +# -------------------------------------------------------------------------- + +async def test_api_post_success_via_info(): + client, _ = _make_client(post_body={"code": 200, "info": "Success", "data": None}) + result = await client.api_post( + "https://openapi.alphaess.com/api/setTimeChargeBySn", + {"sysSn": "ALPHA123"}, + ) + # data is None on success -> api_post returns None (prior shape preserved). + assert result is None + + +# -------------------------------------------------------------------------- +# RETURN_CODES completeness +# -------------------------------------------------------------------------- + +def test_return_codes_has_all_19_vendor_codes(): + expected = { + 6001, 6002, 6003, 6004, 6005, 6006, 6007, 6008, 6009, 6010, + 6011, 6012, 6016, 6026, 6029, 6038, 6042, 6046, 6053, + } + assert set(RETURN_CODES) == expected + assert len(RETURN_CODES) == 19