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