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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions apps/predbat/gecloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import re
import aiohttp
import pytz
from datetime import timedelta, datetime, timezone
from utils import str2time, dp1, dp2, dp4
from predbat_metrics import record_api_call
Expand All @@ -28,6 +29,7 @@
"""

GE_API_URL = "https://api.givenergy.cloud/v1/"
GE_API_ACCOUNT = "account"
GE_API_INVERTER_STATUS = "inverter/{inverter_serial_number}/system-data/latest"
GE_API_INVERTER_METER = "inverter/{inverter_serial_number}/meter-data/latest"
GE_API_INVERTER_SETTINGS = "inverter/{inverter_serial_number}/settings"
Expand All @@ -48,6 +50,11 @@

GE_REGISTER_BATTERY_CUTOFF_LIMIT = 75

# How long the cached customer account details stay valid for before they are fetched again
ACCOUNT_MAX_AGE_MINUTES = 24 * 60
# How long to wait before retrying a failed account fetch
ACCOUNT_RETRY_MINUTES = 30

# 0 Current.Export Instantaneous current flow from EV
# 1 Current.Import Instantaneous current flow to EV
# 2 Current.Offered Maximum current offered to EV
Expand Down Expand Up @@ -204,6 +211,8 @@
"battery_soh": {"friendly_name": "Battery State of Health", "icon": "mdi:battery", "unit_of_measurement": "*", "device_class": "battery"},
"battery_dod_soh": {"friendly_name": "Battery Depth of Discharge Adjusted for State of Health", "icon": "mdi:battery", "unit_of_measurement": "*", "device_class": "battery"},
"model": {"friendly_name": "Model", "icon": "mdi:information", "unit_of_measurement": None},
"account": {"friendly_name": "GE Cloud Account", "icon": "mdi:account", "unit_of_measurement": None},
"timezone": {"friendly_name": "GE Cloud Account Timezone", "icon": "mdi:map-clock", "unit_of_measurement": None},
}

BASE_TIME = datetime.strptime("00:00", "%H:%M")
Expand Down Expand Up @@ -251,10 +260,90 @@ def initialize(self, ge_cloud_direct, api_key, automatic):
self.settings_from_cache = False
self.default_options_stamp = None

# Customer account details, including the timezone the inverter register times are expressed in
self.account = {}
self.account_timezone = None
self.account_timezone_name = None
self.account_stamp = None
self.account_fetch_stamp = None

# API request metrics for monitoring
self.requests_total = 0
self.failures_total = 0

def set_account_timezone(self, account):
"""
Record the customer timezone taken from the GE Cloud account details.

The inverter start/end time registers are held in the account timezone, which is not
necessarily the timezone Predbat is running in, so it is stored here to translate them.
"""
tz_name = account.get("standard_timezone", None) or account.get("timezone", None)
if not tz_name:
self.log("GECloud: Warn: No timezone found in account details, using the Predbat timezone for register times")
return
if tz_name == self.account_timezone_name:
return

try:
account_tz = pytz.timezone(tz_name)
except pytz.exceptions.UnknownTimeZoneError:
self.log("GECloud: Warn: Unknown account timezone {}, using the Predbat timezone for register times".format(tz_name))
return

self.account_timezone = account_tz
self.account_timezone_name = tz_name
self.log("GECloud: Account timezone is {}, offset from the Predbat timezone is {} minutes".format(tz_name, self.get_timezone_offset_minutes()))

def get_timezone_offset_minutes(self):
"""
Return how many minutes ahead of the Predbat timezone the customer account timezone is right now.

Returns 0 when the account timezone is unknown, so times are left as-is.
"""
if self.account_timezone is None or self.local_tz is None:
return 0

now = datetime.now(timezone.utc)
account_offset = now.astimezone(self.account_timezone).utcoffset()
local_offset = now.astimezone(self.local_tz).utcoffset()
if account_offset is None or local_offset is None:
return 0
return int((account_offset - local_offset).total_seconds() // 60)

def shift_time_string(self, value, offset_minutes):
"""
Shift a HH:MM or HH:MM:SS register time string by the given number of minutes, wrapping at midnight.
"""
if not offset_minutes or not isinstance(value, str):
return value

parts = value.strip().split(":")
if len(parts) < 2:
return value
try:
total = int(parts[0]) * 60 + int(parts[1])
except ValueError:
return value

total = (total + offset_minutes) % (24 * 60)
shifted = "{:02d}:{:02d}".format(total // 60, total % 60)
if len(parts) > 2:
shifted += ":" + parts[2]
return shifted

def register_time_to_local(self, value):
"""
Convert a time register value from the customer account timezone into the Predbat timezone.
"""
return self.shift_time_string(value, -self.get_timezone_offset_minutes())

def local_time_to_register(self, value):
"""
Convert a time from the Predbat timezone into the customer account timezone for writing to a register.
"""
return self.shift_time_string(value, self.get_timezone_offset_minutes())

async def switch_event(self, entity_id, service):
"""
Switch event
Expand Down Expand Up @@ -357,6 +446,8 @@ async def select_event(self, entity_id, value):

is_time = mapping.get("time", False)
if is_time:
# The register is held in the customer account timezone, the value we are given is in the Predbat timezone
new_value = self.local_time_to_register(new_value)
# We actually write as HH:MM
new_value = new_value[:5]

Expand Down Expand Up @@ -392,6 +483,33 @@ def get_max_inverter_rate_from_model(self, model, max_charge_rate=None):
pass
return max_charge_rate

async def publish_account(self, account):
"""
Publish the customer account details and the account timezone as sensors.

The account sensor uses the account name as its state; the timezone sensor uses the timezone name as its state. All remaining values are stored in a 'data' attribute.
"""
Comment thread
Copilot marked this conversation as resolved.
if not account:
return

entity_name = f"sensor.{self.prefix}_gecloud".lower()

account_data = {key: value for key, value in account.items() if key != "name"}
attributes = dict(attribute_table.get("account", {}))
attributes["data"] = account_data
self.dashboard_item(entity_name + "_account", state=account.get("name", "unknown"), attributes=attributes, app="gecloud")

timezone_name = self.account_timezone_name or account.get("standard_timezone", None) or account.get("timezone", None)
timezone_data = {
"timezone": account.get("timezone", None),
"standard_timezone": account.get("standard_timezone", None),
"predbat_timezone": str(self.local_tz) if self.local_tz else None,
"offset_minutes": self.get_timezone_offset_minutes(),
}
attributes = dict(attribute_table.get("timezone", {}))
attributes["data"] = timezone_data
self.dashboard_item(entity_name + "_timezone", state=timezone_name if timezone_name else "unknown", attributes=attributes, app="gecloud")

async def publish_info(self, device, device_info):
"""
Publish the device info
Expand Down Expand Up @@ -758,6 +876,8 @@ async def publish_registers(self, device, registers, select_key=None):
if validation_rule.startswith("date_format:H:i"):
is_select_time = True
options_text = OPTIONS_TIME_FULL
# The register is held in the customer account timezone, publish it in the Predbat timezone
value = self.register_time_to_local(value)
if isinstance(value, str) and len(value) == 5:
value = value + ":00"
attributes["device_class"] = "time"
Expand Down Expand Up @@ -1054,6 +1174,9 @@ async def run(self, seconds, first):
Start the client
"""

# The account details change rarely, so they are cached in storage and only re-fetched once a day
await self.update_account(first)

if first:
self.polling_mode = True
# Get devices using the modified auto-detection (returns dict)
Expand Down Expand Up @@ -1512,6 +1635,80 @@ async def async_get_device_info(self, serial, previous={}):
return inverter
return previous

async def load_account_from_storage(self):
"""
Restore the customer account details cached by a previous run so a restart does not have to fetch them again.
"""
if not self.storage:
return

cached_account = await self.storage.load("gecloud", "account")
if not isinstance(cached_account, dict) or not cached_account:
self.log("GECloud: No valid account details found in storage cache, will fetch")
return

account_age = await self.storage.age("gecloud", "account")

# Keep the cached details even when stale so that a failed fetch still leaves something usable
self.account = cached_account
self.set_account_timezone(cached_account)

if account_age is not None and account_age < ACCOUNT_MAX_AGE_MINUTES:
self.account_stamp = self.now_utc_exact - timedelta(minutes=account_age)
self.log("GECloud: Restored account details from storage cache (age {:.1f} minutes)".format(account_age))
else:
self.log("GECloud: Storage cache for the account details is stale (age {}), will re-fetch".format("{:.1f} minutes".format(account_age) if account_age is not None else "unknown"))

async def update_account(self, first):
"""
Keep the customer account details up to date and published.

On startup they are restored from storage, and they are only re-fetched from the API once a day.
"""
if first:
await self.load_account_from_storage()
if self.account:
await self.publish_account(self.account)

now_utc = self.now_utc_exact

# Nothing to do while the details we hold are still within their lifetime
if self.account_stamp is not None and (now_utc - self.account_stamp) < timedelta(minutes=ACCOUNT_MAX_AGE_MINUTES):
return

# A failed fetch retries after a short delay rather than a full day, but not on every 60 second
# run() tick, so a sustained API outage does not turn into a poll loop
if self.account_fetch_stamp is not None and (now_utc - self.account_fetch_stamp) < timedelta(minutes=ACCOUNT_RETRY_MINUTES):
return

self.account_fetch_stamp = now_utc
account = await self.async_get_account()
if not account:
return

# Only treat the details as fresh once we actually have them
self.account_stamp = now_utc
if self.storage:
await self.storage.save("gecloud", "account", account, format="json", expiry=None)
await self.publish_account(self.account)

async def async_get_account(self):
"""
Get the customer account details from GE Cloud and record the account timezone.

{'id': 2, 'name': 'francesca.holmes.285', 'first_name': 'Maisie', 'surname': 'Walker', 'role': 'VIEWER',
'email': 'joshua94@martin.com', 'address': '18 Hunt Landing', 'postcode': 'CT6 9AR', 'country': 'UNITED_KINGDOM',
'telephone_number': '+44(0)7559 260236', 'timezone': 'GMT', 'standard_timezone': 'Europe/London',
'company': None, 'flags': []}
"""
account = await self.async_get_inverter_data_retry(GE_API_ACCOUNT)
if not account:
return self.account

self.account = account
self.set_account_timezone(account)
return account

async def async_get_devices(self):
"""
Get list of inverters from GE Cloud.
Expand Down
2 changes: 1 addition & 1 deletion apps/predbat/predbat.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
import pytz
import asyncio

THIS_VERSION = "v8.47.6"
THIS_VERSION = "v8.47.7"

from download import predbat_update_move, predbat_update_download, check_install, DEFAULT_PREDBAT_REPOSITORY
from const import MINUTE_WATT
Expand Down
Loading
Loading