From 9d15bf246a9000e66451a30309f62bc7bba3ecc6 Mon Sep 17 00:00:00 2001 From: Matt Burns Date: Mon, 25 Sep 2023 22:29:54 +0100 Subject: [PATCH 01/12] New libbi action `chargefromgrid` --- pymyenergi/cli.py | 27 ++++++++-- pymyenergi/client.py | 1 - pymyenergi/connection.py | 111 ++++++++++++++++++++++++--------------- pymyenergi/libbi.py | 33 +++++++----- requirements_dev.txt | 1 + setup.py | 2 +- 6 files changed, 116 insertions(+), 59 deletions(-) diff --git a/pymyenergi/cli.py b/pymyenergi/cli.py index daadc09..1d2a863 100644 --- a/pymyenergi/cli.py +++ b/pymyenergi/cli.py @@ -27,8 +27,10 @@ async def main(args): username = args.username or input("Please enter your hub serial number: ") - password = args.password or getpass() - conn = Connection(username, password) + password = args.password or getpass(prompt="Password (apikey): ") + app_email = args.app_email or input("App email: ") + app_password = args.app_password or getpass(prompt="App password: ") + conn = Connection(username, password, app_password, app_email) if args.debug: logging.root.setLevel(logging.DEBUG) client = MyenergiClient(conn) @@ -92,6 +94,11 @@ async def main(args): sys.exit(f"A mode must be specifed, one of {modes}") await device.set_operating_mode(args.arg[0]) print(f"Operating mode was set to {args.arg[0].capitalize()}") + elif args.action == "chargefromgrid" and args.command == LIBBI: + if len(args.arg) < 1 or args.arg[0].capitalize() not in ["True", "False"]: + sys.exit(f"A mode must be specifed, one of true or false") + await device.set_charge_from_grid(args.arg[0]) + print(f"Charge from grid was set to {args.arg[0].capitalize()}") elif args.action == "mingreen" and args.command == ZAPPI: if len(args.arg) < 1: sys.exit("A minimum green level must be provided") @@ -148,7 +155,7 @@ async def main(args): def cli(): config = configparser.ConfigParser() - config["hub"] = {"serial": "", "password": ""} + config["hub"] = {"serial": "", "password": "", "app_password": "", "app_email": ""} config.read([".myenergi.cfg", os.path.expanduser("~/.myenergi.cfg")]) parser = argparse.ArgumentParser(prog="myenergi", description="myenergi CLI.") parser.add_argument( @@ -163,6 +170,18 @@ def cli(): dest="password", default=config.get("hub", "password").strip('"'), ) + parser.add_argument( + "-a", + "--app_password", + dest="app_password", + default=config.get("hub", "app_password").strip('"'), + ) + parser.add_argument( + "-e", + "--app_email", + dest="app_email", + default=config.get("hub", "app_email").strip('"'), + ) parser.add_argument("-d", "--debug", dest="debug", action="store_true") parser.add_argument("-j", "--json", dest="json", action="store_true", default=False) parser.add_argument("--version", dest="version", action="store_true", default=False) @@ -210,7 +229,7 @@ def cli(): LIBBI, help="use libbi --help for available commands" ) subparser_libbi.add_argument("-s", "--serial", dest="serial", default=None) - subparser_libbi.add_argument("action", choices=["show","mode","priority","energy"]) + subparser_libbi.add_argument("action", choices=["show","mode","priority","energy","chargefromgrid"]) subparser_libbi.add_argument("arg", nargs="*") args = parser.parse_args() diff --git a/pymyenergi/client.py b/pymyenergi/client.py index 1af62b3..e9aa41c 100644 --- a/pymyenergi/client.py +++ b/pymyenergi/client.py @@ -199,7 +199,6 @@ def power_charging(self): def power_battery(self): """Battery total power""" return self._totals.get(CT_BATTERY, 0) - def find_device_name(self, key, default_value): """Find device or site name""" diff --git a/pymyenergi/connection.py b/pymyenergi/connection.py index c0d3a3a..b9250e5 100644 --- a/pymyenergi/connection.py +++ b/pymyenergi/connection.py @@ -9,27 +9,36 @@ import httpx +from pycognito import Cognito + from .exceptions import MyenergiException from .exceptions import TimeoutException from .exceptions import WrongCredentials _LOGGER = logging.getLogger(__name__) - +_USER_POOL_ID = 'eu-west-2_E57cCJB20' +_CLIENT_ID = '2fup0dhufn5vurmprjkj599041' class Connection: """Connection to myenergi API.""" def __init__( - self, username: Text = None, password: Text = None, timeout: int = 20 + self, username: Text = None, password: Text = None, app_password: Text = None, app_email: Text = None, timeout: int = 20 ) -> None: """Initialize connection object.""" self.timeout = timeout self.director_url = "https://director.myenergi.net" self.base_url = None + self.oauth_base_url = "https://myaccount.myenergi.com" self.username = username self.password = password + self.app_password = app_password + self.app_email = app_email self.auth = httpx.DigestAuth(self.username, self.password) self.headers = {"User-Agent": "Wget/1.14 (linux-gnu)"} + self.oauth = Cognito(_USER_POOL_ID, _CLIENT_ID, username=self.app_email) + self.oauth.authenticate(password=self.app_password) + self.oauth_headers = {"Authorization": f"Bearer {self.oauth.access_token}"} self.do_query_asn = True _LOGGER.debug("New connection created") @@ -45,50 +54,70 @@ def _checkMyenergiServerURL(self, responseHeader): ) raise WrongCredentials() - async def send(self, method, url, json=None): - # If base URL has not been set, make a request to director to fetch it + async def send(self, method, url, json=None, oauth=False): + # Use OAuth for myaccount.myenergi.com + if oauth: + async with httpx.AsyncClient( + headers=self.oauth_headers, timeout=self.timeout + ) as httpclient: + theUrl = self.oauth_base_url + url + try: + _LOGGER.debug(f"{method} {url} {theUrl}") + response = await httpclient.request(method, theUrl, json=json) + except httpx.ReadTimeout: + raise TimeoutException() + else: + _LOGGER.debug(f"{method} status {response.status_code}") + if response.status_code == 200: + return response.json() + elif response.status_code == 401: + raise WrongCredentials() + raise MyenergiException(response.status_code) - async with httpx.AsyncClient( - auth=self.auth, headers=self.headers, timeout=self.timeout - ) as httpclient: - if self.base_url is None or self.do_query_asn: - _LOGGER.debug("Get Myenergi base url from director") + # Use Digest Auth for director.myenergi.net and s18.myenergi.net + else: + # If base URL has not been set, make a request to director to fetch it + async with httpx.AsyncClient( + auth=self.auth, headers=self.headers, timeout=self.timeout + ) as httpclient: + if self.base_url is None or self.do_query_asn: + _LOGGER.debug("Get Myenergi base url from director") + try: + directorUrl = self.director_url + "/cgi-jstatus-E" + response = await httpclient.get(directorUrl) + except Exception: + _LOGGER.error("Myenergi server request problem") + _LOGGER.debug(sys.exc_info()[0]) + else: + self.do_query_asn = False + self._checkMyenergiServerURL(response.headers) + theUrl = self.base_url + url try: - directorUrl = self.director_url + "/cgi-jstatus-E" - response = await httpclient.get(directorUrl) - except Exception: - _LOGGER.error("Myenergi server request problem") - _LOGGER.debug(sys.exc_info()[0]) + _LOGGER.debug(f"{method} {url} {theUrl}") + response = await httpclient.request(method, theUrl, json=json) + except httpx.ReadTimeout: + # Make sure to query for ASN next request, might be a server problem + self.do_query_asn = True + raise TimeoutException() else: - self.do_query_asn = False + _LOGGER.debug(f"GET status {response.status_code}") self._checkMyenergiServerURL(response.headers) - theUrl = self.base_url + url - try: - _LOGGER.debug(f"{method} {url} {theUrl}") - response = await httpclient.request(method, theUrl, json=json) - except httpx.ReadTimeout: - # Make sure to query for ASN next request, might be a server problem - self.do_query_asn = True - raise TimeoutException() - else: - _LOGGER.debug(f"GET status {response.status_code}") - self._checkMyenergiServerURL(response.headers) - if response.status_code == 200: - return response.json() - elif response.status_code == 401: - raise WrongCredentials() - # Make sure to query for ASN next request, might be a server problem - self.do_query_asn = True - raise MyenergiException(response.status_code) + if response.status_code == 200: + return response.json() + elif response.status_code == 401: + raise WrongCredentials() + # Make sure to query for ASN next request, might be a server problem + self.do_query_asn = True + raise MyenergiException(response.status_code) - async def get(self, url): - return await self.send("GET", url) + async def get(self, url, data=None, oauth=False): + return await self.send("GET", url, data, oauth) - async def post(self, url, data=None): - return await self.send("POST", url, data) + async def post(self, url, data=None, oauth=False): + return await self.send("POST", url, data, oauth) - async def put(self, url, data=None): - return await self.send("PUT", url, data) + async def put(self, url, data=None, oauth=False): + return await self.send("PUT", url, data, oauth) - async def delete(self, url, data=None): - return await self.send("DELETE", url, data) + async def delete(self, url, data=None, oauth=False): + return await self.send("DELETE", url, data, oauth) diff --git a/pymyenergi/libbi.py b/pymyenergi/libbi.py index 5a1969d..02d35bc 100644 --- a/pymyenergi/libbi.py +++ b/pymyenergi/libbi.py @@ -34,7 +34,7 @@ def __init__(self, connection: Connection, serialno, data={}) -> None: @property def kind(self): return LIBBI - + @property def status(self): """Get current known status""" @@ -43,7 +43,7 @@ def status(self): return STATES[n] else: return n - + @property def local_mode(self): """Get current known status""" @@ -68,22 +68,22 @@ def ct_keys(self): def ct3(self): """Current transformer 3""" return self._create_ct(3) - + @property def ct4(self): """Current transformer 4""" return self._create_ct(4) - + @property def ct5(self): """Current transformer 4""" return self._create_ct(5) - + @property def ct6(self): """Current transformer 4""" return self._create_ct(6) - + @property def supply_frequency(self): """Supply frequency in Hz""" @@ -118,12 +118,12 @@ def energy_total(self): def energy_green(self): """Device green energy from history data""" return self.history_data.get("device_green", 0) - + @property def state_of_charge(self): """State of Charge in %""" return self._data.get("soc", 0) - + @property def priority(self): """Current priority""" @@ -133,7 +133,7 @@ def priority(self): def battery_size(self): """Battery size in kwh""" return self._data.get("mbc", 0) /1000 - + @property def inverter_size(self): """Inverter size in kwh""" @@ -167,7 +167,7 @@ def generated(self): @property def prefix(self): return "L" - + async def set_operating_mode(self, mode: str): """Stopped or normal mode""" @@ -178,7 +178,16 @@ async def set_operating_mode(self, mode: str): ) self._data["lmo"] = LIBBI_MODE_NAMES[mode_int] return True - + + async def set_charge_from_grid(self, charge_from_grid: bool): + """Set charge from grid""" + await self._connection.put( + f"/api/AccountAccess/LibbiMode?chargeFromGrid={charge_from_grid}&serialNo={self._serialno}", + oauth=True + ) + self._data["charge_from_grid"] = charge_from_grid + return True + async def set_priority(self, priority): """Set device priority""" await self._connection.get( @@ -202,7 +211,7 @@ def show(self, short_format=False): ret = ret + f"Battery size: {self.battery_size}kWh\n" ret = ret + f"Inverter size: {self.inverter_size}kWh\n" ret = ret + f"State of Charge: {self.state_of_charge}%\n" - ret = ret + f"Generating: {self.power_generated}W\n" + ret = ret + f"Generating: {self.power_generated}W\n" ret = ret + f"Grid: {self.power_grid}W\n" ret = ret + f"Status : {self.status}\n" ret = ret + f"Local Mode : {self.local_mode}\n" diff --git a/requirements_dev.txt b/requirements_dev.txt index 1eb258c..9a9a2cd 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,2 +1,3 @@ pre-commit httpx +pycognito diff --git a/setup.py b/setup.py index a56f357..64ae9b7 100755 --- a/setup.py +++ b/setup.py @@ -20,7 +20,7 @@ license="MIT", packages=["pymyenergi"], python_requires=">=3.6", - install_requires=["httpx"], + install_requires=["httpx", "pycognito"], classifiers=[ "License :: OSI Approved :: MIT License", "Programming Language :: Python", From 61803c34e3734ab1a0547c9f76eedc15809576f4 Mon Sep 17 00:00:00 2001 From: trizmark Date: Thu, 12 Oct 2023 15:01:46 +0100 Subject: [PATCH 02/12] Added extra libbi functionality --- pymyenergi/cli.py | 1 + pymyenergi/client.py | 5 +++++ pymyenergi/connection.py | 18 ++++++++++++++++++ pymyenergi/libbi.py | 24 ++++++++++++++++++++---- 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/pymyenergi/cli.py b/pymyenergi/cli.py index 1d2a863..dae0d88 100644 --- a/pymyenergi/cli.py +++ b/pymyenergi/cli.py @@ -31,6 +31,7 @@ async def main(args): app_email = args.app_email or input("App email: ") app_password = args.app_password or getpass(prompt="App password: ") conn = Connection(username, password, app_password, app_email) + await conn.discoverLocations() if args.debug: logging.root.setLevel(logging.DEBUG) client = MyenergiClient(conn) diff --git a/pymyenergi/client.py b/pymyenergi/client.py index e9aa41c..0211b3f 100644 --- a/pymyenergi/client.py +++ b/pymyenergi/client.py @@ -247,6 +247,11 @@ async def refresh(self): f"Updating {existing_device.kind} {existing_device.name}" ) existing_device.data = device_data + + # Update the extra information available on libbi + # this is the bit that requires OAuth + if existing_device.kind == LIBBI: + await existing_device.refresh_extra() self._calculate_totals() async def refresh_history_today(self): diff --git a/pymyenergi/connection.py b/pymyenergi/connection.py index b9250e5..986440b 100644 --- a/pymyenergi/connection.py +++ b/pymyenergi/connection.py @@ -40,6 +40,7 @@ def __init__( self.oauth.authenticate(password=self.app_password) self.oauth_headers = {"Authorization": f"Bearer {self.oauth.access_token}"} self.do_query_asn = True + self.invitation_id = '' _LOGGER.debug("New connection created") def _checkMyenergiServerURL(self, responseHeader): @@ -54,6 +55,17 @@ def _checkMyenergiServerURL(self, responseHeader): ) raise WrongCredentials() + async def discoverLocations(self): + locs = await self.get("/api/Locations", oauth=True) + # check if guest location - use the first location by default + if locs["content"][0]["isGuestLocation"] == True: + self.invitation_id = locs["content"][0]["invitationData"]["invitationId"] + + def checkAndUpdateToken(self): + # check if we have to renew out token + self.oauth.check_token() + self.oauth_headers = {"Authorization": f"Bearer {self.oauth.access_token}"} + async def send(self, method, url, json=None, oauth=False): # Use OAuth for myaccount.myenergi.com if oauth: @@ -61,6 +73,12 @@ async def send(self, method, url, json=None, oauth=False): headers=self.oauth_headers, timeout=self.timeout ) as httpclient: theUrl = self.oauth_base_url + url + # if we have an invitiation id, we need to add that to the query + if (self.invitation_id != ""): + if ("?" in theUrl): + theUrl = theUrl + "&invitationId=" + self.invitation_id + else: + theUrl = theUrl + "?invitationId=" + self.invitation_id try: _LOGGER.debug(f"{method} {url} {theUrl}") response = await httpclient.request(method, theUrl, json=json) diff --git a/pymyenergi/libbi.py b/pymyenergi/libbi.py index 02d35bc..008e817 100644 --- a/pymyenergi/libbi.py +++ b/pymyenergi/libbi.py @@ -19,7 +19,12 @@ 7:'Duration Charging', 101:'Idle?', 102:'102', - 234:'Calibration Charge' } + 104:'Battery Full?', + 151:'FW Upgrade (ARM)', + 156:'FW Upgrade (DSP)', + 234:'Calibration Charge', + 251:'FW Upgrade (DSP)', + 252:'FW Upgrade (ARM)' } LIBBI_MODES = ["Stopped","Normal"] LIBBI_MODE_NAMES = ["STOP", "BALANCE"] @@ -29,8 +34,13 @@ class Libbi(BaseDevice): def __init__(self, connection: Connection, serialno, data={}) -> None: self.history_data = {} + self._extra_data = {} super().__init__(connection, serialno, data) + async def refresh_extra(self): + chargeFromGrid = await self._connection.get("/api/AccountAccess/LibbiMode?serialNo=" + str(self.serial_number), oauth=True) + self._extra_data["charge_from_grid"] = chargeFromGrid["content"][str(self.serial_number)] + @property def kind(self): return LIBBI @@ -163,6 +173,11 @@ def battery_discharge(self): def generated(self): """Solar generation from history data""" return self.history_data.get("generated", 0) + + @property + def charge_from_grid(self): + """Is charging from the grid enabled?""" + return self._extra_data.get("charge_from_grid") @property def prefix(self): @@ -175,7 +190,7 @@ async def set_operating_mode(self, mode: str): mode_int = LIBBI_MODES.index(mode.capitalize()) await self._connection.get( f"/cgi-libbi-mode-{self.prefix}{self._serialno}-{mode_int}" - ) + ) self._data["lmo"] = LIBBI_MODE_NAMES[mode_int] return True @@ -184,8 +199,8 @@ async def set_charge_from_grid(self, charge_from_grid: bool): await self._connection.put( f"/api/AccountAccess/LibbiMode?chargeFromGrid={charge_from_grid}&serialNo={self._serialno}", oauth=True - ) - self._data["charge_from_grid"] = charge_from_grid + ) + self._extra_data["charge_from_grid"] = charge_from_grid return True async def set_priority(self, priority): @@ -215,6 +230,7 @@ def show(self, short_format=False): ret = ret + f"Grid: {self.power_grid}W\n" ret = ret + f"Status : {self.status}\n" ret = ret + f"Local Mode : {self.local_mode}\n" + ret = ret + f"Charge from Grid: {self.charge_from_grid}\n" ret = ret + f"CT 1 {self.ct1.name} {self.ct1.power}W phase {self.ct1.phase}\n" ret = ret + f"CT 2 {self.ct2.name} {self.ct2.power}W phase {self.ct2.phase}\n" ret = ret + f"CT 3 {self.ct3.name} {self.ct3.power}W phase {self.ct3.phase}\n" From c73cf7e4fe70448316ef39d0ab922dc91ee923fa Mon Sep 17 00:00:00 2001 From: trizmark Date: Thu, 12 Oct 2023 15:04:00 +0100 Subject: [PATCH 03/12] Update version as well --- pymyenergi/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pymyenergi/VERSION b/pymyenergi/VERSION index 998726e..8db6c8a 100644 --- a/pymyenergi/VERSION +++ b/pymyenergi/VERSION @@ -1 +1 @@ -0.0.29 \ No newline at end of file +0.0.30 \ No newline at end of file From f3f313b70de726209af0e8f8653cb92a32d19134 Mon Sep 17 00:00:00 2001 From: trizmark Date: Fri, 13 Oct 2023 09:41:55 +0100 Subject: [PATCH 04/12] Update API endpoint for location discover --- pymyenergi/connection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pymyenergi/connection.py b/pymyenergi/connection.py index 986440b..15d5ff9 100644 --- a/pymyenergi/connection.py +++ b/pymyenergi/connection.py @@ -56,7 +56,7 @@ def _checkMyenergiServerURL(self, responseHeader): raise WrongCredentials() async def discoverLocations(self): - locs = await self.get("/api/Locations", oauth=True) + locs = await self.get("/api/Location", oauth=True) # check if guest location - use the first location by default if locs["content"][0]["isGuestLocation"] == True: self.invitation_id = locs["content"][0]["invitationData"]["invitationId"] From 454d0c8e51ef5a64f54b9e3b1e4e1e3285cb0e58 Mon Sep 17 00:00:00 2001 From: trizmark Date: Sat, 28 Oct 2023 15:00:04 +0100 Subject: [PATCH 05/12] Added export mode for libbi --- README.md | 15 +++++--- pymyenergi/cli.py | 13 +++++-- pymyenergi/libbi.py | 94 +++++++++++++++++++++++++++++---------------- 3 files changed, 80 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 877b175..e8e925e 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Setup will add a cli under the name myenergicli, see below for usage A simple cli is provided with this library. -If no username or password is supplied as input arguments and no configuration file is found you will be prompted. +If no username, password, app_email or app_password is supplied as input arguments and no configuration file is found you will be prompted. Conifguration file will be searched for in ./.myenergi.cfg and ~/.myenergi.cfg ### Example configuration file @@ -35,18 +35,20 @@ Conifguration file will be searched for in ./.myenergi.cfg and ~/.myenergi.cfg [hub] serial=12345678 password=yourpassword +app_email=myemail@email.com +app_password=yourapppassword ``` ### CLI usage ``` -usage: myenergi [-h] [-u USERNAME] [-p PASSWORD] [-d] [-j] - {list,overview,zappi,eddi,harvi} ... +usage: myenergi [-h] [-u USERNAME] [-p PASSWORD] [-e APP_EMAIL] [-a APP_PASSWORD] [-d] [-j] + {list,overview,zappi,eddi,harvi,libbi} ... myenergi CLI. positional arguments: - {list,overview,zappi,eddi,harvi} + {list,overview,zappi,eddi,harvi,libbi} sub-command help list list devices overview show overview @@ -150,16 +152,17 @@ Very early and basic support of Libbi. - Reads a few values such as State of Charge, DCPV CT - Battery in and out energy -- Gets and sets the current status +- Gets and sets the current operating mode (normal/stopped/export) - Change priority of Libbi +- Enable/Disable charging from the grid cli examples: ``` myenergi libbi show myenergi libbi mode normal -myenergi libbi mode stop myenergi libbi priority 1 myenergi libbi energy +myenergi libbi chargefromgrid disable ``` diff --git a/pymyenergi/cli.py b/pymyenergi/cli.py index dae0d88..7bdf3a7 100644 --- a/pymyenergi/cli.py +++ b/pymyenergi/cli.py @@ -96,8 +96,13 @@ async def main(args): await device.set_operating_mode(args.arg[0]) print(f"Operating mode was set to {args.arg[0].capitalize()}") elif args.action == "chargefromgrid" and args.command == LIBBI: - if len(args.arg) < 1 or args.arg[0].capitalize() not in ["True", "False"]: - sys.exit(f"A mode must be specifed, one of true or false") + if len(args.arg) < 1 or args.arg[0].capitalize() not in [ + "True", + "False", + "Enable", + "Disable", + ]: + sys.exit(f"A mode must be specifed, one of enable or disable") await device.set_charge_from_grid(args.arg[0]) print(f"Charge from grid was set to {args.arg[0].capitalize()}") elif args.action == "mingreen" and args.command == ZAPPI: @@ -230,7 +235,9 @@ def cli(): LIBBI, help="use libbi --help for available commands" ) subparser_libbi.add_argument("-s", "--serial", dest="serial", default=None) - subparser_libbi.add_argument("action", choices=["show","mode","priority","energy","chargefromgrid"]) + subparser_libbi.add_argument( + "action", choices=["show", "mode", "priority", "energy", "chargefromgrid"] + ) subparser_libbi.add_argument("arg", nargs="*") args = parser.parse_args() diff --git a/pymyenergi/libbi.py b/pymyenergi/libbi.py index 008e817..0131464 100644 --- a/pymyenergi/libbi.py +++ b/pymyenergi/libbi.py @@ -7,27 +7,36 @@ _LOGGER = logging.getLogger(__name__) -MODE_NORMAL = 1 -MODE_STOPPED = 0 - -STATES = { 0:'Off', - 1:'On', - 2:'Battery Full', - 4:'Idle', - 5:'Charging', - 6:'Discharging', - 7:'Duration Charging', - 101:'Idle?', - 102:'102', - 104:'Battery Full?', - 151:'FW Upgrade (ARM)', - 156:'FW Upgrade (DSP)', - 234:'Calibration Charge', - 251:'FW Upgrade (DSP)', - 252:'FW Upgrade (ARM)' } - -LIBBI_MODES = ["Stopped","Normal"] -LIBBI_MODE_NAMES = ["STOP", "BALANCE"] +STATES = { + 0: "Off", + 1: "On", + 2: "Battery Full", + 4: "Idle", + 5: "Charging", + 6: "Discharging", + 7: "Duration Charging", + 8: "Duration Drain", + 51: "Boosting", + 53: "Boosting", + 55: "Boosting", + 11: "Stopped", + 101: "Idle?", + 102: "Full", + 104: "Full", + 151: "FW Upgrade (ARM)", + 156: "FW Upgrade (DSP)", + 234: "Calibration Charge", + 251: "FW Upgrade (DSP)", + 252: "FW Upgrade (ARM)", +} + +LIBBI_MODES = ["Stopped", "Normal", "Export"] +LIBBI_MODE_CONFIG = { + "Stopped": {"mode_int": 0, "mode_name": "STOP"}, + "Normal": {"mode_int": 1, "mode_name": "BALANCE"}, + "Export": {"mode_int": 5, "mode_name": "DRAIN"}, +} +"""The myenergi app defines other modes as well (capture, charge, match), but these cannot be set""" class Libbi(BaseDevice): """Libbi Client for myenergi API.""" @@ -38,8 +47,13 @@ def __init__(self, connection: Connection, serialno, data={}) -> None: super().__init__(connection, serialno, data) async def refresh_extra(self): - chargeFromGrid = await self._connection.get("/api/AccountAccess/LibbiMode?serialNo=" + str(self.serial_number), oauth=True) - self._extra_data["charge_from_grid"] = chargeFromGrid["content"][str(self.serial_number)] + chargeFromGrid = await self._connection.get( + "/api/AccountAccess/LibbiMode?serialNo=" + str(self.serial_number), + oauth=True, + ) + self._extra_data["charge_from_grid"] = chargeFromGrid["content"][ + str(self.serial_number) + ] @property def kind(self): @@ -142,12 +156,12 @@ def priority(self): @property def battery_size(self): """Battery size in kwh""" - return self._data.get("mbc", 0) /1000 + return self._data.get("mbc", 0) / 1000 @property def inverter_size(self): """Inverter size in kwh""" - return self._data.get("mic", 0) /1000 + return self._data.get("mic", 0) / 1000 @property def grid_import(self): @@ -173,7 +187,7 @@ def battery_discharge(self): def generated(self): """Solar generation from history data""" return self.history_data.get("generated", 0) - + @property def charge_from_grid(self): """Is charging from the grid enabled?""" @@ -183,22 +197,32 @@ def charge_from_grid(self): def prefix(self): return "L" + def get_mode_description(self, mode: str): + """Get the mode name as returned by myenergi API. E.g. Normal mode is BALANCE""" + for k in LIBBI_MODE_CONFIG: + if LIBBI_MODE_CONFIG[k]["mode_name"] == mode: + return k + return "???" async def set_operating_mode(self, mode: str): - """Stopped or normal mode""" - print("current mode", self._data["lmo"]) - mode_int = LIBBI_MODES.index(mode.capitalize()) + """Set operating mode""" + print("current mode", self.get_mode_description(self._data["lmo"])) + mode_int = LIBBI_MODE_CONFIG[mode.capitalize()]["mode_int"] await self._connection.get( f"/cgi-libbi-mode-{self.prefix}{self._serialno}-{mode_int}" ) - self._data["lmo"] = LIBBI_MODE_NAMES[mode_int] + self._data["lmo"] = LIBBI_MODE_CONFIG[mode.capitalize()]["mode_name"] return True async def set_charge_from_grid(self, charge_from_grid: bool): """Set charge from grid""" + if charge_from_grid.capitalize() in ["Enable", "Disable"]: + charge_from_grid = ( + "True" if charge_from_grid.capitalize() == "Enable" else "False" + ) await self._connection.put( f"/api/AccountAccess/LibbiMode?chargeFromGrid={charge_from_grid}&serialNo={self._serialno}", - oauth=True + oauth=True, ) self._extra_data["charge_from_grid"] = charge_from_grid return True @@ -229,8 +253,12 @@ def show(self, short_format=False): ret = ret + f"Generating: {self.power_generated}W\n" ret = ret + f"Grid: {self.power_grid}W\n" ret = ret + f"Status : {self.status}\n" - ret = ret + f"Local Mode : {self.local_mode}\n" - ret = ret + f"Charge from Grid: {self.charge_from_grid}\n" + ret = ret + "Local Mode : " + self.get_mode_description(self.local_mode) + "\n" + ret = ret + "Charge from Grid: " + if self.charge_from_grid: + ret = ret + "Enabled\n" + else: + ret = ret + "Disabled\n" ret = ret + f"CT 1 {self.ct1.name} {self.ct1.power}W phase {self.ct1.phase}\n" ret = ret + f"CT 2 {self.ct2.name} {self.ct2.power}W phase {self.ct2.phase}\n" ret = ret + f"CT 3 {self.ct3.name} {self.ct3.power}W phase {self.ct3.phase}\n" From 0372ad7bce35a33a3d45ab2cf250ddf7bb19d7f4 Mon Sep 17 00:00:00 2001 From: trizmark Date: Sat, 28 Oct 2023 20:28:25 +0100 Subject: [PATCH 06/12] Able to get and set charge target --- README.md | 4 +++- pymyenergi/cli.py | 17 +++++++++++++++-- pymyenergi/libbi.py | 25 +++++++++++++++++++++++-- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e8e925e..86954a3 100644 --- a/README.md +++ b/README.md @@ -148,13 +148,14 @@ loop.run_until_complete(get_data()) ``` ## Libbi support -Very early and basic support of Libbi. +Currently supported features: - Reads a few values such as State of Charge, DCPV CT - Battery in and out energy - Gets and sets the current operating mode (normal/stopped/export) - Change priority of Libbi - Enable/Disable charging from the grid +- Set charge target (in Wh) cli examples: ``` @@ -163,6 +164,7 @@ myenergi libbi mode normal myenergi libbi priority 1 myenergi libbi energy myenergi libbi chargefromgrid disable +myenergi libbi chargetarget 10200 ``` diff --git a/pymyenergi/cli.py b/pymyenergi/cli.py index 7bdf3a7..1a4a39b 100644 --- a/pymyenergi/cli.py +++ b/pymyenergi/cli.py @@ -102,9 +102,14 @@ async def main(args): "Enable", "Disable", ]: - sys.exit(f"A mode must be specifed, one of enable or disable") + sys.exit("A mode must be specifed, one of enable or disable") await device.set_charge_from_grid(args.arg[0]) print(f"Charge from grid was set to {args.arg[0].capitalize()}") + elif args.action == "chargetarget" and args.command == LIBBI: + if len(args.arg) < 1 or not args.arg[0].isnumeric(): + sys.exit("The charge target must be specified in Wh") + await device.set_charge_target(args.arg[0]) + print(f"Charge target was set to {args.arg[0]}Wh") elif args.action == "mingreen" and args.command == ZAPPI: if len(args.arg) < 1: sys.exit("A minimum green level must be provided") @@ -236,7 +241,15 @@ def cli(): ) subparser_libbi.add_argument("-s", "--serial", dest="serial", default=None) subparser_libbi.add_argument( - "action", choices=["show", "mode", "priority", "energy", "chargefromgrid"] + "action", + choices=[ + "show", + "mode", + "priority", + "energy", + "chargefromgrid", + "chargetarget", + ], ) subparser_libbi.add_argument("arg", nargs="*") diff --git a/pymyenergi/libbi.py b/pymyenergi/libbi.py index 0131464..0aacb6c 100644 --- a/pymyenergi/libbi.py +++ b/pymyenergi/libbi.py @@ -38,6 +38,7 @@ } """The myenergi app defines other modes as well (capture, charge, match), but these cannot be set""" + class Libbi(BaseDevice): """Libbi Client for myenergi API.""" @@ -54,6 +55,11 @@ async def refresh_extra(self): self._extra_data["charge_from_grid"] = chargeFromGrid["content"][ str(self.serial_number) ] + chargeTarget = await self._connection.get( + "/api/AccountAccess/" + str(self.serial_number) + "/LibbiChargeSetup", + oauth=True, + ) + self._extra_data["charge_target"] = chargeTarget["content"]["energyTarget"] @property def kind(self): @@ -193,6 +199,11 @@ def charge_from_grid(self): """Is charging from the grid enabled?""" return self._extra_data.get("charge_from_grid") + @property + def charge_target(self): + """Libbi charge target""" + return self._extra_data.get("charge_target", 0) / 1000 + @property def prefix(self): return "L" @@ -235,6 +246,15 @@ async def set_priority(self, priority): self._data["pri"] = int(priority) return True + async def set_charge_target(self, charge_target: float): + """Set charge target""" + await self._connection.put( + f"/api/AccountAccess/{self._serialno}/TargetEnergy?targetEnergy={charge_target}", + oauth=True, + ) + self._extra_data["charge_target"] = charge_target + return True + def show(self, short_format=False): """Returns a string with all data in human readable format""" ret = "" @@ -252,13 +272,14 @@ def show(self, short_format=False): ret = ret + f"State of Charge: {self.state_of_charge}%\n" ret = ret + f"Generating: {self.power_generated}W\n" ret = ret + f"Grid: {self.power_grid}W\n" - ret = ret + f"Status : {self.status}\n" - ret = ret + "Local Mode : " + self.get_mode_description(self.local_mode) + "\n" + ret = ret + f"Status: {self.status}\n" + ret = ret + "Local Mode: " + self.get_mode_description(self.local_mode) + "\n" ret = ret + "Charge from Grid: " if self.charge_from_grid: ret = ret + "Enabled\n" else: ret = ret + "Disabled\n" + ret = ret + f"Charge target: {self.charge_target}kWh\n" ret = ret + f"CT 1 {self.ct1.name} {self.ct1.power}W phase {self.ct1.phase}\n" ret = ret + f"CT 2 {self.ct2.name} {self.ct2.power}W phase {self.ct2.phase}\n" ret = ret + f"CT 3 {self.ct3.name} {self.ct3.power}W phase {self.ct3.phase}\n" From 34083375d591f48f9b7535c5ae2865b6ac393792 Mon Sep 17 00:00:00 2001 From: trizmark Date: Sat, 28 Oct 2023 23:37:51 +0100 Subject: [PATCH 07/12] Fixed charge from grid --- pymyenergi/cli.py | 11 ++++++++++- pymyenergi/libbi.py | 4 ---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/pymyenergi/cli.py b/pymyenergi/cli.py index 1a4a39b..c2c0bca 100644 --- a/pymyenergi/cli.py +++ b/pymyenergi/cli.py @@ -103,7 +103,16 @@ async def main(args): "Disable", ]: sys.exit("A mode must be specifed, one of enable or disable") - await device.set_charge_from_grid(args.arg[0]) + if args.arg[0].capitalize() in [ + "Enable", + "Disable", + ]: + if args.arg[0].capitalize() == "Enable": + await device.set_charge_from_grid(True) + else: + await device.set_charge_from_grid(False) + else: + await device.set_charge_from_grid(args.arg[0]) print(f"Charge from grid was set to {args.arg[0].capitalize()}") elif args.action == "chargetarget" and args.command == LIBBI: if len(args.arg) < 1 or not args.arg[0].isnumeric(): diff --git a/pymyenergi/libbi.py b/pymyenergi/libbi.py index 0aacb6c..b07ae8c 100644 --- a/pymyenergi/libbi.py +++ b/pymyenergi/libbi.py @@ -227,10 +227,6 @@ async def set_operating_mode(self, mode: str): async def set_charge_from_grid(self, charge_from_grid: bool): """Set charge from grid""" - if charge_from_grid.capitalize() in ["Enable", "Disable"]: - charge_from_grid = ( - "True" if charge_from_grid.capitalize() == "Enable" else "False" - ) await self._connection.put( f"/api/AccountAccess/LibbiMode?chargeFromGrid={charge_from_grid}&serialNo={self._serialno}", oauth=True, From 5402856a774a401ab273ed068ece5c8b54be3fa6 Mon Sep 17 00:00:00 2001 From: trizmark Date: Sun, 29 Oct 2023 20:42:31 +0000 Subject: [PATCH 08/12] Added status 12 - Target Charge --- pymyenergi/libbi.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pymyenergi/libbi.py b/pymyenergi/libbi.py index b07ae8c..7fcf7cd 100644 --- a/pymyenergi/libbi.py +++ b/pymyenergi/libbi.py @@ -16,6 +16,7 @@ 6: "Discharging", 7: "Duration Charging", 8: "Duration Drain", + 12: "Target Charge", 51: "Boosting", 53: "Boosting", 55: "Boosting", From adb9ed55ac352e5b96b6698a4f0a27e7ad8cea66 Mon Sep 17 00:00:00 2001 From: trizmark Date: Sun, 29 Oct 2023 20:58:17 +0000 Subject: [PATCH 09/12] Removed extra options for chargefromgrid --- pymyenergi/cli.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/pymyenergi/cli.py b/pymyenergi/cli.py index c2c0bca..9782a46 100644 --- a/pymyenergi/cli.py +++ b/pymyenergi/cli.py @@ -99,20 +99,9 @@ async def main(args): if len(args.arg) < 1 or args.arg[0].capitalize() not in [ "True", "False", - "Enable", - "Disable", ]: - sys.exit("A mode must be specifed, one of enable or disable") - if args.arg[0].capitalize() in [ - "Enable", - "Disable", - ]: - if args.arg[0].capitalize() == "Enable": - await device.set_charge_from_grid(True) - else: - await device.set_charge_from_grid(False) - else: - await device.set_charge_from_grid(args.arg[0]) + sys.exit("A mode must be specifed, one of true or false") + await device.set_charge_from_grid(args.arg[0]) print(f"Charge from grid was set to {args.arg[0].capitalize()}") elif args.action == "chargetarget" and args.command == LIBBI: if len(args.arg) < 1 or not args.arg[0].isnumeric(): From a4a5b8ca9fe7234e4150b33489b8a9e7bfd2c732 Mon Sep 17 00:00:00 2001 From: trizmark Date: Sun, 29 Oct 2023 21:01:00 +0000 Subject: [PATCH 10/12] Updated readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 86954a3..8eeabf9 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ myenergi libbi show myenergi libbi mode normal myenergi libbi priority 1 myenergi libbi energy -myenergi libbi chargefromgrid disable +myenergi libbi chargefromgrid false myenergi libbi chargetarget 10200 ``` From 2a470c8410bd3b4b9e855c400b08f0a4f19879b7 Mon Sep 17 00:00:00 2001 From: trizmark Date: Sat, 2 Dec 2023 20:34:40 +0000 Subject: [PATCH 11/12] Added status 173 --- pymyenergi/libbi.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pymyenergi/libbi.py b/pymyenergi/libbi.py index 7fcf7cd..3b82318 100644 --- a/pymyenergi/libbi.py +++ b/pymyenergi/libbi.py @@ -26,6 +26,7 @@ 104: "Full", 151: "FW Upgrade (ARM)", 156: "FW Upgrade (DSP)", + 172: "BMS Charge Temperature Low", 234: "Calibration Charge", 251: "FW Upgrade (DSP)", 252: "FW Upgrade (ARM)", From ff1b1f92737a25d69e46bacc06f7fa39dbe7ecd8 Mon Sep 17 00:00:00 2001 From: trizmark Date: Fri, 19 Jan 2024 14:22:08 +0000 Subject: [PATCH 12/12] OAuth now optional --- README.md | 2 ++ pymyenergi/cli.py | 10 +++++-- pymyenergi/connection.py | 61 ++++++++++++++++++++++------------------ pymyenergi/libbi.py | 30 +++++++++++--------- 4 files changed, 60 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 8eeabf9..6a96aea 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ optional arguments: -h, --help show this help message and exit -u USERNAME, --username USERNAME -p PASSWORD, --password PASSWORD + -e APP_EMAIL, --app_email APP_EMAIL + -a APP_PASSWORD, --app_password APP_PASSWORD -d, --debug -j, --json ``` diff --git a/pymyenergi/cli.py b/pymyenergi/cli.py index 9782a46..deec9e0 100644 --- a/pymyenergi/cli.py +++ b/pymyenergi/cli.py @@ -28,10 +28,14 @@ async def main(args): username = args.username or input("Please enter your hub serial number: ") password = args.password or getpass(prompt="Password (apikey): ") - app_email = args.app_email or input("App email: ") - app_password = args.app_password or getpass(prompt="App password: ") + app_email = args.app_email or input("App email (enter to skip; only needed for libbi): ") + if app_email: + app_password = args.app_password or getpass(prompt="App password: ") + else: + app_password = '' conn = Connection(username, password, app_password, app_email) - await conn.discoverLocations() + if app_email and app_password: + await conn.discoverLocations() if args.debug: logging.root.setLevel(logging.DEBUG) client = MyenergiClient(conn) diff --git a/pymyenergi/connection.py b/pymyenergi/connection.py index 15d5ff9..9bac9d7 100644 --- a/pymyenergi/connection.py +++ b/pymyenergi/connection.py @@ -36,9 +36,10 @@ def __init__( self.app_email = app_email self.auth = httpx.DigestAuth(self.username, self.password) self.headers = {"User-Agent": "Wget/1.14 (linux-gnu)"} - self.oauth = Cognito(_USER_POOL_ID, _CLIENT_ID, username=self.app_email) - self.oauth.authenticate(password=self.app_password) - self.oauth_headers = {"Authorization": f"Bearer {self.oauth.access_token}"} + if self.app_email and app_password: + self.oauth = Cognito(_USER_POOL_ID, _CLIENT_ID, username=self.app_email) + self.oauth.authenticate(password=self.app_password) + self.oauth_headers = {"Authorization": f"Bearer {self.oauth.access_token}"} self.do_query_asn = True self.invitation_id = '' _LOGGER.debug("New connection created") @@ -62,35 +63,41 @@ async def discoverLocations(self): self.invitation_id = locs["content"][0]["invitationData"]["invitationId"] def checkAndUpdateToken(self): - # check if we have to renew out token - self.oauth.check_token() - self.oauth_headers = {"Authorization": f"Bearer {self.oauth.access_token}"} + # check if we have oauth credentials + if self.app_email and self.app_password: + # check if we have to renew out token + self.oauth.check_token() + self.oauth_headers = {"Authorization": f"Bearer {self.oauth.access_token}"} async def send(self, method, url, json=None, oauth=False): # Use OAuth for myaccount.myenergi.com if oauth: - async with httpx.AsyncClient( - headers=self.oauth_headers, timeout=self.timeout - ) as httpclient: - theUrl = self.oauth_base_url + url - # if we have an invitiation id, we need to add that to the query - if (self.invitation_id != ""): - if ("?" in theUrl): - theUrl = theUrl + "&invitationId=" + self.invitation_id + # check if we have oauth credentials + if self.app_email and self.app_password: + async with httpx.AsyncClient( + headers=self.oauth_headers, timeout=self.timeout + ) as httpclient: + theUrl = self.oauth_base_url + url + # if we have an invitiation id, we need to add that to the query + if (self.invitation_id != ""): + if ("?" in theUrl): + theUrl = theUrl + "&invitationId=" + self.invitation_id + else: + theUrl = theUrl + "?invitationId=" + self.invitation_id + try: + _LOGGER.debug(f"{method} {url} {theUrl}") + response = await httpclient.request(method, theUrl, json=json) + except httpx.ReadTimeout: + raise TimeoutException() else: - theUrl = theUrl + "?invitationId=" + self.invitation_id - try: - _LOGGER.debug(f"{method} {url} {theUrl}") - response = await httpclient.request(method, theUrl, json=json) - except httpx.ReadTimeout: - raise TimeoutException() - else: - _LOGGER.debug(f"{method} status {response.status_code}") - if response.status_code == 200: - return response.json() - elif response.status_code == 401: - raise WrongCredentials() - raise MyenergiException(response.status_code) + _LOGGER.debug(f"{method} status {response.status_code}") + if response.status_code == 200: + return response.json() + elif response.status_code == 401: + raise WrongCredentials() + raise MyenergiException(response.status_code) + else: + _LOGGER.error("Trying to use OAuth without app credentials") # Use Digest Auth for director.myenergi.net and s18.myenergi.net else: diff --git a/pymyenergi/libbi.py b/pymyenergi/libbi.py index 3b82318..4976535 100644 --- a/pymyenergi/libbi.py +++ b/pymyenergi/libbi.py @@ -21,7 +21,7 @@ 53: "Boosting", 55: "Boosting", 11: "Stopped", - 101: "Idle?", + 101: "Battery Empty", 102: "Full", 104: "Full", 151: "FW Upgrade (ARM)", @@ -50,18 +50,20 @@ def __init__(self, connection: Connection, serialno, data={}) -> None: super().__init__(connection, serialno, data) async def refresh_extra(self): - chargeFromGrid = await self._connection.get( - "/api/AccountAccess/LibbiMode?serialNo=" + str(self.serial_number), - oauth=True, - ) - self._extra_data["charge_from_grid"] = chargeFromGrid["content"][ - str(self.serial_number) - ] - chargeTarget = await self._connection.get( - "/api/AccountAccess/" + str(self.serial_number) + "/LibbiChargeSetup", - oauth=True, - ) - self._extra_data["charge_target"] = chargeTarget["content"]["energyTarget"] + # only refresh this data if we have app credentials + if self._connection.app_email and self._connection.app_password: + chargeFromGrid = await self._connection.get( + "/api/AccountAccess/LibbiMode?serialNo=" + str(self.serial_number), + oauth=True, + ) + self._extra_data["charge_from_grid"] = chargeFromGrid["content"][ + str(self.serial_number) + ] + chargeTarget = await self._connection.get( + "/api/AccountAccess/" + str(self.serial_number) + "/LibbiChargeSetup", + oauth=True, + ) + self._extra_data["charge_target"] = chargeTarget["content"]["energyTarget"] @property def kind(self): @@ -286,4 +288,6 @@ def show(self, short_format=False): ret = ret + f"CT 6 {self.ct6.name} {self.ct6.power}W phase {self.ct6.phase}\n" for key in self.ct_keys: ret = ret + f"Energy {key} {self.history_data.get(key, 0)}Wh\n" + if not self._connection.app_email or not self._connection.app_password: + ret += "No app credentials provided - the above information might not be totally accurate\n" return ret