From 0b718ce70e1d37ad32cba14cb90a387493b7a41f Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sat, 30 Aug 2025 19:19:04 +0100 Subject: [PATCH 01/16] Fixes --- apps/predbat/components.py | 12 + apps/predbat/fox.py | 472 +++++++++++++++++++++++++++++++++++++ apps/predbat/predbat.py | 2 +- 3 files changed, 485 insertions(+), 1 deletion(-) create mode 100644 apps/predbat/fox.py diff --git a/apps/predbat/components.py b/apps/predbat/components.py index 5de8618ee..73fa22d26 100644 --- a/apps/predbat/components.py +++ b/apps/predbat/components.py @@ -15,6 +15,7 @@ from web import WebInterface from ha import HAInterface from db_manager import DatabaseManager +from fox import FoxAPI import os COMPONENT_LIST = { @@ -88,6 +89,17 @@ }, }, }, + "fox": { + "class": FoxAPI, + "name": "Fox API", + "event_filter": "predbat_fox_", + "args": { + "key": { + "required": True, + "config": "fox_key", + }, + }, + } } diff --git a/apps/predbat/fox.py b/apps/predbat/fox.py new file mode 100644 index 000000000..62172c911 --- /dev/null +++ b/apps/predbat/fox.py @@ -0,0 +1,472 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2024 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# Fox API Library +# ----------------------------------------------------------------------------- + +import asyncio +from datetime import datetime +from datetime import timedelta +import traceback +import time +import hashlib +import requests +import json +import sys +import argparse +import random + +# Define TIME_FORMAT_HA locally to avoid dependency issues +TIME_FORMAT_HA = "%Y-%m-%dT%H:%M:%S%z" + +BASE_TIME = datetime.strptime("00:00", "%H:%M") +OPTIONS_TIME = [((BASE_TIME + timedelta(seconds=minute * 60)).strftime("%H:%M")) for minute in range(0, 24 * 60, 1)] + +FOX_DOMAIN = "https://www.foxesscloud.com" +FOX_LANG = 'en' +TIMEOUT = 60 + +# Dummy attribute table for testing +fox_attribute_table = { + "mode": {} +} + +class FoxAPI: + """Fox API client.""" + + def __init__(self, key, base): + self.base = base + self.log = base.log + self.key = key + self.api_started = False + self.stop_api = False + self.failures_total = 0 + self.device_list = [] + self.device_detail = {} + self.device_battery_soc_settings = {} + self.device_power_generation = {} + self.available_variables = {} + self.device_values = {} + + def wait_api_started(self): + """ + Wait for the API to start + """ + self.log("Fox API: Waiting for API to start") + count = 0 + while not self.api_started and count < 240: + time.sleep(1) + count += 1 + if not self.api_started: + self.log("Warn: Fox API: Failed to start") + return False + return True + + def is_alive(self): + """ + Check if the API is alive + """ + return self.api_started and self.device_list + + async def start(self): + """ + Main run loop + """ + + first = True + count_seconds = 0 + while not self.stop_api: + try: + if first or (count_seconds % (30*60) == 0): + await self.get_device_list() + if first: + self.log("Fox API: Found {} devices".format(len(self.device_list))) + for device in self.device_list: + sn = device.get('deviceSN', None) + if sn: + await self.get_device_detail(sn) + await self.get_battery_soc_settings(sn) + + if first or (count_seconds % (5*60)) == 0: + for device in self.device_list: + sn = device.get('deviceSN', None) + if sn: + await self.get_device_power_generation(sn) + await self.get_device_history(sn) + await self.publish_data() + + if not self.api_started: + print("Fox API: Started") + self.api_started = True + + first = False + + except Exception as e: + self.log("Error: Fox API: {}".format(e)) + self.log("Error: " + traceback.format_exc()) + + await asyncio.sleep(1) + count_seconds += 1 + + await self.client.close() + print("Fox API: Stopped") + + async def stop(self): + self.stop_api = True + + async def get_available_variables(self): + """ + Get available variables for the device + + { + 'todayYield': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'Today’s power generation', 'Energy-storage inverter': True}, + 'pvPower': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'PVPower', 'Energy-storage inverter': True}, + 'pv1Volt': {'unit': 'V', 'Grid-tied inverter': True, 'name': 'PV1Volt', 'Energy-storage inverter': True}, + 'pv1Current': {'unit': 'A', 'Grid-tied inverter': True, 'name': 'PV1Current', 'Energy-storage inverter': True}, + 'pv1Power': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'PV1Power', 'Energy-storage inverter': True}, + 'pv2Volt': {'unit': 'V', 'Grid-tied inverter': True, 'name': 'PV2Volt', 'Energy-storage inverter': True}, + 'pv2Current': {'unit': 'A', 'Grid-tied inverter': True, 'name': 'PV2Current', 'Energy-storage inverter': True}, + 'pv2Power': {'unit': 'kW', 'name': 'PV2Power'}, + .. + 'epsPower': {'unit': 'kW', 'Grid-tied inverter': False, 'name': 'EPSPower', 'Energy-storage inverter': True}, + 'epsCurrentR': {'unit': 'A', 'Grid-tied inverter': False, 'name': 'EPS-RCurrent', 'Energy-storage inverter': True}, + 'epsVoltR': {'unit': 'V', 'Grid-tied inverter': False, 'name': 'EPS-RVolt', 'Energy-storage inverter': True}, + 'epsPowerR': {'unit': 'kW', 'Grid-tied inverter': False, 'name': 'EPS-RPower', 'Energy-storage inverter': True}, + 'epsCurrentS': {'unit': 'A', 'Grid-tied inverter': False, 'name': 'EPS-SCurrent', 'Energy-storage inverter': True}, + 'epsVoltS': {'unit': 'V', 'Grid-tied inverter': False, 'name': 'EPS-SVolt', 'Energy-storage inverter': True}, + 'epsPowerS': {'unit': 'kW', 'Grid-tied inverter': False, 'name': 'EPS-SPower', 'Energy-storage inverter': True}, + 'epsCurrentT': {'unit': 'A', 'Grid-tied inverter': False, 'name': 'EPS-TCurrent', 'Energy-storage inverter': True}, + 'epsVoltT': {'unit': 'V', 'Grid-tied inverter': False, 'name': 'EPS-TVolt', 'Energy-storage inverter': True}, + 'epsPowerT': {'unit': 'kW', 'Grid-tied inverter': False, 'name': 'EPS-TPower', 'Energy-storage inverter': True}, + 'RCurrent': {'unit': 'A', 'Grid-tied inverter': True, 'name': 'RCurrent', 'Energy-storage inverter': True}, + 'RVolt': {'unit': 'V', 'Grid-tied inverter': True, 'name': 'RVolt', 'Energy-storage inverter': True}, + 'RFreq': {'unit': 'Hz', 'Grid-tied inverter': True, 'name': 'RFreq', 'Energy-storage inverter': True}, + 'RPower': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'RPower', 'Energy-storage inverter': True}, + 'SCurrent': {'unit': 'A', 'Grid-tied inverter': True, 'name': 'SCurrent', 'Energy-storage inverter': True}, + 'SVolt': {'unit': 'V', 'Grid-tied inverter': True, 'name': 'SVolt', 'Energy-storage inverter': True}, + 'SFreq': {'unit': 'Hz', 'Grid-tied inverter': True, 'name': 'SFreq', 'Energy-storage inverter': True}, + 'SPower': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'SPower', 'Energy-storage inverter': True}, + 'TCurrent': {'unit': 'A', 'Grid-tied inverter': True, 'name': 'TCurrent', 'Energy-storage inverter': True}, + 'TVolt': {'unit': 'V', 'Grid-tied inverter': True, 'name': 'TVolt', 'Energy-storage inverter': True}, + 'TFreq': {'unit': 'Hz', 'Grid-tied inverter': True, 'name': 'TFreq', 'Energy-storage inverter': True}, + 'TPower': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'TPower', 'Energy-storage inverter': True}, + 'ambientTemperation': {'unit': '℃', 'Grid-tied inverter': True, 'name': 'AmbientTemperature', 'Energy-storage inverter': True}, + 'boostTemperation': {'unit': '℃', 'Grid-tied inverter': True, 'name': 'BoostTemperature', 'Energy-storage inverter': True}, + 'invTemperation': {'unit': '℃', 'Grid-tied inverter': True, 'name': 'InvTemperation', 'Energy-storage inverter': True}, + 'chargeTemperature': {'unit': '℃', 'Grid-tied inverter': True, 'name': 'ChargeTemperature', 'Energy-storage inverter': True}, + 'batTemperature': {'unit': '℃', 'Grid-tied inverter': False, 'name': 'batTemperature', 'Energy-storage inverter': True}, + 'dspTemperature': {'unit': '℃', 'Grid-tied inverter': True, 'name': 'DSPTemperature', 'Energy-storage inverter': True}, + 'loadsPower': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'Load Power', 'Energy-storage inverter': True}, + 'loadsPowerR': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'LoadsRPower', 'Energy-storage inverter': True}, + 'loadsPowerS': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'LoadsSPower', 'Energy-storage inverter': True}, + 'loadsPowerT': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'LoadsTPower', 'Energy-storage inverter': True}, + 'generationPower': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'Output Power', 'Energy-storage inverter': True}, + 'feedinPower': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'Feed-in Power', 'Energy-storage inverter': True}, + 'gridConsumptionPower': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'GridConsumption Power', 'Energy-storage inverter': True}, + 'invBatVolt': {'unit': 'V', 'Grid-tied inverter': True, 'name': 'InvBatVolt', 'Energy-storage inverter': True}, + 'invBatCurrent': {'note': 'Positive Discharge, Negative Charge', 'unit': 'A', 'Grid-tied inverter': False, 'name': 'InvBatCurrent', 'Energy-storage inverter': True}, + 'invBatPower': {'note': 'Positive Discharge, Negative Charge', 'unit': 'kW', 'Grid-tied inverter': False, 'name': 'invBatPower', 'Energy-storage inverter': True}, + 'batChargePower': {'unit': 'kW', 'Grid-tied inverter': False, 'name': 'Charge Power', 'Energy-storage inverter': True}, + 'batDischargePower': {'unit': 'kW', 'Grid-tied inverter': False, 'name': 'Discharge Power', 'Energy-storage inverter': True}, + 'batVolt': {'unit': 'V', 'Grid-tied inverter': False, 'name': 'BatVolt', 'Energy-storage inverter': True}, + 'batCurrent': {'unit': 'A', 'Grid-tied inverter': False, 'name': 'BatCurrent', 'Energy-storage inverter': True}, + 'meterPower': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'MeterPower', 'Energy-storage inverter': True}, + 'meterPower2': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'Meter2Power', 'Energy-storage inverter': True}, + 'meterPowerR': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'MeterRPower', 'Energy-storage inverter': True}, + 'meterPowerS': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'MeterSPower', 'Energy-storage inverter': True}, + 'meterPowerT': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'MeterTPower', 'Energy-storage inverter': True}, + 'SoC': {'unit': '%', 'Grid-tied inverter': False, 'name': 'SoC', 'Energy-storage inverter': True}, + 'ReactivePower': {'unit': 'kVar', 'Grid-tied inverter': True, 'name': 'ReactivePower', 'Energy-storage inverter': True}, + 'PowerFactor': {'Grid-tied inverter': True, 'name': 'PowerFactor', 'Energy-storage inverter': True}, + 'generation': {'unit': 'kWh', 'Grid-tied inverter': True, 'name': 'Cumulative power generation', 'Energy-storage inverter': True}, + 'ResidualEnergy': {'unit': '0.01kWh', 'Grid-tied inverter': False, 'name': 'Battery Residual Energy', 'Energy-storage inverter': True}, + 'runningState': {'Grid-tied inverter': True, 'name': 'Running State', 'Energy-storage inverter': True, 'enum': {'165': 'fault', '166': 'permanent-fault', '167': 'standby', '168': 'upgrading', '169': 'fct', '170': 'illegal', '160': 'self-test', '161': 'waiting', '162': 'checking', '163': 'on-grid', '164': 'off-grid'}}, + 'batStatus': {'Grid-tied inverter': False, 'name': 'Battery Status', 'Energy-storage inverter': True}, + 'batStatusV2': {'Grid-tied inverter': False, 'name': 'Battery Status Name', 'Energy-storage inverter': True}, + 'currentFault': {'Grid-tied inverter': True, 'name': 'The current error code is reported', 'Energy-storage inverter': True}, + 'currentFaultCount': {'Grid-tied inverter': True, 'name': 'The number of errors', 'Energy-storage inverter': True}, + 'energyThroughput': {'unit': 'Wh', 'Grid-tied inverter': False, 'name': 'Battery throughput', 'Energy-storage inverter': True}, + 'SOH': {'unit': '%', 'Grid-tied inverter': False, 'name': 'SOH', 'Energy-storage inverter': True}, + 'gridConsumption': {'unit': 'kWh', 'Grid-tied inverter': True, 'name': 'Total grid electricity consumption', 'Energy-storage inverter': True}, + 'loads': {'unit': 'kWh', 'Grid-tied inverter': True, 'name': 'Load power consumption', 'Energy-storage inverter': True}, + 'feedin': {'unit': 'kWh', 'Grid-tied inverter': True, 'name': 'The total energy of the feeder', 'Energy-storage inverter': True}, + 'chargeEnergyToTal': {'unit': 'kWh', 'Grid-tied inverter': False, 'name': 'Total charge energy', 'Energy-storage inverter': True}, + 'dischargeEnergyToTal': {'unit': 'kWh', 'Grid-tied inverter': False, 'name': 'Total discharge energy', 'Energy-storage inverter': True} + + """ + GET_AVAILABLE_VARIABLES = "/op/v0/device/variable/get" + result = await self.request_get(GET_AVAILABLE_VARIABLES) + available_data = {} + if result and isinstance(result, list): + for variable_item in result: + for variable_id in variable_item: + variable = variable_item.get(variable_id, {}) + name = variable.get('name', {}) + name = name.get(FOX_LANG, '') + variable['name'] = name + available_data[variable_id] = variable + self.available_variables = available_data + + async def get_device_history(self, deviceSN): + """ + Get device history + """ + GET_DEVICE_HISTORY = "/op/v0/device/history/query" + timestamp = round(time.time() * 1000) + query = {'sn': deviceSN, 'begin': timestamp - 1000*60*60*1, 'end': timestamp} + result = await self.request_get(GET_DEVICE_HISTORY, post=True, datain=query) + if result and isinstance(result, list): + for item in result: + if 'datas' in item: + datas = item['datas'] + for data_item in datas: + unit = data_item.get('unit', '') + name = data_item.get('name', '') + variable = data_item.get('variable', '') + history = data_item.get('data', []) + point = history[-1] if history else {} + timestamp = point.get('time', "") + value = point.get('value', None) + if timestamp and variable and value is not None: + self.device_values[variable] = {'timestamp': timestamp, 'value': value, 'unit': unit, 'name': name} + + async def get_device_detail(self, deviceSN): + """ + Get device information + + { + 'deviceType': 'KH8', + 'masterVersion': '1.34', + 'afciVersion': '', + 'hasPV': True, + 'deviceSN': + '60KE8020479C034', + 'slaveVersion': '1.01', + 'capacity': 8, + 'hasBattery': True, + 'function': {'scheduler': True}, + + 'hardwareVersion': '--', + 'managerVersion': '1.28', + 'stationName': '2 Dona Fold', + 'moduleSN': '609W6EUF46MB519', + 'batteryList': + [{'batterySN': '60EP01104APP050', 'model': 'EP11', 'type': 'bcu', 'version': '1.005'}, + {'batterySN': '60EP01104APP050', 'model': 'EP11', 'type': 'bmu', 'version': '1.05', 'capacity': 10360}, + {'batterySN': '60EP01104APP050', 'model': 'EP11', 'type': 'ivu', 'version': '0.00'}], + 'productType': 'KH', + 'stationID': '2958ff16-13a5-4ab9-957a-79e938f86a19', + 'status': 1 + } + """ + GET_DEVICE_INFO = f"/op/v0/device/detail" + query = {'sn': deviceSN} + result = await self.request_get(GET_DEVICE_INFO, post=False, datain=query) + print("Device detail result: {}".format(result)) + if result: + self.device_detail[deviceSN] = result + + async def get_battery_soc_settings(self, deviceSN): + """ + {'minSocOnGrid': 20, 'minSoc': 10} + """ + GET_BATTERY_SOC = "/op/v0/device/battery/soc/get" + device_detail = self.device_detail.get(deviceSN, {}) + hasBattery = device_detail.get('hasBattery', False) + if hasBattery: + result = await self.request_get(GET_BATTERY_SOC, datain={'sn': deviceSN}) + print("Battery SOC result: {}".format(result)) + if result: + self.device_battery_soc_settings[deviceSN] = result + + async def get_device_power_generation(self, deviceSN): + """ + {'month': 867.5999999999995, 'today': 17.699999999999818, 'cumulative': 5765.7} + """ + GET_DEVICE_POWER = "/op/v0/device/generation" + device_detail = self.device_detail.get(deviceSN, {}) + hasBattery = device_detail.get('hasBattery', False) + if hasBattery: + result = await self.request_get(GET_DEVICE_POWER, datain={'sn': deviceSN}) + print("Device power generation result: {}".format(result)) + if result: + self.device_power_generation[deviceSN] = result + + async def get_device_list(self): + """ + [ + { + 'deviceType': 'KH8', + 'hasBattery': True, + 'hasPV': True, + 'stationName': + '2 Dona Fold', + 'moduleSN': '609W6EUF46MB519', + 'deviceSN': '60KE8020479C034', + 'productType': 'KH', + 'stationID': '2958ff16-13a5-4ab9-957a-79e938f86a19', + 'status': 1 + } + ] + """ + GET_DEVICE_LIST = "/op/v0/device/list" + query = {'pageSize': 100, 'currentPage': 1} + result = await self.request_get(GET_DEVICE_LIST, post=True, datain=query) + if result: + devices = result.get('data', []) + self.device_list = devices + + + def get_headers(self, path): + headers = {} + token = self.key + lang = FOX_LANG + timestamp = str(round(time.time() * 1000)) + headers['token'] = token + headers['lang'] = lang + headers['timestamp'] = timestamp + signature = fr'{path}\r\n{token}\r\n{timestamp}' + headers['signature'] = hashlib.md5(signature.encode('UTF-8')).hexdigest() + return headers + + async def request_get(self, path, post=False, datain=None): + """ + Retry wrapper + """ + retries = 0 + while retries < 5: + result = await self.request_get_func(path, post=post, datain=datain) + if result: + return result + retries += 1 + return result + + async def request_get_func(self, path, post=False, datain=None): + headers = self.get_headers(path) + url = FOX_DOMAIN + path + print("Request: path {} post {} datain {} headers {}".format(path, post, datain, headers)) + try: + if post: + if datain: + response = await asyncio.to_thread(requests.post, url, headers=headers, json=datain, timeout=TIMEOUT) + else: + response = await asyncio.to_thread(requests.post, url, headers=headers, timeout=TIMEOUT) + else: + response = await asyncio.to_thread(requests.get, url, headers=headers, params=datain, timeout=TIMEOUT) + except requests.exceptions.RequestException as e: + self.log(f"Warn: GECloud: Exception during request to {url}: {e}") + self.failures_total += 1 + return None + + try: + data = response.json() + except requests.exceptions.JSONDecodeError: + self.log("Warn: GeCloud: Failed to decode response from {}".format(url)) + data = None + except (requests.Timeout, requests.exceptions.ReadTimeout): + self.log("Warn: GeCloud: Timeout from {}".format(url)) + data = None + except (requests.exceptions.RequestException, requests.exceptions.ConnectionError) as e: + self.log("Warn: GeCloud: Could not connect to {}".format(url)) + data = None + + if response.status_code in [200, 201]: + if data is None: + data = {} + errno = data.get('errno', 0) + msg = data.get('msg', '') + if errno != 0: + self.log("Warn: Fox: Error {} from {} message {}".format(errno, url, msg)) + self.failures_total += 1 + return None + + if 'result' in data: + data = data['result'] + + self.last_success_timestamp = time.time() + return data + else: + self.failures_total += 1 + if response.status_code == 429: + # Rate limiting so wait up to 30 seconds + await asyncio.sleep(random.random() * 30) + return None + + async def publish_data(self): + """ + Publish data to HA using dashboard_item + """ + + # Create entity name prefix + entity_name_sensor = "sensor.predbat_fox" + entity_name_number = "number.predbat_fox" + entity_name_select = "select.predbat_fox" + entity_name_switch = "switch.predbat_fox" + entity_name_binary_sensor = "binary_sensor.predbat_fox" + + for item_name in self.device_values: + item = self.device_values[item_name] + state = item.get('value', None) + name = item.get('name', item_name) + attributes = { + 'unit_of_measurement': item.get('unit', ''), + 'friendly_name': name, + } + entity_id = entity_name_sensor + "_" + item_name.lower() + self.base.dashboard_item(entity_id, state=state, attributes=attributes, app="fox") + + # Event stubs to queue for main thread + async def select_event(self, entity_id, value): + pass + + async def number_event(self, entity_id, value): + pass + + async def switch_event(self, entity_id, service): + pass + + +class MockBase: + """Mock base class for testing""" + def __init__(self): + pass + + def log(self, message): + print(f"LOG: {message}") + + def dashboard_item(self, *args, **kwargs): + print(f"DASHBOARD: {args}, {kwargs}") + + +async def test_fox_api(api_key): + """ + Run a test + """ + print(f"Testing Fox API with key: {api_key[:10]}...") + + # Create a mock base object + mock_base = MockBase() + + # Create FoxAPI instance with a lambda that returns the API key + fox_api = FoxAPI(lambda: api_key, mock_base) + await fox_api.start() + + +def main(): + """ + Main function for command line execution + """ + parser = argparse.ArgumentParser(description="Test Fox API") + parser.add_argument("--api-key", required=True, help="Fox API key") + + args = parser.parse_args() + + # Run the test + asyncio.run(test_fox_api(args.api_key)) + + +if __name__ == "__main__": + main() + diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 7845e9942..c53c37a5e 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -39,7 +39,7 @@ THIS_VERSION = "v8.25.0" # fmt: off -PREDBAT_FILES = ["predbat.py", "config.py", "prediction.py", "gecloud.py","utils.py", "inverter.py", "ha.py", "download.py", "unit_test.py", "web.py", "web_helper.py", "predheat.py", "futurerate.py", "octopus.py", "solcast.py","execute.py", "plan.py", "fetch.py", "output.py", "userinterface.py", "energydataservice.py", "alertfeed.py", "compare.py", "db_manager.py", "db_engine.py", "plugin_system.py", "ohme.py", "components.py"] +PREDBAT_FILES = ["predbat.py", "config.py", "prediction.py", "gecloud.py","utils.py", "inverter.py", "ha.py", "download.py", "unit_test.py", "web.py", "web_helper.py", "predheat.py", "futurerate.py", "octopus.py", "solcast.py","execute.py", "plan.py", "fetch.py", "output.py", "userinterface.py", "energydataservice.py", "alertfeed.py", "compare.py", "db_manager.py", "db_engine.py", "plugin_system.py", "ohme.py", "components.py", "fox.py"] # fmt: on from download import predbat_update_move, predbat_update_download, check_install From f7aeb3290bb46bf267a7b811112ffefb58780494 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sat, 30 Aug 2025 19:21:23 +0100 Subject: [PATCH 02/16] Web tidy --- apps/predbat/web.py | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/predbat/web.py b/apps/predbat/web.py index 10af5da09..b852c7d45 100644 --- a/apps/predbat/web.py +++ b/apps/predbat/web.py @@ -2634,7 +2634,6 @@ async def html_components(self, request): # Component details text += f'
\n' - text += f"

Component: {component_name}

\n" # Show args and their current values args_info = component_info.get("args", {}) From ca5f11e32343e1a606f1024100587c1207b3716e Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sun, 31 Aug 2025 10:43:47 +0100 Subject: [PATCH 03/16] Fiox registers done --- apps/predbat/fox.py | 202 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 162 insertions(+), 40 deletions(-) diff --git a/apps/predbat/fox.py b/apps/predbat/fox.py index 62172c911..5957abfd6 100644 --- a/apps/predbat/fox.py +++ b/apps/predbat/fox.py @@ -27,6 +27,7 @@ FOX_DOMAIN = "https://www.foxesscloud.com" FOX_LANG = 'en' TIMEOUT = 60 +FOX_SETTINGS = ["ExportLimit", "MaxSoc", "GridCode", "WorkMode", "ExportLimitPower", "MinSoc", "MinSocOnGrid"] # Dummy attribute table for testing fox_attribute_table = { @@ -45,10 +46,11 @@ def __init__(self, key, base): self.failures_total = 0 self.device_list = [] self.device_detail = {} - self.device_battery_soc_settings = {} self.device_power_generation = {} self.available_variables = {} self.device_values = {} + self.device_settings = {} + self.device_production = {} def wait_api_started(self): """ @@ -87,13 +89,13 @@ async def start(self): sn = device.get('deviceSN', None) if sn: await self.get_device_detail(sn) - await self.get_battery_soc_settings(sn) + await self.get_device_settings(sn) if first or (count_seconds % (5*60)) == 0: for device in self.device_list: sn = device.get('deviceSN', None) if sn: - await self.get_device_power_generation(sn) + #await self.get_device_power_generation(sn) await self.get_device_history(sn) await self.publish_data() @@ -230,7 +232,9 @@ async def get_device_history(self, deviceSN): timestamp = point.get('time', "") value = point.get('value', None) if timestamp and variable and value is not None: - self.device_values[variable] = {'timestamp': timestamp, 'value': value, 'unit': unit, 'name': name} + if deviceSN not in self.device_values: + self.device_values[deviceSN] = {} + self.device_values[deviceSN][variable] = {'timestamp': timestamp, 'value': value, 'unit': unit, 'name': name} async def get_device_detail(self, deviceSN): """ @@ -264,35 +268,66 @@ async def get_device_detail(self, deviceSN): GET_DEVICE_INFO = f"/op/v0/device/detail" query = {'sn': deviceSN} result = await self.request_get(GET_DEVICE_INFO, post=False, datain=query) - print("Device detail result: {}".format(result)) if result: self.device_detail[deviceSN] = result - async def get_battery_soc_settings(self, deviceSN): + async def get_device_settings(self, deviceSN): """ - {'minSocOnGrid': 20, 'minSoc': 10} + Get device settings """ - GET_BATTERY_SOC = "/op/v0/device/battery/soc/get" - device_detail = self.device_detail.get(deviceSN, {}) - hasBattery = device_detail.get('hasBattery', False) - if hasBattery: - result = await self.request_get(GET_BATTERY_SOC, datain={'sn': deviceSN}) - print("Battery SOC result: {}".format(result)) - if result: - self.device_battery_soc_settings[deviceSN] = result + for key in FOX_SETTINGS: + await self.get_device_setting(deviceSN, key) + async def get_device_setting(self, deviceSN, key): + """ + Get device setting + {'enumList': ['PeakShaving', 'Feedin', 'SelfUse'], 'unit': '', 'precision': 1.0, 'value': 'SelfUse'} + """ + GET_DEVICE_SETTING = "/op/v0/device/setting/get" + result = await self.request_get(GET_DEVICE_SETTING, datain={'sn': deviceSN, 'key': key}, post=True) + if result: + if deviceSN not in self.device_settings: + self.device_settings[deviceSN] = {} + self.device_settings[deviceSN][key] = result + return result + return None + + async def set_device_setting(self, deviceSN, key, value): + """ + Set device setting + """ + SET_DEVICE_SETTING = "/op/v0/device/setting/set" + result = await self.request_get(SET_DEVICE_SETTING, datain={'sn': deviceSN, 'key': key, 'value': value, 'lang': FOX_LANG}, post=True) + if result is None: + return False + return True + + async def get_device_production(self, deviceSN): + """ + [ + {'unit': 'kWh', 'values': [0.0, 0.0, 0.0, 0.0, 151.5999999999999, 1079.1000000000004, 979.8999999999996, 871.3999999999987, 0.0, 0.0, 0.0, 0.0], 'variable': 'generation'}, + {'unit': 'kWh', 'values': [0.0, 0.0, 0.0, 0.0, 68.59999999999991, 685.2, 584.0, 534.3000000000002, 0.0, 0.0, 0.0, 0.0], 'variable': 'feedin'}, + {'unit': 'kWh', 'values': [0.0, 0.0, 0.0, 0.0, 52.700000000000045, 300.0999999999999, 295.2999999999997, 174.89999999999986, 0.0, 0.0, 0.0, 0.0], 'variable': 'gridConsumption'}, + {'unit': 'kWh', 'values': [0.0, 0.0, 0.0, 0.0, 36.30000000000007, 149.0, 170.0, 142.39999999999998, 0.0, 0.0, 0.0, 0.0], 'variable': 'chargeEnergyToTal'}, + {'unit': 'kWh', 'values': [0.0, 0.0, 0.0, 0.0, 52.600000000000136, 219.30000000000018, 253.1999999999997, 225.20000000000027, 0.0, 0.0, 0.0, 0.0], 'variable': 'dischargeEnergyToTal'} + ] + """ + GET_DEVICE_PRODUCTION = "/op/v0/device/report/query" + year = datetime.now().year + variables = ["generation","feedin","gridConsumption","chargeEnergyToTal","dischargeEnergyToTal"] + result = await self.request_get(GET_DEVICE_PRODUCTION, datain={'sn': deviceSN, 'year': year, 'dimension': 'year', 'variables': variables}, post=True) + if result: + self.device_production[deviceSN] = result + + async def get_device_power_generation(self, deviceSN): """ {'month': 867.5999999999995, 'today': 17.699999999999818, 'cumulative': 5765.7} """ GET_DEVICE_POWER = "/op/v0/device/generation" - device_detail = self.device_detail.get(deviceSN, {}) - hasBattery = device_detail.get('hasBattery', False) - if hasBattery: - result = await self.request_get(GET_DEVICE_POWER, datain={'sn': deviceSN}) - print("Device power generation result: {}".format(result)) - if result: - self.device_power_generation[deviceSN] = result + result = await self.request_get(GET_DEVICE_POWER, datain={'sn': deviceSN}) + if result: + self.device_power_generation[deviceSN] = result async def get_device_list(self): """ @@ -338,7 +373,7 @@ async def request_get(self, path, post=False, datain=None): retries = 0 while retries < 5: result = await self.request_get_func(path, post=post, datain=datain) - if result: + if result is not None: return result retries += 1 return result @@ -378,12 +413,19 @@ async def request_get_func(self, path, post=False, datain=None): errno = data.get('errno', 0) msg = data.get('msg', '') if errno != 0: - self.log("Warn: Fox: Error {} from {} message {}".format(errno, url, msg)) self.failures_total += 1 + if errno == 40400: + # Rate limiting so wait up to 10 seconds + self.log("Fox: Rate limiting detected, waiting...") + await asyncio.sleep(random.random() * 10 + 1) + else: + self.log("Warn: Fox: Error {} from {} message {}".format(errno, url, msg)) return None if 'result' in data: data = data['result'] + if data is None: + data = {} self.last_success_timestamp = time.time() return data @@ -406,28 +448,101 @@ async def publish_data(self): entity_name_switch = "switch.predbat_fox" entity_name_binary_sensor = "binary_sensor.predbat_fox" - for item_name in self.device_values: - item = self.device_values[item_name] - state = item.get('value', None) - name = item.get('name', item_name) - attributes = { - 'unit_of_measurement': item.get('unit', ''), - 'friendly_name': name, - } - entity_id = entity_name_sensor + "_" + item_name.lower() - self.base.dashboard_item(entity_id, state=state, attributes=attributes, app="fox") - # Event stubs to queue for main thread + for sn in self.device_values: + for item_name in self.device_values[sn]: + item = self.device_values[sn][item_name] + state = item.get('value', None) + name = item.get('name', item_name) + attributes = { + 'unit_of_measurement': item.get('unit', ''), + 'friendly_name': name, + } + entity_id = entity_name_sensor + "_" + sn.lower() + "_" + item_name.lower() + self.base.dashboard_item(entity_id, state=state, attributes=attributes, app="fox") + + for sn in self.device_settings: + for setting in self.device_settings[sn]: + item = self.device_settings[sn][setting] + state = item.get('value', None) + unit = item.get('unit', '') + range = item.get('range', {}) + precision = item.get('precision', 1) + enumList = item.get('enumList', []) + + name = setting + attributes = { + 'unit_of_measurement': unit, + 'friendly_name': name, + } + if enumList: + # Selector + attributes['options'] = enumList + entity_id = entity_name_select + "_" + sn.lower() + "_" + setting.lower() + elif range: + # Number + attributes['min'] = range.get('min', 0) + attributes['max'] = range.get('max', 100) + attributes['step'] = precision + entity_id = entity_name_number + "_" + sn.lower() + "_" + setting.lower() + else: + # Sensor + entity_id = entity_name_sensor + "_" + sn.lower() + "_" + setting.lower() + self.base.dashboard_item(entity_id, state=state, attributes=attributes, app="fox") + + async def write_setting_from_event(self, entity_id, value, is_number=False): + """ + Handle write events + """ + entity_id = entity_id.replace("number.predbat_fox_", "") + entity_id = entity_id.replace("select.predbat_fox_", "") + sn = entity_id.split("_")[0] + register_lower = entity_id.split("_")[1] + fox_settings_lower = [s.lower() for s in FOX_SETTINGS] + serial = None + for s in self.device_settings: + if s.lower() == sn.lower(): + serial = s + break + if not serial: + self.log("Warn: Fox: Event, unknown serial number for {}: {}".format(entity_id, sn)) + return + if register_lower in fox_settings_lower: + register = FOX_SETTINGS[fox_settings_lower.index(register_lower)] + if is_number: + step = self.device_settings[serial][register].get('precision', None) + if step and step == 1: + try: + value = int(value) + except ValueError: + self.log("Warn: Fox: Invalid integer value for {}: {}".format(entity_id, value)) + return + else: + try: + value = float(value) + except ValueError: + self.log("Warn: Fox: Invalid number value for {}: {}".format(entity_id, value)) + return + if await self.set_device_setting(sn, register, value): + self.device_settings[serial][register]['value'] = value + else: + self.log("Warn: Fox: Unknown select event for {}".format(entity_id)) + await self.publish_data() + async def select_event(self, entity_id, value): - pass + """ + Handle select events + """ + await self.write_setting_from_event(entity_id, value) async def number_event(self, entity_id, value): - pass + await self.write_setting_from_event(entity_id, value, is_number=True) async def switch_event(self, entity_id, service): pass + class MockBase: """Mock base class for testing""" def __init__(self): @@ -450,8 +565,14 @@ async def test_fox_api(api_key): mock_base = MockBase() # Create FoxAPI instance with a lambda that returns the API key - fox_api = FoxAPI(lambda: api_key, mock_base) - await fox_api.start() + fox_api = FoxAPI(api_key, mock_base) + res = await fox_api.get_device_setting('60KE8020479C034', 'WorkMode') + print(res) + res =await fox_api.set_device_setting('60KE8020479C034', 'WorkMode', 'SelfUse') + print(res) + res = await fox_api.get_device_setting('60KE8020479C034', 'WorkMode') + print(res) + #await fox_api.start() def main(): @@ -462,9 +583,10 @@ def main(): parser.add_argument("--api-key", required=True, help="Fox API key") args = parser.parse_args() + key = args.api_key # Run the test - asyncio.run(test_fox_api(args.api_key)) + asyncio.run(test_fox_api(key)) if __name__ == "__main__": From b7e9baaf3fd5b1b843d9f3f904cd56becf7a7a5a Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sun, 31 Aug 2025 11:48:24 +0100 Subject: [PATCH 04/16] Controls --- apps/predbat/fox.py | 150 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 135 insertions(+), 15 deletions(-) diff --git a/apps/predbat/fox.py b/apps/predbat/fox.py index 5957abfd6..6b354b450 100644 --- a/apps/predbat/fox.py +++ b/apps/predbat/fox.py @@ -23,6 +23,7 @@ BASE_TIME = datetime.strptime("00:00", "%H:%M") OPTIONS_TIME = [((BASE_TIME + timedelta(seconds=minute * 60)).strftime("%H:%M")) for minute in range(0, 24 * 60, 1)] +OPTIONS_TIME_FULL = [((BASE_TIME + timedelta(seconds=minute * 60)).strftime("%H:%M") + ":00") for minute in range(0, 24 * 60, 1)] FOX_DOMAIN = "https://www.foxesscloud.com" FOX_LANG = 'en' @@ -51,6 +52,7 @@ def __init__(self, key, base): self.device_values = {} self.device_settings = {} self.device_production = {} + self.device_battery_charging_time = {} def wait_api_started(self): """ @@ -90,6 +92,7 @@ async def start(self): if sn: await self.get_device_detail(sn) await self.get_device_settings(sn) + await self.get_battery_charging_time(sn) if first or (count_seconds % (5*60)) == 0: for device in self.device_list: @@ -302,6 +305,35 @@ async def set_device_setting(self, deviceSN, key, value): return False return True + async def set_battery_charging_time(self, deviceSN, setting): + """ + Set battery charging time + """ + SET_BATTERY_CHARGING_TIME = "/op/v0/device/battery/forceChargeTime/set" + datain = {'sn' : deviceSN} + datain.update(setting) + result = await self.request_get(SET_BATTERY_CHARGING_TIME, datain=datain, post=True) + if result is None: + return False + return True + + async def get_battery_charging_time(self, deviceSN): + """ + { + 'enable2': True, + 'endTime1': {'hour': 23, 'minute': 59}, + 'enable1': True, + 'endTime2': {'hour': 5, 'minute': 30}, + 'startTime2': {'hour': 0, 'minute': 0}, + 'startTime1': {'hour': 23, 'minute': 30} + } + + """ + GET_BATTERY_CHARGING_TIME = "/op/v0/device/battery/forceChargeTime/get" + result = await self.request_get(GET_BATTERY_CHARGING_TIME, datain={'sn': deviceSN}, post=False) + if result: + self.device_battery_charging_time[deviceSN] = result + async def get_device_production(self, deviceSN): """ [ @@ -381,7 +413,7 @@ async def request_get(self, path, post=False, datain=None): async def request_get_func(self, path, post=False, datain=None): headers = self.get_headers(path) url = FOX_DOMAIN + path - print("Request: path {} post {} datain {} headers {}".format(path, post, datain, headers)) + #print("Request: path {} post {} datain {} headers {}".format(path, post, datain, headers)) try: if post: if datain: @@ -456,7 +488,7 @@ async def publish_data(self): name = item.get('name', item_name) attributes = { 'unit_of_measurement': item.get('unit', ''), - 'friendly_name': name, + 'friendly_name': f"Fox {sn} {name}", } entity_id = entity_name_sensor + "_" + sn.lower() + "_" + item_name.lower() self.base.dashboard_item(entity_id, state=state, attributes=attributes, app="fox") @@ -473,22 +505,46 @@ async def publish_data(self): name = setting attributes = { 'unit_of_measurement': unit, - 'friendly_name': name, + 'friendly_name': f"Fox {sn} {name}", } if enumList: # Selector attributes['options'] = enumList - entity_id = entity_name_select + "_" + sn.lower() + "_" + setting.lower() + entity_id = entity_name_select + "_" + sn.lower() + "_" + "setting_" + setting.lower() elif range: # Number attributes['min'] = range.get('min', 0) attributes['max'] = range.get('max', 100) attributes['step'] = precision - entity_id = entity_name_number + "_" + sn.lower() + "_" + setting.lower() + entity_id = entity_name_number + "_" + sn.lower() + "_" + "setting_" + setting.lower() else: # Sensor - entity_id = entity_name_sensor + "_" + sn.lower() + "_" + setting.lower() + entity_id = entity_name_sensor + "_" + sn.lower() + "_" + "setting_" + setting.lower() self.base.dashboard_item(entity_id, state=state, attributes=attributes, app="fox") + + for sn in self.device_battery_charging_time: + setting = self.device_battery_charging_time[sn] + enable1 = setting.get('enable1', False) + enable2 = setting.get('enable2', False) + endTime1 = setting.get('endTime1', {}) + endTime2 = setting.get('endTime2', {}) + startTime1 = setting.get('startTime1', {}) + startTime2 = setting.get('startTime2', {}) + startTime1_str = f"{startTime1.get('hour', 0):02}:{startTime1.get('minute', 0):02}" + ":00" + startTime2_str = f"{startTime2.get('hour', 0):02}:{startTime2.get('minute', 0):02}" + ":00" + endTime1_str = f"{endTime1.get('hour', 0):02}:{endTime1.get('minute', 0):02}" + ":00" + endTime2_str = f"{endTime2.get('hour', 0):02}:{endTime2.get('minute', 0):02}" + ":00" + + # Times are selector + entity_id_battery_charging_time = entity_name_select + "_" + sn.lower() + "_battery_charging_time" + self.base.dashboard_item(entity_id_battery_charging_time + "_start1", state=startTime1_str, attributes={'friendly_name': f"Fox {sn} Battery Charging Time Start 1", "options" : OPTIONS_TIME_FULL}, app="fox") + self.base.dashboard_item(entity_id_battery_charging_time + "_start2", state=startTime2_str, attributes={'friendly_name': f"Fox {sn} Battery Charging Time Start 2", "options" : OPTIONS_TIME_FULL}, app="fox") + self.base.dashboard_item(entity_id_battery_charging_time + "_end1", state=endTime1_str, attributes={'friendly_name': f"Fox {sn} Battery Charging Time End 1", "options" : OPTIONS_TIME_FULL}, app="fox") + self.base.dashboard_item(entity_id_battery_charging_time + "_end2", state=endTime2_str, attributes={'friendly_name': f"Fox {sn} Battery Charging Time End 2", "options" : OPTIONS_TIME_FULL}, app="fox") + # Enable 1 and 2 are switches + entity_id_battery_charging_time = entity_name_switch + "_" + sn.lower() + "_battery_charging_time" + self.base.dashboard_item(entity_id_battery_charging_time + "_enable1", state="on" if enable1 else "off", attributes={'friendly_name': f"Fox {sn} Battery Charging Time Enable 1"}, app="fox") + self.base.dashboard_item(entity_id_battery_charging_time + "_enable2", state="on" if enable2 else "off", attributes={'friendly_name': f"Fox {sn} Battery Charging Time Enable 2"}, app="fox") async def write_setting_from_event(self, entity_id, value, is_number=False): """ @@ -496,6 +552,7 @@ async def write_setting_from_event(self, entity_id, value, is_number=False): """ entity_id = entity_id.replace("number.predbat_fox_", "") entity_id = entity_id.replace("select.predbat_fox_", "") + entity_id = entity_id.replace("setting_", "") sn = entity_id.split("_")[0] register_lower = entity_id.split("_")[1] fox_settings_lower = [s.lower() for s in FOX_SETTINGS] @@ -533,15 +590,83 @@ async def select_event(self, entity_id, value): """ Handle select events """ - await self.write_setting_from_event(entity_id, value) + if '_setting_' in entity_id: + await self.write_setting_from_event(entity_id, value) + elif '_battery_charging_time_' in entity_id: + await self.write_battery_charging_time_event(entity_id, value) async def number_event(self, entity_id, value): - await self.write_setting_from_event(entity_id, value, is_number=True) + if '_setting_' in entity_id: + await self.write_setting_from_event(entity_id, value, is_number=True) async def switch_event(self, entity_id, service): - pass + if '_battery_charging_time_' in entity_id: + await self.write_battery_charging_time_event(entity_id, service) + def apply_service_to_toggle(self, current_value, service): + """ + Apply a toggle service to the current value. + """ + if service == 'turn_on': + current_value = True + elif service == 'turn_off': + current_value = False + elif service == 'toggle': + current_value = not current_value + return current_value + async def write_battery_charging_time_event(self, entity_id, value): + """ + Handle battery charging time events + """ + + entity_id = entity_id.replace("switch.predbat_fox_", "") + entity_id = entity_id.replace("select.predbat_fox_", "") + sn = entity_id.split("_")[0] + serial = None + for s in self.device_battery_charging_time: + if s.lower() == sn.lower(): + serial = s + break + + if not serial: + self.log("Warn: Fox: Event, unknown serial number for {}: {}".format(entity_id, sn)) + return + + state = self.device_battery_charging_time[serial].copy() + if '_battery_charging_time_enable1' in entity_id: + enable1 = state.get('enable1', False) + state['enable1'] = self.apply_service_to_toggle(enable1, value) + elif '_battery_charging_time_enable2' in entity_id: + enable2 = state.get('enable2', False) + state['enable2'] = self.apply_service_to_toggle(enable2, value) + elif '_battery_charging_time_start1' in entity_id: + start1 = state.get('startTime1', {'hour': 0, 'minute': 0}) + start1['hour'] = int(value.split(":")[0]) + start1['minute'] = int(value.split(":")[1]) + state['startTime1'] = start1 + elif '_battery_charging_time_start2' in entity_id: + start2 = state.get('startTime2', {'hour': 0, 'minute': 0}) + start2['hour'] = int(value.split(":")[0]) + start2['minute'] = int(value.split(":")[1]) + state['startTime2'] = start2 + elif '_battery_charging_time_end1' in entity_id: + end1 = state.get('endTime1', {'hour': 0, 'minute': 0}) + end1['hour'] = int(value.split(":")[0]) + end1['minute'] = int(value.split(":")[1]) + state['endTime1'] = end1 + elif '_battery_charging_time_end2' in entity_id: + end2 = state.get('endTime2', {'hour': 0, 'minute': 0}) + end2['hour'] = int(value.split(":")[0]) + end2['minute'] = int(value.split(":")[1]) + state['endTime2'] = end2 + else: + self.log("Warn: Fox: Unknown battery charging time event for {}".format(entity_id)) + return + result = await self.set_battery_charging_time(serial, state) + if result is not None: + self.device_battery_charging_time[serial] = state + await self.publish_data() class MockBase: """Mock base class for testing""" @@ -566,12 +691,7 @@ async def test_fox_api(api_key): # Create FoxAPI instance with a lambda that returns the API key fox_api = FoxAPI(api_key, mock_base) - res = await fox_api.get_device_setting('60KE8020479C034', 'WorkMode') - print(res) - res =await fox_api.set_device_setting('60KE8020479C034', 'WorkMode', 'SelfUse') - print(res) - res = await fox_api.get_device_setting('60KE8020479C034', 'WorkMode') - print(res) + res = await fox_api.get_battery_charging_time('60KE8020479C034') #await fox_api.start() From d20958b16505e6c92bb24c5a828571c506cc78bc Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sun, 31 Aug 2025 10:50:22 +0000 Subject: [PATCH 05/16] [pre-commit.ci lite] apply automatic fixes --- apps/predbat/components.py | 2 +- apps/predbat/fox.py | 359 ++++++++++++++++++------------------- 2 files changed, 179 insertions(+), 182 deletions(-) diff --git a/apps/predbat/components.py b/apps/predbat/components.py index 73fa22d26..503bd625f 100644 --- a/apps/predbat/components.py +++ b/apps/predbat/components.py @@ -99,7 +99,7 @@ "config": "fox_key", }, }, - } + }, } diff --git a/apps/predbat/fox.py b/apps/predbat/fox.py index 6b354b450..f87bf9384 100644 --- a/apps/predbat/fox.py +++ b/apps/predbat/fox.py @@ -26,14 +26,13 @@ OPTIONS_TIME_FULL = [((BASE_TIME + timedelta(seconds=minute * 60)).strftime("%H:%M") + ":00") for minute in range(0, 24 * 60, 1)] FOX_DOMAIN = "https://www.foxesscloud.com" -FOX_LANG = 'en' +FOX_LANG = "en" TIMEOUT = 60 FOX_SETTINGS = ["ExportLimit", "MaxSoc", "GridCode", "WorkMode", "ExportLimitPower", "MinSoc", "MinSocOnGrid"] # Dummy attribute table for testing -fox_attribute_table = { - "mode": {} -} +fox_attribute_table = {"mode": {}} + class FoxAPI: """Fox API client.""" @@ -83,22 +82,22 @@ async def start(self): count_seconds = 0 while not self.stop_api: try: - if first or (count_seconds % (30*60) == 0): + if first or (count_seconds % (30 * 60) == 0): await self.get_device_list() if first: self.log("Fox API: Found {} devices".format(len(self.device_list))) for device in self.device_list: - sn = device.get('deviceSN', None) + sn = device.get("deviceSN", None) if sn: await self.get_device_detail(sn) await self.get_device_settings(sn) await self.get_battery_charging_time(sn) - if first or (count_seconds % (5*60)) == 0: + if first or (count_seconds % (5 * 60)) == 0: for device in self.device_list: - sn = device.get('deviceSN', None) + sn = device.get("deviceSN", None) if sn: - #await self.get_device_power_generation(sn) + # await self.get_device_power_generation(sn) await self.get_device_history(sn) await self.publish_data() @@ -126,30 +125,30 @@ async def get_available_variables(self): Get available variables for the device { - 'todayYield': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'Today’s power generation', 'Energy-storage inverter': True}, - 'pvPower': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'PVPower', 'Energy-storage inverter': True}, - 'pv1Volt': {'unit': 'V', 'Grid-tied inverter': True, 'name': 'PV1Volt', 'Energy-storage inverter': True}, - 'pv1Current': {'unit': 'A', 'Grid-tied inverter': True, 'name': 'PV1Current', 'Energy-storage inverter': True}, - 'pv1Power': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'PV1Power', 'Energy-storage inverter': True}, - 'pv2Volt': {'unit': 'V', 'Grid-tied inverter': True, 'name': 'PV2Volt', 'Energy-storage inverter': True}, - 'pv2Current': {'unit': 'A', 'Grid-tied inverter': True, 'name': 'PV2Current', 'Energy-storage inverter': True}, - 'pv2Power': {'unit': 'kW', 'name': 'PV2Power'}, - .. - 'epsPower': {'unit': 'kW', 'Grid-tied inverter': False, 'name': 'EPSPower', 'Energy-storage inverter': True}, - 'epsCurrentR': {'unit': 'A', 'Grid-tied inverter': False, 'name': 'EPS-RCurrent', 'Energy-storage inverter': True}, - 'epsVoltR': {'unit': 'V', 'Grid-tied inverter': False, 'name': 'EPS-RVolt', 'Energy-storage inverter': True}, - 'epsPowerR': {'unit': 'kW', 'Grid-tied inverter': False, 'name': 'EPS-RPower', 'Energy-storage inverter': True}, - 'epsCurrentS': {'unit': 'A', 'Grid-tied inverter': False, 'name': 'EPS-SCurrent', 'Energy-storage inverter': True}, - 'epsVoltS': {'unit': 'V', 'Grid-tied inverter': False, 'name': 'EPS-SVolt', 'Energy-storage inverter': True}, + 'todayYield': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'Today’s power generation', 'Energy-storage inverter': True}, + 'pvPower': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'PVPower', 'Energy-storage inverter': True}, + 'pv1Volt': {'unit': 'V', 'Grid-tied inverter': True, 'name': 'PV1Volt', 'Energy-storage inverter': True}, + 'pv1Current': {'unit': 'A', 'Grid-tied inverter': True, 'name': 'PV1Current', 'Energy-storage inverter': True}, + 'pv1Power': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'PV1Power', 'Energy-storage inverter': True}, + 'pv2Volt': {'unit': 'V', 'Grid-tied inverter': True, 'name': 'PV2Volt', 'Energy-storage inverter': True}, + 'pv2Current': {'unit': 'A', 'Grid-tied inverter': True, 'name': 'PV2Current', 'Energy-storage inverter': True}, + 'pv2Power': {'unit': 'kW', 'name': 'PV2Power'}, + .. + 'epsPower': {'unit': 'kW', 'Grid-tied inverter': False, 'name': 'EPSPower', 'Energy-storage inverter': True}, + 'epsCurrentR': {'unit': 'A', 'Grid-tied inverter': False, 'name': 'EPS-RCurrent', 'Energy-storage inverter': True}, + 'epsVoltR': {'unit': 'V', 'Grid-tied inverter': False, 'name': 'EPS-RVolt', 'Energy-storage inverter': True}, + 'epsPowerR': {'unit': 'kW', 'Grid-tied inverter': False, 'name': 'EPS-RPower', 'Energy-storage inverter': True}, + 'epsCurrentS': {'unit': 'A', 'Grid-tied inverter': False, 'name': 'EPS-SCurrent', 'Energy-storage inverter': True}, + 'epsVoltS': {'unit': 'V', 'Grid-tied inverter': False, 'name': 'EPS-SVolt', 'Energy-storage inverter': True}, 'epsPowerS': {'unit': 'kW', 'Grid-tied inverter': False, 'name': 'EPS-SPower', 'Energy-storage inverter': True}, - 'epsCurrentT': {'unit': 'A', 'Grid-tied inverter': False, 'name': 'EPS-TCurrent', 'Energy-storage inverter': True}, - 'epsVoltT': {'unit': 'V', 'Grid-tied inverter': False, 'name': 'EPS-TVolt', 'Energy-storage inverter': True}, - 'epsPowerT': {'unit': 'kW', 'Grid-tied inverter': False, 'name': 'EPS-TPower', 'Energy-storage inverter': True}, - 'RCurrent': {'unit': 'A', 'Grid-tied inverter': True, 'name': 'RCurrent', 'Energy-storage inverter': True}, - 'RVolt': {'unit': 'V', 'Grid-tied inverter': True, 'name': 'RVolt', 'Energy-storage inverter': True}, - 'RFreq': {'unit': 'Hz', 'Grid-tied inverter': True, 'name': 'RFreq', 'Energy-storage inverter': True}, - 'RPower': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'RPower', 'Energy-storage inverter': True}, - 'SCurrent': {'unit': 'A', 'Grid-tied inverter': True, 'name': 'SCurrent', 'Energy-storage inverter': True}, + 'epsCurrentT': {'unit': 'A', 'Grid-tied inverter': False, 'name': 'EPS-TCurrent', 'Energy-storage inverter': True}, + 'epsVoltT': {'unit': 'V', 'Grid-tied inverter': False, 'name': 'EPS-TVolt', 'Energy-storage inverter': True}, + 'epsPowerT': {'unit': 'kW', 'Grid-tied inverter': False, 'name': 'EPS-TPower', 'Energy-storage inverter': True}, + 'RCurrent': {'unit': 'A', 'Grid-tied inverter': True, 'name': 'RCurrent', 'Energy-storage inverter': True}, + 'RVolt': {'unit': 'V', 'Grid-tied inverter': True, 'name': 'RVolt', 'Energy-storage inverter': True}, + 'RFreq': {'unit': 'Hz', 'Grid-tied inverter': True, 'name': 'RFreq', 'Energy-storage inverter': True}, + 'RPower': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'RPower', 'Energy-storage inverter': True}, + 'SCurrent': {'unit': 'A', 'Grid-tied inverter': True, 'name': 'SCurrent', 'Energy-storage inverter': True}, 'SVolt': {'unit': 'V', 'Grid-tied inverter': True, 'name': 'SVolt', 'Energy-storage inverter': True}, 'SFreq': {'unit': 'Hz', 'Grid-tied inverter': True, 'name': 'SFreq', 'Energy-storage inverter': True}, 'SPower': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'SPower', 'Energy-storage inverter': True}, @@ -198,8 +197,8 @@ async def get_available_variables(self): 'loads': {'unit': 'kWh', 'Grid-tied inverter': True, 'name': 'Load power consumption', 'Energy-storage inverter': True}, 'feedin': {'unit': 'kWh', 'Grid-tied inverter': True, 'name': 'The total energy of the feeder', 'Energy-storage inverter': True}, 'chargeEnergyToTal': {'unit': 'kWh', 'Grid-tied inverter': False, 'name': 'Total charge energy', 'Energy-storage inverter': True}, - 'dischargeEnergyToTal': {'unit': 'kWh', 'Grid-tied inverter': False, 'name': 'Total discharge energy', 'Energy-storage inverter': True} - + 'dischargeEnergyToTal': {'unit': 'kWh', 'Grid-tied inverter': False, 'name': 'Total discharge energy', 'Energy-storage inverter': True} + """ GET_AVAILABLE_VARIABLES = "/op/v0/device/variable/get" result = await self.request_get(GET_AVAILABLE_VARIABLES) @@ -208,9 +207,9 @@ async def get_available_variables(self): for variable_item in result: for variable_id in variable_item: variable = variable_item.get(variable_id, {}) - name = variable.get('name', {}) - name = name.get(FOX_LANG, '') - variable['name'] = name + name = variable.get("name", {}) + name = name.get(FOX_LANG, "") + variable["name"] = name available_data[variable_id] = variable self.available_variables = available_data @@ -220,56 +219,56 @@ async def get_device_history(self, deviceSN): """ GET_DEVICE_HISTORY = "/op/v0/device/history/query" timestamp = round(time.time() * 1000) - query = {'sn': deviceSN, 'begin': timestamp - 1000*60*60*1, 'end': timestamp} + query = {"sn": deviceSN, "begin": timestamp - 1000 * 60 * 60 * 1, "end": timestamp} result = await self.request_get(GET_DEVICE_HISTORY, post=True, datain=query) if result and isinstance(result, list): for item in result: - if 'datas' in item: - datas = item['datas'] + if "datas" in item: + datas = item["datas"] for data_item in datas: - unit = data_item.get('unit', '') - name = data_item.get('name', '') - variable = data_item.get('variable', '') - history = data_item.get('data', []) + unit = data_item.get("unit", "") + name = data_item.get("name", "") + variable = data_item.get("variable", "") + history = data_item.get("data", []) point = history[-1] if history else {} - timestamp = point.get('time', "") - value = point.get('value', None) + timestamp = point.get("time", "") + value = point.get("value", None) if timestamp and variable and value is not None: if deviceSN not in self.device_values: self.device_values[deviceSN] = {} - self.device_values[deviceSN][variable] = {'timestamp': timestamp, 'value': value, 'unit': unit, 'name': name} + self.device_values[deviceSN][variable] = {"timestamp": timestamp, "value": value, "unit": unit, "name": name} async def get_device_detail(self, deviceSN): """ Get device information { - 'deviceType': 'KH8', - 'masterVersion': '1.34', - 'afciVersion': '', - 'hasPV': True, - 'deviceSN': - '60KE8020479C034', - 'slaveVersion': '1.01', - 'capacity': 8, - 'hasBattery': True, + 'deviceType': 'KH8', + 'masterVersion': '1.34', + 'afciVersion': '', + 'hasPV': True, + 'deviceSN': + '60KE8020479C034', + 'slaveVersion': '1.01', + 'capacity': 8, + 'hasBattery': True, 'function': {'scheduler': True}, - - 'hardwareVersion': '--', - 'managerVersion': '1.28', - 'stationName': '2 Dona Fold', - 'moduleSN': '609W6EUF46MB519', - 'batteryList': - [{'batterySN': '60EP01104APP050', 'model': 'EP11', 'type': 'bcu', 'version': '1.005'}, - {'batterySN': '60EP01104APP050', 'model': 'EP11', 'type': 'bmu', 'version': '1.05', 'capacity': 10360}, - {'batterySN': '60EP01104APP050', 'model': 'EP11', 'type': 'ivu', 'version': '0.00'}], - 'productType': 'KH', - 'stationID': '2958ff16-13a5-4ab9-957a-79e938f86a19', + + 'hardwareVersion': '--', + 'managerVersion': '1.28', + 'stationName': '2 Dona Fold', + 'moduleSN': '609W6EUF46MB519', + 'batteryList': + [{'batterySN': '60EP01104APP050', 'model': 'EP11', 'type': 'bcu', 'version': '1.005'}, + {'batterySN': '60EP01104APP050', 'model': 'EP11', 'type': 'bmu', 'version': '1.05', 'capacity': 10360}, + {'batterySN': '60EP01104APP050', 'model': 'EP11', 'type': 'ivu', 'version': '0.00'}], + 'productType': 'KH', + 'stationID': '2958ff16-13a5-4ab9-957a-79e938f86a19', 'status': 1 } """ GET_DEVICE_INFO = f"/op/v0/device/detail" - query = {'sn': deviceSN} + query = {"sn": deviceSN} result = await self.request_get(GET_DEVICE_INFO, post=False, datain=query) if result: self.device_detail[deviceSN] = result @@ -287,7 +286,7 @@ async def get_device_setting(self, deviceSN, key): {'enumList': ['PeakShaving', 'Feedin', 'SelfUse'], 'unit': '', 'precision': 1.0, 'value': 'SelfUse'} """ GET_DEVICE_SETTING = "/op/v0/device/setting/get" - result = await self.request_get(GET_DEVICE_SETTING, datain={'sn': deviceSN, 'key': key}, post=True) + result = await self.request_get(GET_DEVICE_SETTING, datain={"sn": deviceSN, "key": key}, post=True) if result: if deviceSN not in self.device_settings: self.device_settings[deviceSN] = {} @@ -300,7 +299,7 @@ async def set_device_setting(self, deviceSN, key, value): Set device setting """ SET_DEVICE_SETTING = "/op/v0/device/setting/set" - result = await self.request_get(SET_DEVICE_SETTING, datain={'sn': deviceSN, 'key': key, 'value': value, 'lang': FOX_LANG}, post=True) + result = await self.request_get(SET_DEVICE_SETTING, datain={"sn": deviceSN, "key": key, "value": value, "lang": FOX_LANG}, post=True) if result is None: return False return True @@ -310,7 +309,7 @@ async def set_battery_charging_time(self, deviceSN, setting): Set battery charging time """ SET_BATTERY_CHARGING_TIME = "/op/v0/device/battery/forceChargeTime/set" - datain = {'sn' : deviceSN} + datain = {"sn": deviceSN} datain.update(setting) result = await self.request_get(SET_BATTERY_CHARGING_TIME, datain=datain, post=True) if result is None: @@ -320,17 +319,17 @@ async def set_battery_charging_time(self, deviceSN, setting): async def get_battery_charging_time(self, deviceSN): """ { - 'enable2': True, - 'endTime1': {'hour': 23, 'minute': 59}, - 'enable1': True, - 'endTime2': {'hour': 5, 'minute': 30}, - 'startTime2': {'hour': 0, 'minute': 0}, + 'enable2': True, + 'endTime1': {'hour': 23, 'minute': 59}, + 'enable1': True, + 'endTime2': {'hour': 5, 'minute': 30}, + 'startTime2': {'hour': 0, 'minute': 0}, 'startTime1': {'hour': 23, 'minute': 30} } """ GET_BATTERY_CHARGING_TIME = "/op/v0/device/battery/forceChargeTime/get" - result = await self.request_get(GET_BATTERY_CHARGING_TIME, datain={'sn': deviceSN}, post=False) + result = await self.request_get(GET_BATTERY_CHARGING_TIME, datain={"sn": deviceSN}, post=False) if result: self.device_battery_charging_time[deviceSN] = result @@ -346,18 +345,17 @@ async def get_device_production(self, deviceSN): """ GET_DEVICE_PRODUCTION = "/op/v0/device/report/query" year = datetime.now().year - variables = ["generation","feedin","gridConsumption","chargeEnergyToTal","dischargeEnergyToTal"] - result = await self.request_get(GET_DEVICE_PRODUCTION, datain={'sn': deviceSN, 'year': year, 'dimension': 'year', 'variables': variables}, post=True) + variables = ["generation", "feedin", "gridConsumption", "chargeEnergyToTal", "dischargeEnergyToTal"] + result = await self.request_get(GET_DEVICE_PRODUCTION, datain={"sn": deviceSN, "year": year, "dimension": "year", "variables": variables}, post=True) if result: self.device_production[deviceSN] = result - async def get_device_power_generation(self, deviceSN): """ {'month': 867.5999999999995, 'today': 17.699999999999818, 'cumulative': 5765.7} """ GET_DEVICE_POWER = "/op/v0/device/generation" - result = await self.request_get(GET_DEVICE_POWER, datain={'sn': deviceSN}) + result = await self.request_get(GET_DEVICE_POWER, datain={"sn": deviceSN}) if result: self.device_power_generation[deviceSN] = result @@ -365,40 +363,39 @@ async def get_device_list(self): """ [ { - 'deviceType': 'KH8', - 'hasBattery': True, - 'hasPV': True, - 'stationName': - '2 Dona Fold', - 'moduleSN': '609W6EUF46MB519', - 'deviceSN': '60KE8020479C034', - 'productType': 'KH', - 'stationID': '2958ff16-13a5-4ab9-957a-79e938f86a19', + 'deviceType': 'KH8', + 'hasBattery': True, + 'hasPV': True, + 'stationName': + '2 Dona Fold', + 'moduleSN': '609W6EUF46MB519', + 'deviceSN': '60KE8020479C034', + 'productType': 'KH', + 'stationID': '2958ff16-13a5-4ab9-957a-79e938f86a19', 'status': 1 } ] """ GET_DEVICE_LIST = "/op/v0/device/list" - query = {'pageSize': 100, 'currentPage': 1} + query = {"pageSize": 100, "currentPage": 1} result = await self.request_get(GET_DEVICE_LIST, post=True, datain=query) if result: - devices = result.get('data', []) + devices = result.get("data", []) self.device_list = devices - def get_headers(self, path): headers = {} token = self.key lang = FOX_LANG timestamp = str(round(time.time() * 1000)) - headers['token'] = token - headers['lang'] = lang - headers['timestamp'] = timestamp - signature = fr'{path}\r\n{token}\r\n{timestamp}' - headers['signature'] = hashlib.md5(signature.encode('UTF-8')).hexdigest() + headers["token"] = token + headers["lang"] = lang + headers["timestamp"] = timestamp + signature = rf"{path}\r\n{token}\r\n{timestamp}" + headers["signature"] = hashlib.md5(signature.encode("UTF-8")).hexdigest() return headers - async def request_get(self, path, post=False, datain=None): + async def request_get(self, path, post=False, datain=None): """ Retry wrapper """ @@ -410,10 +407,10 @@ async def request_get(self, path, post=False, datain=None): retries += 1 return result - async def request_get_func(self, path, post=False, datain=None): + async def request_get_func(self, path, post=False, datain=None): headers = self.get_headers(path) url = FOX_DOMAIN + path - #print("Request: path {} post {} datain {} headers {}".format(path, post, datain, headers)) + # print("Request: path {} post {} datain {} headers {}".format(path, post, datain, headers)) try: if post: if datain: @@ -442,8 +439,8 @@ async def request_get_func(self, path, post=False, datain=None): if response.status_code in [200, 201]: if data is None: data = {} - errno = data.get('errno', 0) - msg = data.get('msg', '') + errno = data.get("errno", 0) + msg = data.get("msg", "") if errno != 0: self.failures_total += 1 if errno == 40400: @@ -453,9 +450,9 @@ async def request_get_func(self, path, post=False, datain=None): else: self.log("Warn: Fox: Error {} from {} message {}".format(errno, url, msg)) return None - - if 'result' in data: - data = data['result'] + + if "result" in data: + data = data["result"] if data is None: data = {} @@ -480,15 +477,14 @@ async def publish_data(self): entity_name_switch = "switch.predbat_fox" entity_name_binary_sensor = "binary_sensor.predbat_fox" - for sn in self.device_values: for item_name in self.device_values[sn]: item = self.device_values[sn][item_name] - state = item.get('value', None) - name = item.get('name', item_name) + state = item.get("value", None) + name = item.get("name", item_name) attributes = { - 'unit_of_measurement': item.get('unit', ''), - 'friendly_name': f"Fox {sn} {name}", + "unit_of_measurement": item.get("unit", ""), + "friendly_name": f"Fox {sn} {name}", } entity_id = entity_name_sensor + "_" + sn.lower() + "_" + item_name.lower() self.base.dashboard_item(entity_id, state=state, attributes=attributes, app="fox") @@ -496,40 +492,40 @@ async def publish_data(self): for sn in self.device_settings: for setting in self.device_settings[sn]: item = self.device_settings[sn][setting] - state = item.get('value', None) - unit = item.get('unit', '') - range = item.get('range', {}) - precision = item.get('precision', 1) - enumList = item.get('enumList', []) + state = item.get("value", None) + unit = item.get("unit", "") + range = item.get("range", {}) + precision = item.get("precision", 1) + enumList = item.get("enumList", []) name = setting attributes = { - 'unit_of_measurement': unit, - 'friendly_name': f"Fox {sn} {name}", + "unit_of_measurement": unit, + "friendly_name": f"Fox {sn} {name}", } if enumList: # Selector - attributes['options'] = enumList + attributes["options"] = enumList entity_id = entity_name_select + "_" + sn.lower() + "_" + "setting_" + setting.lower() elif range: # Number - attributes['min'] = range.get('min', 0) - attributes['max'] = range.get('max', 100) - attributes['step'] = precision + attributes["min"] = range.get("min", 0) + attributes["max"] = range.get("max", 100) + attributes["step"] = precision entity_id = entity_name_number + "_" + sn.lower() + "_" + "setting_" + setting.lower() else: # Sensor entity_id = entity_name_sensor + "_" + sn.lower() + "_" + "setting_" + setting.lower() self.base.dashboard_item(entity_id, state=state, attributes=attributes, app="fox") - + for sn in self.device_battery_charging_time: setting = self.device_battery_charging_time[sn] - enable1 = setting.get('enable1', False) - enable2 = setting.get('enable2', False) - endTime1 = setting.get('endTime1', {}) - endTime2 = setting.get('endTime2', {}) - startTime1 = setting.get('startTime1', {}) - startTime2 = setting.get('startTime2', {}) + enable1 = setting.get("enable1", False) + enable2 = setting.get("enable2", False) + endTime1 = setting.get("endTime1", {}) + endTime2 = setting.get("endTime2", {}) + startTime1 = setting.get("startTime1", {}) + startTime2 = setting.get("startTime2", {}) startTime1_str = f"{startTime1.get('hour', 0):02}:{startTime1.get('minute', 0):02}" + ":00" startTime2_str = f"{startTime2.get('hour', 0):02}:{startTime2.get('minute', 0):02}" + ":00" endTime1_str = f"{endTime1.get('hour', 0):02}:{endTime1.get('minute', 0):02}" + ":00" @@ -537,14 +533,14 @@ async def publish_data(self): # Times are selector entity_id_battery_charging_time = entity_name_select + "_" + sn.lower() + "_battery_charging_time" - self.base.dashboard_item(entity_id_battery_charging_time + "_start1", state=startTime1_str, attributes={'friendly_name': f"Fox {sn} Battery Charging Time Start 1", "options" : OPTIONS_TIME_FULL}, app="fox") - self.base.dashboard_item(entity_id_battery_charging_time + "_start2", state=startTime2_str, attributes={'friendly_name': f"Fox {sn} Battery Charging Time Start 2", "options" : OPTIONS_TIME_FULL}, app="fox") - self.base.dashboard_item(entity_id_battery_charging_time + "_end1", state=endTime1_str, attributes={'friendly_name': f"Fox {sn} Battery Charging Time End 1", "options" : OPTIONS_TIME_FULL}, app="fox") - self.base.dashboard_item(entity_id_battery_charging_time + "_end2", state=endTime2_str, attributes={'friendly_name': f"Fox {sn} Battery Charging Time End 2", "options" : OPTIONS_TIME_FULL}, app="fox") + self.base.dashboard_item(entity_id_battery_charging_time + "_start1", state=startTime1_str, attributes={"friendly_name": f"Fox {sn} Battery Charging Time Start 1", "options": OPTIONS_TIME_FULL}, app="fox") + self.base.dashboard_item(entity_id_battery_charging_time + "_start2", state=startTime2_str, attributes={"friendly_name": f"Fox {sn} Battery Charging Time Start 2", "options": OPTIONS_TIME_FULL}, app="fox") + self.base.dashboard_item(entity_id_battery_charging_time + "_end1", state=endTime1_str, attributes={"friendly_name": f"Fox {sn} Battery Charging Time End 1", "options": OPTIONS_TIME_FULL}, app="fox") + self.base.dashboard_item(entity_id_battery_charging_time + "_end2", state=endTime2_str, attributes={"friendly_name": f"Fox {sn} Battery Charging Time End 2", "options": OPTIONS_TIME_FULL}, app="fox") # Enable 1 and 2 are switches entity_id_battery_charging_time = entity_name_switch + "_" + sn.lower() + "_battery_charging_time" - self.base.dashboard_item(entity_id_battery_charging_time + "_enable1", state="on" if enable1 else "off", attributes={'friendly_name': f"Fox {sn} Battery Charging Time Enable 1"}, app="fox") - self.base.dashboard_item(entity_id_battery_charging_time + "_enable2", state="on" if enable2 else "off", attributes={'friendly_name': f"Fox {sn} Battery Charging Time Enable 2"}, app="fox") + self.base.dashboard_item(entity_id_battery_charging_time + "_enable1", state="on" if enable1 else "off", attributes={"friendly_name": f"Fox {sn} Battery Charging Time Enable 1"}, app="fox") + self.base.dashboard_item(entity_id_battery_charging_time + "_enable2", state="on" if enable2 else "off", attributes={"friendly_name": f"Fox {sn} Battery Charging Time Enable 2"}, app="fox") async def write_setting_from_event(self, entity_id, value, is_number=False): """ @@ -567,7 +563,7 @@ async def write_setting_from_event(self, entity_id, value, is_number=False): if register_lower in fox_settings_lower: register = FOX_SETTINGS[fox_settings_lower.index(register_lower)] if is_number: - step = self.device_settings[serial][register].get('precision', None) + step = self.device_settings[serial][register].get("precision", None) if step and step == 1: try: value = int(value) @@ -581,7 +577,7 @@ async def write_setting_from_event(self, entity_id, value, is_number=False): self.log("Warn: Fox: Invalid number value for {}: {}".format(entity_id, value)) return if await self.set_device_setting(sn, register, value): - self.device_settings[serial][register]['value'] = value + self.device_settings[serial][register]["value"] = value else: self.log("Warn: Fox: Unknown select event for {}".format(entity_id)) await self.publish_data() @@ -590,28 +586,28 @@ async def select_event(self, entity_id, value): """ Handle select events """ - if '_setting_' in entity_id: + if "_setting_" in entity_id: await self.write_setting_from_event(entity_id, value) - elif '_battery_charging_time_' in entity_id: + elif "_battery_charging_time_" in entity_id: await self.write_battery_charging_time_event(entity_id, value) async def number_event(self, entity_id, value): - if '_setting_' in entity_id: + if "_setting_" in entity_id: await self.write_setting_from_event(entity_id, value, is_number=True) async def switch_event(self, entity_id, service): - if '_battery_charging_time_' in entity_id: + if "_battery_charging_time_" in entity_id: await self.write_battery_charging_time_event(entity_id, service) def apply_service_to_toggle(self, current_value, service): """ Apply a toggle service to the current value. """ - if service == 'turn_on': + if service == "turn_on": current_value = True - elif service == 'turn_off': + elif service == "turn_off": current_value = False - elif service == 'toggle': + elif service == "toggle": current_value = not current_value return current_value @@ -632,34 +628,34 @@ async def write_battery_charging_time_event(self, entity_id, value): if not serial: self.log("Warn: Fox: Event, unknown serial number for {}: {}".format(entity_id, sn)) return - + state = self.device_battery_charging_time[serial].copy() - if '_battery_charging_time_enable1' in entity_id: - enable1 = state.get('enable1', False) - state['enable1'] = self.apply_service_to_toggle(enable1, value) - elif '_battery_charging_time_enable2' in entity_id: - enable2 = state.get('enable2', False) - state['enable2'] = self.apply_service_to_toggle(enable2, value) - elif '_battery_charging_time_start1' in entity_id: - start1 = state.get('startTime1', {'hour': 0, 'minute': 0}) - start1['hour'] = int(value.split(":")[0]) - start1['minute'] = int(value.split(":")[1]) - state['startTime1'] = start1 - elif '_battery_charging_time_start2' in entity_id: - start2 = state.get('startTime2', {'hour': 0, 'minute': 0}) - start2['hour'] = int(value.split(":")[0]) - start2['minute'] = int(value.split(":")[1]) - state['startTime2'] = start2 - elif '_battery_charging_time_end1' in entity_id: - end1 = state.get('endTime1', {'hour': 0, 'minute': 0}) - end1['hour'] = int(value.split(":")[0]) - end1['minute'] = int(value.split(":")[1]) - state['endTime1'] = end1 - elif '_battery_charging_time_end2' in entity_id: - end2 = state.get('endTime2', {'hour': 0, 'minute': 0}) - end2['hour'] = int(value.split(":")[0]) - end2['minute'] = int(value.split(":")[1]) - state['endTime2'] = end2 + if "_battery_charging_time_enable1" in entity_id: + enable1 = state.get("enable1", False) + state["enable1"] = self.apply_service_to_toggle(enable1, value) + elif "_battery_charging_time_enable2" in entity_id: + enable2 = state.get("enable2", False) + state["enable2"] = self.apply_service_to_toggle(enable2, value) + elif "_battery_charging_time_start1" in entity_id: + start1 = state.get("startTime1", {"hour": 0, "minute": 0}) + start1["hour"] = int(value.split(":")[0]) + start1["minute"] = int(value.split(":")[1]) + state["startTime1"] = start1 + elif "_battery_charging_time_start2" in entity_id: + start2 = state.get("startTime2", {"hour": 0, "minute": 0}) + start2["hour"] = int(value.split(":")[0]) + start2["minute"] = int(value.split(":")[1]) + state["startTime2"] = start2 + elif "_battery_charging_time_end1" in entity_id: + end1 = state.get("endTime1", {"hour": 0, "minute": 0}) + end1["hour"] = int(value.split(":")[0]) + end1["minute"] = int(value.split(":")[1]) + state["endTime1"] = end1 + elif "_battery_charging_time_end2" in entity_id: + end2 = state.get("endTime2", {"hour": 0, "minute": 0}) + end2["hour"] = int(value.split(":")[0]) + end2["minute"] = int(value.split(":")[1]) + state["endTime2"] = end2 else: self.log("Warn: Fox: Unknown battery charging time event for {}".format(entity_id)) return @@ -668,14 +664,16 @@ async def write_battery_charging_time_event(self, entity_id, value): self.device_battery_charging_time[serial] = state await self.publish_data() + class MockBase: """Mock base class for testing""" + def __init__(self): pass - + def log(self, message): print(f"LOG: {message}") - + def dashboard_item(self, *args, **kwargs): print(f"DASHBOARD: {args}, {kwargs}") @@ -685,14 +683,14 @@ async def test_fox_api(api_key): Run a test """ print(f"Testing Fox API with key: {api_key[:10]}...") - + # Create a mock base object mock_base = MockBase() - + # Create FoxAPI instance with a lambda that returns the API key - fox_api = FoxAPI(api_key, mock_base) - res = await fox_api.get_battery_charging_time('60KE8020479C034') - #await fox_api.start() + fox_api = FoxAPI(api_key, mock_base) + res = await fox_api.get_battery_charging_time("60KE8020479C034") + # await fox_api.start() def main(): @@ -701,14 +699,13 @@ def main(): """ parser = argparse.ArgumentParser(description="Test Fox API") parser.add_argument("--api-key", required=True, help="Fox API key") - + args = parser.parse_args() key = args.api_key - + # Run the test asyncio.run(test_fox_api(key)) if __name__ == "__main__": main() - From 7c7332927b042df0f59c0ec8acb00d3828c07162 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sun, 31 Aug 2025 20:35:27 +0100 Subject: [PATCH 06/16] Fox work --- apps/predbat/fox.py | 79 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/apps/predbat/fox.py b/apps/predbat/fox.py index 6b354b450..bc256f390 100644 --- a/apps/predbat/fox.py +++ b/apps/predbat/fox.py @@ -53,6 +53,7 @@ def __init__(self, key, base): self.device_settings = {} self.device_production = {} self.device_battery_charging_time = {} + self.device_scheduler = {} def wait_api_started(self): """ @@ -333,6 +334,8 @@ async def get_battery_charging_time(self, deviceSN): result = await self.request_get(GET_BATTERY_CHARGING_TIME, datain={'sn': deviceSN}, post=False) if result: self.device_battery_charging_time[deviceSN] = result + return result + return {} async def get_device_production(self, deviceSN): """ @@ -361,6 +364,50 @@ async def get_device_power_generation(self, deviceSN): if result: self.device_power_generation[deviceSN] = result + async def set_scheduler(self, deviceSN, groups): + SET_SCHEDULER = "/op/v1/device/scheduler/enable" + result = await self.request_get(SET_SCHEDULER, datain={'deviceSN': deviceSN, 'groups': groups}, post=True) + if result: + self.device_scheduler[deviceSN]['groups'] = groups + + async def get_scheduler(self, deviceSN): + """ + Get device scheduler + { + 'enable': 0, + 'groups': + [ + {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0}, + {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0}, + {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0}, + {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0}, + {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0}, + {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0}, + {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0}, + {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0} + ], + 'properties': + { + 'startminute': {'unit': '', 'precision': 1.0, 'range': {'min': 0.0, 'max': 59.0}}, + 'fdpwr': {'unit': 'W', 'precision': 1.0, 'range': {'min': 0.0, 'max': 10500.0}}, + 'endhour': {'unit': '', 'precision': 1.0, 'range': {'min': 0.0, 'max': 23.0}}, + 'endminute': {'unit': '', 'precision': 1.0, 'range': {'min': 0.0, 'max': 59.0}}, + 'fdsoc': {'unit': '%', 'precision': 1.0, 'range': {'min': 10.0, 'max': 100.0}}, + 'starthour': {'unit': '', 'precision': 1.0, 'range': {'min': 0.0, 'max': 23.0}}, + 'workmode': {'enumList': ['ForceDischarge', 'Feedin', 'SelfUse', 'ForceCharge'], 'unit': '', 'precision': 1.0}, + 'minsocongrid': {'unit': '%', 'precision': 1.0, 'range': {'min': 10.0, 'max': 100.0}}, + 'maxsoc': {'unit': '%', 'precision': 1.0, 'range': {'min': 10.0, 'max': 100.0}} + } + } + """ + GET_SCHEDULER = "/op/v1/device/scheduler/get" + result = await self.request_get(GET_SCHEDULER, datain={'deviceSN': deviceSN}, post=True) + print(result) + if result: + self.device_scheduler[deviceSN] = result + return result + return {} + async def get_device_list(self): """ [ @@ -413,7 +460,7 @@ async def request_get(self, path, post=False, datain=None): async def request_get_func(self, path, post=False, datain=None): headers = self.get_headers(path) url = FOX_DOMAIN + path - #print("Request: path {} post {} datain {} headers {}".format(path, post, datain, headers)) + print("Request: path {} post {} datain {} headers {}".format(path, post, datain, headers)) try: if post: if datain: @@ -688,10 +735,38 @@ async def test_fox_api(api_key): # Create a mock base object mock_base = MockBase() + + sn = '60KE8020479C034' # Create FoxAPI instance with a lambda that returns the API key fox_api = FoxAPI(api_key, mock_base) - res = await fox_api.get_battery_charging_time('60KE8020479C034') + res = await fox_api.get_battery_charging_time(sn) + print(res) + res = await fox_api.get_scheduler(sn) + groups = res.get('groups', []) + # {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0}, + new_slot = groups[0].copy() + new_slot['enable'] = 1 + new_slot['workMode'] = 'ForceCharge' + new_slot['startHour'] = 23 + new_slot['startMinute'] = 30 + new_slot['endHour'] = 23 + new_slot['endMinute'] = 59 + new_slot['fdSoc'] = 100 + new_slot['fdPwr'] = 8000 + new_slot2 = groups[1].copy() + new_slot2['enable'] = 1 + new_slot2['workMode'] = 'ForceCharge' + new_slot2['startHour'] = 0 + new_slot2['startMinute'] = 00 + new_slot2['endHour'] = 5 + new_slot2['endMinute'] = 29 + new_slot2['fdSoc'] = 100 + new_slot2['fdPwr'] = 8000 + + print("Sending: {}".format([new_slot, new_slot2])) + res = await fox_api.set_scheduler(sn, [new_slot, new_slot2]) + print(res) #await fox_api.start() From 15f1c8a7d79a22ea530598cc5299156a75e7862d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sun, 31 Aug 2025 19:37:48 +0000 Subject: [PATCH 07/16] [pre-commit.ci lite] apply automatic fixes --- apps/predbat/fox.py | 88 ++++++++++++++++++++++----------------------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/apps/predbat/fox.py b/apps/predbat/fox.py index 5b5691dfc..79d9232b1 100644 --- a/apps/predbat/fox.py +++ b/apps/predbat/fox.py @@ -364,42 +364,42 @@ async def get_device_power_generation(self, deviceSN): async def set_scheduler(self, deviceSN, groups): SET_SCHEDULER = "/op/v1/device/scheduler/enable" - result = await self.request_get(SET_SCHEDULER, datain={'deviceSN': deviceSN, 'groups': groups}, post=True) + result = await self.request_get(SET_SCHEDULER, datain={"deviceSN": deviceSN, "groups": groups}, post=True) if result: - self.device_scheduler[deviceSN]['groups'] = groups + self.device_scheduler[deviceSN]["groups"] = groups async def get_scheduler(self, deviceSN): """ Get device scheduler { - 'enable': 0, - 'groups': + 'enable': 0, + 'groups': [ - {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0}, - {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0}, - {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0}, - {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0}, - {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0}, - {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0}, - {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0}, + {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0}, + {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0}, + {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0}, + {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0}, + {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0}, + {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0}, + {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0}, {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0} - ], - 'properties': + ], + 'properties': { - 'startminute': {'unit': '', 'precision': 1.0, 'range': {'min': 0.0, 'max': 59.0}}, - 'fdpwr': {'unit': 'W', 'precision': 1.0, 'range': {'min': 0.0, 'max': 10500.0}}, - 'endhour': {'unit': '', 'precision': 1.0, 'range': {'min': 0.0, 'max': 23.0}}, - 'endminute': {'unit': '', 'precision': 1.0, 'range': {'min': 0.0, 'max': 59.0}}, - 'fdsoc': {'unit': '%', 'precision': 1.0, 'range': {'min': 10.0, 'max': 100.0}}, - 'starthour': {'unit': '', 'precision': 1.0, 'range': {'min': 0.0, 'max': 23.0}}, - 'workmode': {'enumList': ['ForceDischarge', 'Feedin', 'SelfUse', 'ForceCharge'], 'unit': '', 'precision': 1.0}, - 'minsocongrid': {'unit': '%', 'precision': 1.0, 'range': {'min': 10.0, 'max': 100.0}}, + 'startminute': {'unit': '', 'precision': 1.0, 'range': {'min': 0.0, 'max': 59.0}}, + 'fdpwr': {'unit': 'W', 'precision': 1.0, 'range': {'min': 0.0, 'max': 10500.0}}, + 'endhour': {'unit': '', 'precision': 1.0, 'range': {'min': 0.0, 'max': 23.0}}, + 'endminute': {'unit': '', 'precision': 1.0, 'range': {'min': 0.0, 'max': 59.0}}, + 'fdsoc': {'unit': '%', 'precision': 1.0, 'range': {'min': 10.0, 'max': 100.0}}, + 'starthour': {'unit': '', 'precision': 1.0, 'range': {'min': 0.0, 'max': 23.0}}, + 'workmode': {'enumList': ['ForceDischarge', 'Feedin', 'SelfUse', 'ForceCharge'], 'unit': '', 'precision': 1.0}, + 'minsocongrid': {'unit': '%', 'precision': 1.0, 'range': {'min': 10.0, 'max': 100.0}}, 'maxsoc': {'unit': '%', 'precision': 1.0, 'range': {'min': 10.0, 'max': 100.0}} } } """ GET_SCHEDULER = "/op/v1/device/scheduler/get" - result = await self.request_get(GET_SCHEDULER, datain={'deviceSN': deviceSN}, post=True) + result = await self.request_get(GET_SCHEDULER, datain={"deviceSN": deviceSN}, post=True) print(result) if result: self.device_scheduler[deviceSN] = result @@ -734,38 +734,38 @@ async def test_fox_api(api_key): # Create a mock base object mock_base = MockBase() - sn = '60KE8020479C034' - + sn = "60KE8020479C034" + # Create FoxAPI instance with a lambda that returns the API key - fox_api = FoxAPI(api_key, mock_base) + fox_api = FoxAPI(api_key, mock_base) res = await fox_api.get_battery_charging_time(sn) print(res) res = await fox_api.get_scheduler(sn) - groups = res.get('groups', []) - # {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0}, + groups = res.get("groups", []) + # {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0}, new_slot = groups[0].copy() - new_slot['enable'] = 1 - new_slot['workMode'] = 'ForceCharge' - new_slot['startHour'] = 23 - new_slot['startMinute'] = 30 - new_slot['endHour'] = 23 - new_slot['endMinute'] = 59 - new_slot['fdSoc'] = 100 - new_slot['fdPwr'] = 8000 + new_slot["enable"] = 1 + new_slot["workMode"] = "ForceCharge" + new_slot["startHour"] = 23 + new_slot["startMinute"] = 30 + new_slot["endHour"] = 23 + new_slot["endMinute"] = 59 + new_slot["fdSoc"] = 100 + new_slot["fdPwr"] = 8000 new_slot2 = groups[1].copy() - new_slot2['enable'] = 1 - new_slot2['workMode'] = 'ForceCharge' - new_slot2['startHour'] = 0 - new_slot2['startMinute'] = 00 - new_slot2['endHour'] = 5 - new_slot2['endMinute'] = 29 - new_slot2['fdSoc'] = 100 - new_slot2['fdPwr'] = 8000 + new_slot2["enable"] = 1 + new_slot2["workMode"] = "ForceCharge" + new_slot2["startHour"] = 0 + new_slot2["startMinute"] = 00 + new_slot2["endHour"] = 5 + new_slot2["endMinute"] = 29 + new_slot2["fdSoc"] = 100 + new_slot2["fdPwr"] = 8000 print("Sending: {}".format([new_slot, new_slot2])) res = await fox_api.set_scheduler(sn, [new_slot, new_slot2]) print(res) - #await fox_api.start() + # await fox_api.start() def main(): From 5a5450395a4d6c30bd86babcd348feaf764d1afb Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Mon, 1 Sep 2025 20:26:33 +0100 Subject: [PATCH 08/16] Fox schedule --- apps/predbat/fox.py | 266 +++++++++++++++++++++++++++++++++----------- 1 file changed, 198 insertions(+), 68 deletions(-) diff --git a/apps/predbat/fox.py b/apps/predbat/fox.py index 5b5691dfc..02998be35 100644 --- a/apps/predbat/fox.py +++ b/apps/predbat/fox.py @@ -29,6 +29,7 @@ FOX_LANG = "en" TIMEOUT = 60 FOX_SETTINGS = ["ExportLimit", "MaxSoc", "GridCode", "WorkMode", "ExportLimitPower", "MinSoc", "MinSocOnGrid"] +OPTIONS_WORK_MODE = ["SelfUse", "ForceCharge", "ForceDischarge", "Feedin"] # Dummy attribute table for testing fox_attribute_table = {"mode": {}} @@ -53,6 +54,9 @@ def __init__(self, key, base): self.device_production = {} self.device_battery_charging_time = {} self.device_scheduler = {} + self.device_current_schedule = {} + self.fdpwr_max = {} + self.fdsoc_min = {} def wait_api_started(self): """ @@ -93,6 +97,8 @@ async def start(self): await self.get_device_detail(sn) await self.get_device_settings(sn) await self.get_battery_charging_time(sn) + await self.get_scheduler(sn) + await self.compute_schedule(sn) if first or (count_seconds % (5 * 60)) == 0: for device in self.device_list: @@ -291,8 +297,11 @@ async def get_device_setting(self, deviceSN, key): if result: if deviceSN not in self.device_settings: self.device_settings[deviceSN] = {} + print("Device setting for {} = {}".format(deviceSN, result)) self.device_settings[deviceSN][key] = result return result + else: + print("Failed to get device setting for {} key {}".format(deviceSN, key)) return None async def set_device_setting(self, deviceSN, key, value): @@ -336,6 +345,57 @@ async def get_battery_charging_time(self, deviceSN): return result return {} + async def compute_schedule(self, deviceSN): + """ + Work out the current schedule by looking at battery charging times or scheduler settings + """ + battery_times = self.device_battery_charging_time.get(deviceSN, {}) + scheduler_times = self.device_scheduler.get(deviceSN, {}).get('groups', []) + device_scheduler_enabled = self.device_scheduler.get(deviceSN, {}).get('enabled', False) + + # First convert battery times into the same format as scheduer times + # Create an array of 0 - 2 slots containing the battery charge times + + minSocOnGrid = self.device_settings.get(deviceSN, {}).get('MinSocOnGrid', {}).get('value', 10) + MinSoc = self.device_settings.get(deviceSN, {}).get('MinSoc', {}).get('value', 10) + + battery_slots = [] + for i in range(0, 8): + if i < len(scheduler_times): + battery_slots.append(scheduler_times[i].copy()) + else: + battery_slots.append({ + 'startHour': 0, + 'startMinute': 0, + 'endHour': 0, + 'endMinute': 0, + 'enable': 0, + 'fdPwr': self.fdpwr_max.get(deviceSN, 8000), + 'workMode': "SelfUse", + 'fdSoc': 100, + 'minSocOnGrid': minSocOnGrid, + }) + + if not device_scheduler_enabled: + for i in [1, 2]: + start_time = battery_times.get(f'startTime{i}', {}) + end_time = battery_times.get(f'endTime{i}', {}) + enable = battery_times.get(f'enable{i}', False) + if start_time and end_time and enable: + battery_slots[i-1] = { + 'startHour': start_time.get('hour', 0), + 'startMinute': start_time.get('minute', 0), + 'endHour': end_time.get('hour', 0), + 'endMinute': end_time.get('minute', 0), + 'enable': 1, + 'fdPwr': 0, + 'workMode': "ForceCharge", + 'fdSoc': 100, + 'minSocOnGrid': minSocOnGrid, + } + self.device_current_schedule[deviceSN] = battery_slots + return battery_slots + async def get_device_production(self, deviceSN): """ [ @@ -400,9 +460,11 @@ async def get_scheduler(self, deviceSN): """ GET_SCHEDULER = "/op/v1/device/scheduler/get" result = await self.request_get(GET_SCHEDULER, datain={'deviceSN': deviceSN}, post=True) - print(result) if result: self.device_scheduler[deviceSN] = result + self.fdpwr_max[deviceSN] = result.get('properties', {}).get('fdpwr', {}).get('range', {}).get('max', 8000) + self.fdsoc_min[deviceSN] = result.get('properties', {}).get('fdsoc', {}).get('range', {}).get('min', 10) + return result return {} @@ -541,7 +603,7 @@ async def publish_data(self): item = self.device_settings[sn][setting] state = item.get("value", None) unit = item.get("unit", "") - range = item.get("range", {}) + value_range = item.get("range", {}) precision = item.get("precision", 1) enumList = item.get("enumList", []) @@ -554,10 +616,10 @@ async def publish_data(self): # Selector attributes["options"] = enumList entity_id = entity_name_select + "_" + sn.lower() + "_" + "setting_" + setting.lower() - elif range: + elif value_range: # Number - attributes["min"] = range.get("min", 0) - attributes["max"] = range.get("max", 100) + attributes["min"] = value_range.get("min", 0) + attributes["max"] = value_range.get("max", 100) attributes["step"] = precision entity_id = entity_name_number + "_" + sn.lower() + "_" + "setting_" + setting.lower() else: @@ -565,29 +627,32 @@ async def publish_data(self): entity_id = entity_name_sensor + "_" + sn.lower() + "_" + "setting_" + setting.lower() self.base.dashboard_item(entity_id, state=state, attributes=attributes, app="fox") - for sn in self.device_battery_charging_time: - setting = self.device_battery_charging_time[sn] - enable1 = setting.get("enable1", False) - enable2 = setting.get("enable2", False) - endTime1 = setting.get("endTime1", {}) - endTime2 = setting.get("endTime2", {}) - startTime1 = setting.get("startTime1", {}) - startTime2 = setting.get("startTime2", {}) - startTime1_str = f"{startTime1.get('hour', 0):02}:{startTime1.get('minute', 0):02}" + ":00" - startTime2_str = f"{startTime2.get('hour', 0):02}:{startTime2.get('minute', 0):02}" + ":00" - endTime1_str = f"{endTime1.get('hour', 0):02}:{endTime1.get('minute', 0):02}" + ":00" - endTime2_str = f"{endTime2.get('hour', 0):02}:{endTime2.get('minute', 0):02}" + ":00" - - # Times are selector - entity_id_battery_charging_time = entity_name_select + "_" + sn.lower() + "_battery_charging_time" - self.base.dashboard_item(entity_id_battery_charging_time + "_start1", state=startTime1_str, attributes={"friendly_name": f"Fox {sn} Battery Charging Time Start 1", "options": OPTIONS_TIME_FULL}, app="fox") - self.base.dashboard_item(entity_id_battery_charging_time + "_start2", state=startTime2_str, attributes={"friendly_name": f"Fox {sn} Battery Charging Time Start 2", "options": OPTIONS_TIME_FULL}, app="fox") - self.base.dashboard_item(entity_id_battery_charging_time + "_end1", state=endTime1_str, attributes={"friendly_name": f"Fox {sn} Battery Charging Time End 1", "options": OPTIONS_TIME_FULL}, app="fox") - self.base.dashboard_item(entity_id_battery_charging_time + "_end2", state=endTime2_str, attributes={"friendly_name": f"Fox {sn} Battery Charging Time End 2", "options": OPTIONS_TIME_FULL}, app="fox") - # Enable 1 and 2 are switches - entity_id_battery_charging_time = entity_name_switch + "_" + sn.lower() + "_battery_charging_time" - self.base.dashboard_item(entity_id_battery_charging_time + "_enable1", state="on" if enable1 else "off", attributes={"friendly_name": f"Fox {sn} Battery Charging Time Enable 1"}, app="fox") - self.base.dashboard_item(entity_id_battery_charging_time + "_enable2", state="on" if enable2 else "off", attributes={"friendly_name": f"Fox {sn} Battery Charging Time Enable 2"}, app="fox") + for sn in self.device_current_schedule: + current_schedule = self.device_current_schedule[sn].copy() + for n in range(0, 8): + this_schedule = current_schedule.pop(0) if current_schedule else None + print("Set schedule {} = {}".format(n, this_schedule)) + if this_schedule: + enable = this_schedule.get("enable", False) + startHour = this_schedule.get("startHour", 0) + endHour = this_schedule.get("endHour", 0) + startMinute = this_schedule.get("startMinute", 0) + endMinute = this_schedule.get("endMinute", 0) + startTime_str = f"{startHour:02}:{startMinute:02}:00" + endTime_str = f"{endHour:02}:{endMinute:02}:00" + workMode = this_schedule.get("workMode", "SelfUse") + else: + enable = False + workMode = "SelfUse" + startTime_str = "00:00:00" + endTime_str = "00:00:00" + + entity_id_battery_schedule_select = entity_name_select + "_" + sn.lower() + "_battery_schedule" + entity_id_battery_schedule_switch = entity_name_switch + "_" + sn.lower() + "_battery_schedule" + self.base.dashboard_item(entity_id_battery_schedule_select + f"_start{n}", state=startTime_str, attributes={"friendly_name": f"Fox {sn} Battery Schedule Time Start {n}", "options": OPTIONS_TIME_FULL}, app="fox") + self.base.dashboard_item(entity_id_battery_schedule_select + f"_end{n}", state=endTime_str, attributes={"friendly_name": f"Fox {sn} Battery Schedule Time End {n}", "options": OPTIONS_TIME_FULL}, app="fox") + self.base.dashboard_item(entity_id_battery_schedule_switch + f"_enable{n}", state="on" if enable else "off", attributes={"friendly_name": f"Fox {sn} Battery Schedule Enable {n}"}, app="fox") + self.base.dashboard_item(entity_id_battery_schedule_select + f"_workmode{n}", state=workMode, attributes={"friendly_name": f"Fox {sn} Battery Schedule Work Mode {n}", "options": OPTIONS_WORK_MODE}, app="fox") async def write_setting_from_event(self, entity_id, value, is_number=False): """ @@ -635,16 +700,16 @@ async def select_event(self, entity_id, value): """ if "_setting_" in entity_id: await self.write_setting_from_event(entity_id, value) - elif "_battery_charging_time_" in entity_id: - await self.write_battery_charging_time_event(entity_id, value) + elif "_battery_schedule" in entity_id: + await self.write_battery_schedule_event(entity_id, value) async def number_event(self, entity_id, value): if "_setting_" in entity_id: await self.write_setting_from_event(entity_id, value, is_number=True) async def switch_event(self, entity_id, service): - if "_battery_charging_time_" in entity_id: - await self.write_battery_charging_time_event(entity_id, service) + if "_battery_schedule" in entity_id: + await self.write_battery_schedule_event(entity_id, service) def apply_service_to_toggle(self, current_value, service): """ @@ -658,16 +723,38 @@ def apply_service_to_toggle(self, current_value, service): current_value = not current_value return current_value - async def write_battery_charging_time_event(self, entity_id, value): + def time_string_to_hour_minute(self, value, orig_hour, orig_minute): """ - Handle battery charging time events + Convert a time string in the format HH:MM to hour and minute integers. + """ + split_up = value.split(":") + if len(split_up) < 2: + return orig_hour, orig_minute + + hour = split_up[0] + minute = split_up[1] + try: + hour = int(hour) + minute = int(minute) + except ValueError: + hour = orig_hour + minute = orig_minute + if hour < 0 or hour > 23: + hour = orig_hour + if minute < 0 or minute > 59: + minute = orig_minute + return hour, minute + + async def write_battery_schedule_event(self, entity_id, value): + """ + Handle battery schedule events """ entity_id = entity_id.replace("switch.predbat_fox_", "") entity_id = entity_id.replace("select.predbat_fox_", "") sn = entity_id.split("_")[0] serial = None - for s in self.device_battery_charging_time: + for s in self.device_current_schedule: if s.lower() == sn.lower(): serial = s break @@ -675,42 +762,78 @@ async def write_battery_charging_time_event(self, entity_id, value): if not serial: self.log("Warn: Fox: Event, unknown serial number for {}: {}".format(entity_id, sn)) return + + # ID is last char of entity + try: + id = int(entity_id[-1]) + except ValueError: + self.log("Warn: Fox: Event, invalid ID for {}: {}".format(entity_id, entity_id[-1])) + return - state = self.device_battery_charging_time[serial].copy() - if "_battery_charging_time_enable1" in entity_id: - enable1 = state.get("enable1", False) - state["enable1"] = self.apply_service_to_toggle(enable1, value) - elif "_battery_charging_time_enable2" in entity_id: - enable2 = state.get("enable2", False) - state["enable2"] = self.apply_service_to_toggle(enable2, value) - elif "_battery_charging_time_start1" in entity_id: - start1 = state.get("startTime1", {"hour": 0, "minute": 0}) - start1["hour"] = int(value.split(":")[0]) - start1["minute"] = int(value.split(":")[1]) - state["startTime1"] = start1 - elif "_battery_charging_time_start2" in entity_id: - start2 = state.get("startTime2", {"hour": 0, "minute": 0}) - start2["hour"] = int(value.split(":")[0]) - start2["minute"] = int(value.split(":")[1]) - state["startTime2"] = start2 - elif "_battery_charging_time_end1" in entity_id: - end1 = state.get("endTime1", {"hour": 0, "minute": 0}) - end1["hour"] = int(value.split(":")[0]) - end1["minute"] = int(value.split(":")[1]) - state["endTime1"] = end1 - elif "_battery_charging_time_end2" in entity_id: - end2 = state.get("endTime2", {"hour": 0, "minute": 0}) - end2["hour"] = int(value.split(":")[0]) - end2["minute"] = int(value.split(":")[1]) - state["endTime2"] = end2 + current_schedule = self.device_current_schedule.get(serial, []).copy() + this_schedule = current_schedule[id] if id < len(current_schedule) else {} + enable = this_schedule.get("enable", False) + start_time = this_schedule.get("startTime", {"hour": 0, "minute": 0}) + end_time = this_schedule.get("endTime", {"hour": 0, "minute": 0}) + work_mode = this_schedule.get("workMode", "SelfUse") + + if "_battery_schedule_enable" in entity_id: + this_schedule['enable'] = self.apply_service_to_toggle(enable, value) + elif "_battery_schedule_start_time" in entity_id: + hour, minute = self.time_string_to_hour_minute(value, start_time["hour"], start_time["minute"]) + start_time["hour"] = hour + start_time["minute"] = minute + this_schedule["startTime"] = start_time + elif "_battery_schedule_end_time" in entity_id: + hour, minute = self.time_string_to_hour_minute(value, end_time["hour"], end_time["minute"]) + end_time["hour"] = hour + end_time["minute"] = minute + this_schedule["endTime"] = end_time + this_schedule["fdPwr"] = self.fdpwr_max.get(serial, 8000) + elif "_battery_schedule_workmode" in entity_id: + this_schedule["workMode"] = value if value in OPTIONS_WORK_MODE else work_mode + work_mode = this_schedule["workMode"] else: - self.log("Warn: Fox: Unknown battery charging time event for {}".format(entity_id)) + self.log("Warn: Fox: Unknown battery schedule event for {}".format(entity_id)) return - result = await self.set_battery_charging_time(serial, state) - if result is not None: - self.device_battery_charging_time[serial] = state - await self.publish_data() + this_schedule["minSocOnGrid"] = self.device_settings.get(serial, {}).get('MinSocOnGrid', {}).get('value', 10) + if work_mode == 'ForceDischarge': + this_schedule["fdPwr"] = self.fdpwr_max.get(serial, 8000) + this_schedule["fdSoc"] = self.fdsoc_min.get(serial, 10) + + current_schedule[id] = this_schedule + device_scheduler_enabled = self.device_scheduler.get(serial, {}).get('enabled', False) + for n in range(0, 8): + enabled = current_schedule[n].get("enable", False) + workMode = current_schedule[n].get("workMode", "SelfUse") + if n >= 2 and enabled: + device_scheduler_enabled = True + if enabled and workMode != "SelfUse": + device_scheduler_enabled = True + + if device_scheduler_enabled: + result = await self.set_scheduler(serial, current_schedule) + if result is not None: + self.device_current_schedule[serial] = current_schedule + self.device_scheduler[serial]['enabled'] = device_scheduler_enabled + await self.publish_data() + else: + new_battery_charging_time = self.device_battery_charging_time.get(serial, {}).copy() + for n in range(0, 2): + enabled = current_schedule[n].get("enable", False) + startTime = current_schedule[n].get("startTime", {"hour": 0, "minute": 0}) + endTime = current_schedule[n].get("endTime", {"hour": 0, "minute": 0}) + if n == 0: + new_battery_charging_time['startTime1'] = startTime + new_battery_charging_time['endTime1'] = endTime + else: + new_battery_charging_time['startTime2'] = startTime + new_battery_charging_time['endTime2'] = endTime + result = await self.set_battery_charging_time(serial, new_battery_charging_time) + if result is not None: + self.device_battery_charging_time[serial] = new_battery_charging_time + await self.publish_data() class MockBase: """Mock base class for testing""" @@ -738,9 +861,15 @@ async def test_fox_api(api_key): # Create FoxAPI instance with a lambda that returns the API key fox_api = FoxAPI(api_key, mock_base) + #await fox_api.start() + res = await fox_api.get_device_settings(sn) res = await fox_api.get_battery_charging_time(sn) - print(res) res = await fox_api.get_scheduler(sn) + res = await fox_api.compute_schedule(sn) + res = await fox_api.publish_data() + print(res) + + """ groups = res.get('groups', []) # {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0}, new_slot = groups[0].copy() @@ -766,6 +895,7 @@ async def test_fox_api(api_key): res = await fox_api.set_scheduler(sn, [new_slot, new_slot2]) print(res) #await fox_api.start() + """ def main(): From f25699e0224dc163974dd8a364070637144a0582 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Mon, 1 Sep 2025 19:28:45 +0000 Subject: [PATCH 09/16] [pre-commit.ci lite] apply automatic fixes --- apps/predbat/fox.py | 91 +++++++++++++++++++++++---------------------- 1 file changed, 47 insertions(+), 44 deletions(-) diff --git a/apps/predbat/fox.py b/apps/predbat/fox.py index fdb191549..16b3d5656 100644 --- a/apps/predbat/fox.py +++ b/apps/predbat/fox.py @@ -350,48 +350,50 @@ async def compute_schedule(self, deviceSN): Work out the current schedule by looking at battery charging times or scheduler settings """ battery_times = self.device_battery_charging_time.get(deviceSN, {}) - scheduler_times = self.device_scheduler.get(deviceSN, {}).get('groups', []) - device_scheduler_enabled = self.device_scheduler.get(deviceSN, {}).get('enabled', False) + scheduler_times = self.device_scheduler.get(deviceSN, {}).get("groups", []) + device_scheduler_enabled = self.device_scheduler.get(deviceSN, {}).get("enabled", False) # First convert battery times into the same format as scheduer times # Create an array of 0 - 2 slots containing the battery charge times - minSocOnGrid = self.device_settings.get(deviceSN, {}).get('MinSocOnGrid', {}).get('value', 10) - MinSoc = self.device_settings.get(deviceSN, {}).get('MinSoc', {}).get('value', 10) + minSocOnGrid = self.device_settings.get(deviceSN, {}).get("MinSocOnGrid", {}).get("value", 10) + MinSoc = self.device_settings.get(deviceSN, {}).get("MinSoc", {}).get("value", 10) battery_slots = [] for i in range(0, 8): if i < len(scheduler_times): battery_slots.append(scheduler_times[i].copy()) else: - battery_slots.append({ - 'startHour': 0, - 'startMinute': 0, - 'endHour': 0, - 'endMinute': 0, - 'enable': 0, - 'fdPwr': self.fdpwr_max.get(deviceSN, 8000), - 'workMode': "SelfUse", - 'fdSoc': 100, - 'minSocOnGrid': minSocOnGrid, - }) + battery_slots.append( + { + "startHour": 0, + "startMinute": 0, + "endHour": 0, + "endMinute": 0, + "enable": 0, + "fdPwr": self.fdpwr_max.get(deviceSN, 8000), + "workMode": "SelfUse", + "fdSoc": 100, + "minSocOnGrid": minSocOnGrid, + } + ) if not device_scheduler_enabled: for i in [1, 2]: - start_time = battery_times.get(f'startTime{i}', {}) - end_time = battery_times.get(f'endTime{i}', {}) - enable = battery_times.get(f'enable{i}', False) + start_time = battery_times.get(f"startTime{i}", {}) + end_time = battery_times.get(f"endTime{i}", {}) + enable = battery_times.get(f"enable{i}", False) if start_time and end_time and enable: - battery_slots[i-1] = { - 'startHour': start_time.get('hour', 0), - 'startMinute': start_time.get('minute', 0), - 'endHour': end_time.get('hour', 0), - 'endMinute': end_time.get('minute', 0), - 'enable': 1, - 'fdPwr': 0, - 'workMode': "ForceCharge", - 'fdSoc': 100, - 'minSocOnGrid': minSocOnGrid, + battery_slots[i - 1] = { + "startHour": start_time.get("hour", 0), + "startMinute": start_time.get("minute", 0), + "endHour": end_time.get("hour", 0), + "endMinute": end_time.get("minute", 0), + "enable": 1, + "fdPwr": 0, + "workMode": "ForceCharge", + "fdSoc": 100, + "minSocOnGrid": minSocOnGrid, } self.device_current_schedule[deviceSN] = battery_slots return battery_slots @@ -459,11 +461,11 @@ async def get_scheduler(self, deviceSN): } """ GET_SCHEDULER = "/op/v1/device/scheduler/get" - result = await self.request_get(GET_SCHEDULER, datain={'deviceSN': deviceSN}, post=True) + result = await self.request_get(GET_SCHEDULER, datain={"deviceSN": deviceSN}, post=True) if result: self.device_scheduler[deviceSN] = result - self.fdpwr_max[deviceSN] = result.get('properties', {}).get('fdpwr', {}).get('range', {}).get('max', 8000) - self.fdsoc_min[deviceSN] = result.get('properties', {}).get('fdsoc', {}).get('range', {}).get('min', 10) + self.fdpwr_max[deviceSN] = result.get("properties", {}).get("fdpwr", {}).get("range", {}).get("max", 8000) + self.fdsoc_min[deviceSN] = result.get("properties", {}).get("fdsoc", {}).get("range", {}).get("min", 10) return result return {} @@ -762,7 +764,7 @@ async def write_battery_schedule_event(self, entity_id, value): if not serial: self.log("Warn: Fox: Event, unknown serial number for {}: {}".format(entity_id, sn)) return - + # ID is last char of entity try: id = int(entity_id[-1]) @@ -778,7 +780,7 @@ async def write_battery_schedule_event(self, entity_id, value): work_mode = this_schedule.get("workMode", "SelfUse") if "_battery_schedule_enable" in entity_id: - this_schedule['enable'] = self.apply_service_to_toggle(enable, value) + this_schedule["enable"] = self.apply_service_to_toggle(enable, value) elif "_battery_schedule_start_time" in entity_id: hour, minute = self.time_string_to_hour_minute(value, start_time["hour"], start_time["minute"]) start_time["hour"] = hour @@ -797,13 +799,13 @@ async def write_battery_schedule_event(self, entity_id, value): self.log("Warn: Fox: Unknown battery schedule event for {}".format(entity_id)) return - this_schedule["minSocOnGrid"] = self.device_settings.get(serial, {}).get('MinSocOnGrid', {}).get('value', 10) - if work_mode == 'ForceDischarge': + this_schedule["minSocOnGrid"] = self.device_settings.get(serial, {}).get("MinSocOnGrid", {}).get("value", 10) + if work_mode == "ForceDischarge": this_schedule["fdPwr"] = self.fdpwr_max.get(serial, 8000) this_schedule["fdSoc"] = self.fdsoc_min.get(serial, 10) current_schedule[id] = this_schedule - device_scheduler_enabled = self.device_scheduler.get(serial, {}).get('enabled', False) + device_scheduler_enabled = self.device_scheduler.get(serial, {}).get("enabled", False) for n in range(0, 8): enabled = current_schedule[n].get("enable", False) workMode = current_schedule[n].get("workMode", "SelfUse") @@ -816,7 +818,7 @@ async def write_battery_schedule_event(self, entity_id, value): result = await self.set_scheduler(serial, current_schedule) if result is not None: self.device_current_schedule[serial] = current_schedule - self.device_scheduler[serial]['enabled'] = device_scheduler_enabled + self.device_scheduler[serial]["enabled"] = device_scheduler_enabled await self.publish_data() else: new_battery_charging_time = self.device_battery_charging_time.get(serial, {}).copy() @@ -825,16 +827,17 @@ async def write_battery_schedule_event(self, entity_id, value): startTime = current_schedule[n].get("startTime", {"hour": 0, "minute": 0}) endTime = current_schedule[n].get("endTime", {"hour": 0, "minute": 0}) if n == 0: - new_battery_charging_time['startTime1'] = startTime - new_battery_charging_time['endTime1'] = endTime + new_battery_charging_time["startTime1"] = startTime + new_battery_charging_time["endTime1"] = endTime else: - new_battery_charging_time['startTime2'] = startTime - new_battery_charging_time['endTime2'] = endTime + new_battery_charging_time["startTime2"] = startTime + new_battery_charging_time["endTime2"] = endTime result = await self.set_battery_charging_time(serial, new_battery_charging_time) if result is not None: self.device_battery_charging_time[serial] = new_battery_charging_time await self.publish_data() + class MockBase: """Mock base class for testing""" @@ -860,8 +863,8 @@ async def test_fox_api(api_key): sn = "60KE8020479C034" # Create FoxAPI instance with a lambda that returns the API key - fox_api = FoxAPI(api_key, mock_base) - #await fox_api.start() + fox_api = FoxAPI(api_key, mock_base) + # await fox_api.start() res = await fox_api.get_device_settings(sn) res = await fox_api.get_battery_charging_time(sn) res = await fox_api.get_scheduler(sn) @@ -871,7 +874,7 @@ async def test_fox_api(api_key): """ groups = res.get('groups', []) - # {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0}, + # {'endHour': 0, 'fdPwr': 0, 'minSocOnGrid': 10, 'workMode': 'Invalid', 'fdSoc': 10, 'enable': 0, 'startHour': 0, 'maxSoc': 100, 'startMinute': 0, 'endMinute': 0}, new_slot = groups[0].copy() new_slot["enable"] = 1 new_slot["workMode"] = "ForceCharge" From 81ec8a2c2e0fe3e2006d4270c27ac1bbd06983a9 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sat, 13 Sep 2025 10:23:54 +0100 Subject: [PATCH 10/16] Fox controls --- apps/predbat/config.py | 27 +++++++++++++++++++++++++++ apps/predbat/fox.py | 26 +++++++++++++++++++++----- apps/predbat/ha.py | 19 +++++++++---------- 3 files changed, 57 insertions(+), 15 deletions(-) diff --git a/apps/predbat/config.py b/apps/predbat/config.py index cf2889b68..166800525 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -1693,6 +1693,33 @@ "charge_discharge_with_rate": False, "target_soc_used_for_discharge": False, }, + "FoxCloud": { + "name" : "FoxCloud", + "has_rest_api": False, + "has_mqtt_api": False, + "output_charge_control": "power", + "charge_control_immediate": False, + "has_charge_enable_time": True, + "has_discharge_enable_time": True, + "has_target_soc": True, + "has_reserve_soc": True, + "has_timed_pause": False, + "charge_time_format": "HH:MM:SS", + "charge_time_entity_is_option": True, + "soc_units": "%", + "num_load_entities": 1, + "has_ge_inverter_mode": False, + "time_button_press": False, + "clock_time_format": "%Y-%m-%d %H:%M:%S", + "write_and_poll_sleep": 2, + "has_time_window": False, + "support_charge_freeze": True, + "support_discharge_freeze": True, + "has_idle_time": False, + "can_span_midnight": False, + "charge_discharge_with_rate": False, + "target_soc_used_for_discharge": True, + } } # Control modes for Solax inverters diff --git a/apps/predbat/fox.py b/apps/predbat/fox.py index fdb191549..5d4edc70b 100644 --- a/apps/predbat/fox.py +++ b/apps/predbat/fox.py @@ -641,18 +641,31 @@ async def publish_data(self): startTime_str = f"{startHour:02}:{startMinute:02}:00" endTime_str = f"{endHour:02}:{endMinute:02}:00" workMode = this_schedule.get("workMode", "SelfUse") + fdSoc = this_schedule.get("fdSoc", self.fdsoc_min.get(sn, 10)) + fdPwr = this_schedule.get("fdPwr", self.fdpwr_max.get(sn, 8000)) + maxSoc = this_schedule.get("maxSoc", 100) + minSocOnGrid = this_schedule.get("minSocOnGrid", self.fdsoc_min.get(sn, 10)) else: enable = False workMode = "SelfUse" startTime_str = "00:00:00" endTime_str = "00:00:00" + fdSoc = self.fdsoc_min.get(sn, 10) + fdPwr = self.fdpwr_max.get(sn, 8000) + maxSoc = 100 + minSocOnGrid = self.fdsoc_min.get(sn, 10) entity_id_battery_schedule_select = entity_name_select + "_" + sn.lower() + "_battery_schedule" entity_id_battery_schedule_switch = entity_name_switch + "_" + sn.lower() + "_battery_schedule" + entity_id_battery_schedule_number = entity_name_number + "_" + sn.lower() + "_battery_schedule" self.base.dashboard_item(entity_id_battery_schedule_select + f"_start{n}", state=startTime_str, attributes={"friendly_name": f"Fox {sn} Battery Schedule Time Start {n}", "options": OPTIONS_TIME_FULL}, app="fox") self.base.dashboard_item(entity_id_battery_schedule_select + f"_end{n}", state=endTime_str, attributes={"friendly_name": f"Fox {sn} Battery Schedule Time End {n}", "options": OPTIONS_TIME_FULL}, app="fox") self.base.dashboard_item(entity_id_battery_schedule_switch + f"_enable{n}", state="on" if enable else "off", attributes={"friendly_name": f"Fox {sn} Battery Schedule Enable {n}"}, app="fox") self.base.dashboard_item(entity_id_battery_schedule_select + f"_workmode{n}", state=workMode, attributes={"friendly_name": f"Fox {sn} Battery Schedule Work Mode {n}", "options": OPTIONS_WORK_MODE}, app="fox") + self.base.dashboard_item(entity_id_battery_schedule_number + f"_fdsoc{n}", state=fdSoc, attributes={"friendly_name": f"Fox {sn} Battery Schedule Force Discharge SOC {n}", "unit_of_measurement": "%", "min": 10, "max": 100, "step": 1}, app="fox") + self.base.dashboard_item(entity_id_battery_schedule_number + f"_fdpwr{n}", state=fdPwr, attributes={"friendly_name": f"Fox {sn} Battery Schedule Force Discharge Power {n}", "unit_of_measurement": "W", "min":0, "max": self.fdpwr_max.get(sn, 8000), "step": 100}, app="fox") + self.base.dashboard_item(entity_id_battery_schedule_number + f"_maxsoc{n}", state=maxSoc, attributes={"friendly_name": f"Fox {sn} Battery Schedule Force Charge Max SoC {n}", "unit_of_measurement": "%", "min": self.fdsoc_min.get(sn, 10), "max": 100, "step": 1}, app="fox") + self.base.dashboard_item(entity_id_battery_schedule_number + f"_minsocongrid{n}", state=minSocOnGrid, attributes={"friendly_name": f"Fox {sn} Battery Schedule Min SoC On Grid {n}", "unit_of_measurement": "%", "min": self.fdsoc_min.get(sn, 10), "max": 100, "step": 1}, app="fox") async def write_setting_from_event(self, entity_id, value, is_number=False): """ @@ -793,15 +806,18 @@ async def write_battery_schedule_event(self, entity_id, value): elif "_battery_schedule_workmode" in entity_id: this_schedule["workMode"] = value if value in OPTIONS_WORK_MODE else work_mode work_mode = this_schedule["workMode"] + elif "_battery_schedule_fdsoc" in entity_id: + this_schedule["fdSoc"] = value + elif "_battery_schedule_fdpwr" in entity_id: + this_schedule["fdPwr"] = value + elif "_battery_schedule_maxsoc" in entity_id: + this_schedule["maxSoc"] = value + elif "_battery_schedule_minsocongrid" in entity_id: + this_schedule["minSocOnGrid"] = value else: self.log("Warn: Fox: Unknown battery schedule event for {}".format(entity_id)) return - this_schedule["minSocOnGrid"] = self.device_settings.get(serial, {}).get('MinSocOnGrid', {}).get('value', 10) - if work_mode == 'ForceDischarge': - this_schedule["fdPwr"] = self.fdpwr_max.get(serial, 8000) - this_schedule["fdSoc"] = self.fdsoc_min.get(serial, 10) - current_schedule[id] = this_schedule device_scheduler_enabled = self.device_scheduler.get(serial, {}).get('enabled', False) for n in range(0, 8): diff --git a/apps/predbat/ha.py b/apps/predbat/ha.py index fbcb20c12..126d90840 100644 --- a/apps/predbat/ha.py +++ b/apps/predbat/ha.py @@ -56,11 +56,8 @@ def is_active(self): def is_alive(self): if not self.api_started: return False - if self.websocket_task: - if not self.websocket_task.is_alive(): - return False - if not self.websocket_active: - return False + if self.ha_key and not self.websocket_active: + return False return True def wait_api_started(self): @@ -91,7 +88,6 @@ def __init__(self, ha_url, ha_key, db_enable, db_mirror_ha, db_primary, base): self.db = None self.db_cursor = None self.websocket_active = False - self.websocket_task = None self.api_errors = 0 self.stop_thread = False self.api_started = False @@ -101,6 +97,9 @@ def __init__(self, ha_url, ha_key, db_enable, db_mirror_ha, db_primary, base): self.state_data = {} self.slug = None + print("HA URL", self.ha_url) + print("HA KEY", self.ha_key) + if not self.ha_key: if not (self.db_enable and self.db_primary): self.log("Error: ha_key or SUPERVISOR_TOKEN not found, you must set ha_url/ha_key in apps.yaml") @@ -131,10 +130,10 @@ def __init__(self, ha_url, ha_key, db_enable, db_mirror_ha, db_primary, base): self.api_started = True async def start(self): - """Async start not required""" - self.log("Info: Starting HA interface") - self.websocket_active = True - await self.socketLoop() + if self.ha_key: + self.log("Info: Starting HA interface") + self.websocket_active = True + await self.socketLoop() async def stop(self): self.stop_thread = True From 02b433e3eea441d2c0652d3a380afccd2d4fc2b2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sat, 13 Sep 2025 09:26:05 +0000 Subject: [PATCH 11/16] [pre-commit.ci lite] apply automatic fixes --- apps/predbat/config.py | 4 ++-- apps/predbat/fox.py | 25 +++++++++++++++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/apps/predbat/config.py b/apps/predbat/config.py index 166800525..fec090236 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -1694,7 +1694,7 @@ "target_soc_used_for_discharge": False, }, "FoxCloud": { - "name" : "FoxCloud", + "name": "FoxCloud", "has_rest_api": False, "has_mqtt_api": False, "output_charge_control": "power", @@ -1719,7 +1719,7 @@ "can_span_midnight": False, "charge_discharge_with_rate": False, "target_soc_used_for_discharge": True, - } + }, } # Control modes for Solax inverters diff --git a/apps/predbat/fox.py b/apps/predbat/fox.py index c101af746..436bee010 100644 --- a/apps/predbat/fox.py +++ b/apps/predbat/fox.py @@ -664,10 +664,27 @@ async def publish_data(self): self.base.dashboard_item(entity_id_battery_schedule_select + f"_end{n}", state=endTime_str, attributes={"friendly_name": f"Fox {sn} Battery Schedule Time End {n}", "options": OPTIONS_TIME_FULL}, app="fox") self.base.dashboard_item(entity_id_battery_schedule_switch + f"_enable{n}", state="on" if enable else "off", attributes={"friendly_name": f"Fox {sn} Battery Schedule Enable {n}"}, app="fox") self.base.dashboard_item(entity_id_battery_schedule_select + f"_workmode{n}", state=workMode, attributes={"friendly_name": f"Fox {sn} Battery Schedule Work Mode {n}", "options": OPTIONS_WORK_MODE}, app="fox") - self.base.dashboard_item(entity_id_battery_schedule_number + f"_fdsoc{n}", state=fdSoc, attributes={"friendly_name": f"Fox {sn} Battery Schedule Force Discharge SOC {n}", "unit_of_measurement": "%", "min": 10, "max": 100, "step": 1}, app="fox") - self.base.dashboard_item(entity_id_battery_schedule_number + f"_fdpwr{n}", state=fdPwr, attributes={"friendly_name": f"Fox {sn} Battery Schedule Force Discharge Power {n}", "unit_of_measurement": "W", "min":0, "max": self.fdpwr_max.get(sn, 8000), "step": 100}, app="fox") - self.base.dashboard_item(entity_id_battery_schedule_number + f"_maxsoc{n}", state=maxSoc, attributes={"friendly_name": f"Fox {sn} Battery Schedule Force Charge Max SoC {n}", "unit_of_measurement": "%", "min": self.fdsoc_min.get(sn, 10), "max": 100, "step": 1}, app="fox") - self.base.dashboard_item(entity_id_battery_schedule_number + f"_minsocongrid{n}", state=minSocOnGrid, attributes={"friendly_name": f"Fox {sn} Battery Schedule Min SoC On Grid {n}", "unit_of_measurement": "%", "min": self.fdsoc_min.get(sn, 10), "max": 100, "step": 1}, app="fox") + self.base.dashboard_item( + entity_id_battery_schedule_number + f"_fdsoc{n}", state=fdSoc, attributes={"friendly_name": f"Fox {sn} Battery Schedule Force Discharge SOC {n}", "unit_of_measurement": "%", "min": 10, "max": 100, "step": 1}, app="fox" + ) + self.base.dashboard_item( + entity_id_battery_schedule_number + f"_fdpwr{n}", + state=fdPwr, + attributes={"friendly_name": f"Fox {sn} Battery Schedule Force Discharge Power {n}", "unit_of_measurement": "W", "min": 0, "max": self.fdpwr_max.get(sn, 8000), "step": 100}, + app="fox", + ) + self.base.dashboard_item( + entity_id_battery_schedule_number + f"_maxsoc{n}", + state=maxSoc, + attributes={"friendly_name": f"Fox {sn} Battery Schedule Force Charge Max SoC {n}", "unit_of_measurement": "%", "min": self.fdsoc_min.get(sn, 10), "max": 100, "step": 1}, + app="fox", + ) + self.base.dashboard_item( + entity_id_battery_schedule_number + f"_minsocongrid{n}", + state=minSocOnGrid, + attributes={"friendly_name": f"Fox {sn} Battery Schedule Min SoC On Grid {n}", "unit_of_measurement": "%", "min": self.fdsoc_min.get(sn, 10), "max": 100, "step": 1}, + app="fox", + ) async def write_setting_from_event(self, entity_id, value, is_number=False): """ From 3e4c4e37b5312110ba2349a4121bae2ce2e185df Mon Sep 17 00:00:00 2001 From: Trefor Southwell <48591903+springfall2008@users.noreply.github.com> Date: Sat, 13 Sep 2025 10:30:09 +0100 Subject: [PATCH 12/16] Update custom-dictionary-workspace.txt --- .cspell/custom-dictionary-workspace.txt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index 21adb3584..6145cdc9b 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -1,4 +1,15 @@ # Custom Dictionary Words +starthour +endhour +startminute +endminute +afci +datas +fdsoc +fdpwr +minsocongrid +maxsoc +workmode activepower adata adbase From 50ef0651ae22c444d64f708ee3146123bc445d14 Mon Sep 17 00:00:00 2001 From: Trefor Southwell <48591903+springfall2008@users.noreply.github.com> Date: Sat, 13 Sep 2025 10:30:57 +0100 Subject: [PATCH 13/16] Add 'temperation' to custom dictionary --- .cspell/custom-dictionary-workspace.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index 6145cdc9b..c57d3290e 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -1,4 +1,5 @@ # Custom Dictionary Words +temperation starthour endhour startminute From 76df489358c240ddcd78f160d003b84efab75987 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sat, 13 Sep 2025 09:32:30 +0000 Subject: [PATCH 14/16] [pre-commit.ci lite] apply automatic fixes --- .cspell/custom-dictionary-workspace.txt | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index c57d3290e..7e7c36ea3 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -1,19 +1,8 @@ # Custom Dictionary Words -temperation -starthour -endhour -startminute -endminute -afci -datas -fdsoc -fdpwr -minsocongrid -maxsoc -workmode activepower adata adbase +afci AIO AIO's aiohttp @@ -59,6 +48,7 @@ dasharray datain datap datapoints +datas dayname daynumber daysymbol @@ -79,6 +69,8 @@ elif emszzzz enctype endfor +endhour +endminute endt Energi Energidata @@ -87,6 +79,8 @@ energydataservice epod euids evse +fdpwr +fdsoc feedin fentry Fingerbot @@ -149,6 +143,7 @@ luxpower markdownlint matplotlib maximise +maxsoc mdlrc measurand measurands @@ -159,6 +154,7 @@ metricb metricmid minb minmax +minsocongrid minuteb Mixergy mkdocs @@ -246,6 +242,8 @@ solisx sourcery Southwell springfall +starthour +startminute startt stdlib stepline @@ -255,6 +253,7 @@ tabindex tasmota tdata tdiff +temperation testname thirdparty timea @@ -277,6 +276,7 @@ venv Wallbox weblink welink +workmode writeonly xaxis xaxistooltip From e9489b230c4aa1b48e00f3fefbfcc4a95860a983 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sat, 13 Sep 2025 10:35:34 +0100 Subject: [PATCH 15/16] Typo --- apps/predbat/fox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/predbat/fox.py b/apps/predbat/fox.py index c101af746..d14bf06f4 100644 --- a/apps/predbat/fox.py +++ b/apps/predbat/fox.py @@ -353,7 +353,7 @@ async def compute_schedule(self, deviceSN): scheduler_times = self.device_scheduler.get(deviceSN, {}).get("groups", []) device_scheduler_enabled = self.device_scheduler.get(deviceSN, {}).get("enabled", False) - # First convert battery times into the same format as scheduer times + # First convert battery times into the same format as scheduler times # Create an array of 0 - 2 slots containing the battery charge times minSocOnGrid = self.device_settings.get(deviceSN, {}).get("MinSocOnGrid", {}).get("value", 10) From a47e29ee29f3262fafd61eeabb433aa341fc04e7 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sat, 13 Sep 2025 10:37:54 +0100 Subject: [PATCH 16/16] Remove unused imports --- apps/predbat/fox.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/predbat/fox.py b/apps/predbat/fox.py index 6a1b70524..a272dde5f 100644 --- a/apps/predbat/fox.py +++ b/apps/predbat/fox.py @@ -13,8 +13,6 @@ import time import hashlib import requests -import json -import sys import argparse import random