diff --git a/custom_components/battery_sim/__init__.py b/custom_components/battery_sim/__init__.py index 4d3878b..36b15dc 100644 --- a/custom_components/battery_sim/__init__.py +++ b/custom_components/battery_sim/__init__.py @@ -1,12 +1,25 @@ """Simulates a battery to evaluate how much energy it could save.""" -import logging +import logging, time import voluptuous as vol from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import CONF_NAME from homeassistant.helpers import discovery +from homeassistant.helpers.dispatcher import dispatcher_send, async_dispatcher_connect import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.core import callback + +from homeassistant.const import ( + CONF_NAME, + ENERGY_WATT_HOUR, + ENERGY_KILO_WATT_HOUR, + ATTR_UNIT_OF_MEASUREMENT, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + EVENT_HOMEASSISTANT_START +) from .const import ( CONF_BATTERY, @@ -17,8 +30,18 @@ CONF_ENERGY_TARIFF, CONF_IMPORT_SENSOR, CONF_EXPORT_SENSOR, - DATA_UTILITY, DOMAIN, + BATTERY_PLATFORMS, + OVERIDE_CHARGING, + PAUSE_BATTERY, + ATTR_ENERGY_SAVED, + ATTR_ENERGY_BATTERY_OUT, + ATTR_ENERGY_BATTERY_IN, + CHARGING_RATE, + DISCHARGING_RATE, + GRID_EXPORT_SIM, + GRID_IMPORT_SIM, + ATTR_MONEY_SAVED ) _LOGGER = logging.getLogger(__name__) @@ -43,32 +66,295 @@ ) async def async_setup(hass, config): - hass.data[DATA_UTILITY] = {} + """Set up platform from a YAML.""" + hass.data.setdefault(DOMAIN, {}) if config.get(DOMAIN)!= None: for battery, conf in config.get(DOMAIN).items(): _LOGGER.debug("Setup %s.%s", DOMAIN, battery) + handle = SimulatedBatteryHandle(conf, hass) + if (battery in hass.data[DOMAIN]): + _LOGGER.warning("Battery name not unique - not able to create.") + continue + hass.data[DOMAIN][battery] = handle - hass.data[DATA_UTILITY][battery] = conf - - hass.async_create_task( - discovery.async_load_platform( - hass, - SENSOR_DOMAIN, - DOMAIN, - [{CONF_BATTERY: battery, CONF_NAME: conf.get(CONF_NAME, battery)}], - config, + for platform in BATTERY_PLATFORMS: + hass.async_create_task( + discovery.async_load_platform( + hass, + platform, + DOMAIN, + [{CONF_BATTERY: battery, CONF_NAME: conf.get(CONF_NAME, battery)}], + config, + ) ) - ) return True async def async_setup_entry(hass, entry) -> bool: """Set up platform from a ConfigEntry.""" hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = entry.data + + _LOGGER.debug("Setup %s.%s", DOMAIN, entry.data[CONF_NAME]) + handle = SimulatedBatteryHandle(entry.data, hass) + hass.data[DOMAIN][entry.entry_id] = handle # Forward the setup to the sensor platform. - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, "sensor") - ) + for platform in BATTERY_PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) return True + +class SimulatedBatteryHandle(): + """Representation of the battery itself""" + + def __init__( + self, + config, + hass + ): + + """Initialize the Battery.""" + self._hass = hass + self._import_sensor_id = config[CONF_IMPORT_SENSOR] + self._export_sensor_id = config[CONF_EXPORT_SENSOR] + if CONF_ENERGY_TARIFF not in config: + self._tariff_sensor_id = "none" + else: + self._tariff_sensor_id = config[CONF_ENERGY_TARIFF] + self._date_recording_started = time.asctime() + self._collecting1 = None + self._collecting2 = None + self._charging = False + self._name = config[CONF_NAME] + self._battery_size = config[CONF_BATTERY_SIZE] + self._max_discharge_rate = config[CONF_BATTERY_MAX_DISCHARGE_RATE] + self._max_charge_rate = config[CONF_BATTERY_MAX_CHARGE_RATE] + self._battery_efficiency = config[CONF_BATTERY_EFFICIENCY] + self._last_import_reading_time = time.time() + self._last_export_reading_time = time.time() + self._last_battery_update_time = time.time() + self._max_discharge = 0.0 + self._charge_percentage = 0.0 + self._charge_state = 0.0 + self._last_export_reading = 0.0 + self._last_import_cumulative_reading = 1.0 + self._switches = { + OVERIDE_CHARGING: False, + PAUSE_BATTERY: False + } + self._sensors = { + ATTR_ENERGY_SAVED: 0.0, + ATTR_ENERGY_BATTERY_OUT: 0.0, + ATTR_ENERGY_BATTERY_IN: 0.0, + CHARGING_RATE: 0.0, + DISCHARGING_RATE: 0.0, + GRID_EXPORT_SIM: 0.0, + GRID_IMPORT_SIM: 0.0, + ATTR_MONEY_SAVED: 0.0 + } + self._energy_saved_today = 0.0 + self._energy_saved_week = 0.0 + self._energy_saved_month = 0.0 + + self._hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, self.async_source_tracking + ) + async_dispatcher_connect( + self._hass, f"{self._name}-BatteryResetMessage", self.async_reset_battery + ) + async_dispatcher_connect( + self._hass, f"{self._name}-BatteryResetImportSim", self.reset_import_sim_sensor + ) + async_dispatcher_connect( + self._hass, f"{self._name}-BatteryResetExportSim", self.reset_export_sim_sensor + ) + + def async_reset_battery(self): + self.reset_import_sim_sensor() + self.reset_export_sim_sensor() + self._charge_state = 0.0 + self._sensors[ATTR_ENERGY_SAVED] = 0.0 + self._sensors[ATTR_MONEY_SAVED] = 0.0 + self._energy_saved_today = 0.0 + self._energy_saved_week = 0.0 + self._energy_saved_month = 0.0 + self._date_recording_started = time.asctime() + dispatcher_send(self._hass, f"{self._name}-BatteryUpdateMessage") + return + + def reset_import_sim_sensor(self): + if (self._hass.states.get(self._import_sensor_id).state is not None and + self._hass.states.get(self._import_sensor_id).state not in [STATE_UNAVAILABLE, STATE_UNKNOWN]): + self._sensors[GRID_IMPORT_SIM] = float(self._hass.states.get(self._import_sensor_id).state) + else: + self._sensors[GRID_IMPORT_SIM] = 0.0 + dispatcher_send(self._hass, f"{self._name}-BatteryUpdateMessage") + + def reset_export_sim_sensor(self): + _LOGGER.debug("Reset export sim sensor") + if (self._hass.states.get(self._export_sensor_id).state is not None and + self._hass.states.get(self._export_sensor_id).state not in [STATE_UNAVAILABLE, STATE_UNKNOWN]): + self._sensors[GRID_EXPORT_SIM] = float(self._hass.states.get(self._export_sensor_id).state) + else: + self._sensors[GRID_EXPORT_SIM] = 0.0 + dispatcher_send(self._hass, f"{self._name}-BatteryUpdateMessage") + + @callback + def async_source_tracking(self, event): + """Wait for source to be ready, then start.""" + + _LOGGER.debug("<%s> monitoring %s", self._name, self._import_sensor_id) + self._collecting1 = async_track_state_change_event( + self._hass, [self._import_sensor_id], self.async_import_reading + ) + _LOGGER.debug("<%s> monitoring %s", self._name, self._export_sensor_id) + self._collecting2 = async_track_state_change_event( + self._hass, [self._export_sensor_id], self.async_export_reading + ) + + @callback + def async_export_reading(self, event): + + """Handle the sensor state changes.""" + old_state = event.data.get("old_state") + new_state = event.data.get("new_state") + if ( + old_state is None + or new_state is None + or old_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] + or new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] + ): + return + + conversion_factor = 1.0 + units = self._hass.states.get(self._export_sensor_id).attributes.get(ATTR_UNIT_OF_MEASUREMENT) + if units == ENERGY_WATT_HOUR: + conversion_factor = 0.001 + elif units == ENERGY_KILO_WATT_HOUR: + conversion_factor = 1.0 + else: + _LOGGER.warning("Units of import sensor not recognised - may give wrong results") + + export_amount = conversion_factor*(float(new_state.state) - float(old_state.state)) + + if export_amount < 0: + _LOGGER.warning("Export sensor value decreased - meter may have been reset") + self._sensors[CHARGING_RATE] = 0 + self._last_export_reading_time = time.time() + return + + if (self._last_import_reading_time>self._last_export_reading_time): + if (self._last_export_reading > 0): + _LOGGER.warning("Accumulated export reading not cleared error") + self._last_export_reading = export_amount + else: + export_amount += self._last_export_reading + self._last_export_reading = 0.0 + self.updateBattery(0.0, export_amount) + self._last_export_reading_time = time.time() + + @callback + def async_import_reading(self, event): + + """Handle the import sensor state changes - energy being imported from grid to be drawn from battery instead""" + old_state = event.data.get("old_state") + new_state = event.data.get("new_state") + + """If data missing return""" + if ( + old_state is None + or new_state is None + or old_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] + or new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] + ): + return + + self._last_import_reading_time = time.time() + + """Check units of import sensor and calculate import amount in kWh""" + conversion_factor = 1.0 + units = self._hass.states.get(self._import_sensor_id).attributes.get(ATTR_UNIT_OF_MEASUREMENT) + if units == ENERGY_WATT_HOUR: + conversion_factor = 0.001 + elif units == ENERGY_KILO_WATT_HOUR: + conversion_factor = 1 + else: + _LOGGER.warning("Units of import sensor not recognised - may give wrong results") + import_amount = conversion_factor*(float(new_state.state) - float(old_state.state)) + self._last_import_cumulative_reading = conversion_factor*(float(new_state.state)) + + if import_amount < 0: + _LOGGER.warning("Import sensor value decreased - meter may have been reset") + self._sensors[DISCHARGING_RATE] = 0 + return + + self.updateBattery(import_amount, self._last_export_reading) + self._last_export_reading = 0.0 + + def updateBattery(self, import_amount, export_amount): + _LOGGER.debug("Battery update event (%s). Import: %s, Export: %s", self._name, round(import_amount,4), round,(export_amount,4)) + if self._charge_state=='unknown': self._charge_state = 0.0 + + """Calculate maximum possible discharge based on battery specifications and time since last discharge""" + time_now = time.time() + time_since_last_battery_update = time_now-self._last_battery_update_time + max_discharge = time_since_last_battery_update*self._max_discharge_rate/3600 + max_charge = time_since_last_battery_update*self._max_charge_rate/3600 + available_capacity_to_charge = self._battery_size - float(self._charge_state) + available_capacity_to_discharge = float(self._charge_state)*float(self._battery_efficiency) + + if self._switches[PAUSE_BATTERY]: + _LOGGER.debug("Battery (%s) paused.", self._name) + amount_to_charge = 0.0 + amount_to_discharge = 0.0 + net_export = export_amount + net_import = import_amount + net_money_saved = 0.0 + elif self._switches[OVERIDE_CHARGING]: + _LOGGER.debug("Battery (%s) overide charging.", self._name) + amount_to_charge = min(max_charge, available_capacity_to_charge) + amount_to_discharge = 0.0 + net_export = max(export_amount - amount_to_charge, 0) + net_import = max(amount_to_charge - export_amount, 0) + import_amount + if self._tariff_sensor_id != "none": + net_money_saved = -1*amount_to_charge*float(self._hass.states.get(self._tariff_sensor_id).state) + else: + _LOGGER.debug("Battery (%s) normal mode.", self._name) + amount_to_charge = min(export_amount, max_charge, available_capacity_to_charge) + amount_to_discharge = min(import_amount, max_discharge, available_capacity_to_discharge) + net_import = import_amount - amount_to_discharge + net_export = export_amount - amount_to_charge + if (self._tariff_sensor_id != "none" and + self._hass.states.get(self._tariff_sensor_id).state is not None and + self._hass.states.get(self._tariff_sensor_id).state not in [STATE_UNAVAILABLE, STATE_UNKNOWN]): + net_money_saved = amount_to_discharge*float(self._hass.states.get(self._tariff_sensor_id).state) + + self._charge_state = float(self._charge_state) + amount_to_charge - amount_to_discharge + + self._sensors[ATTR_ENERGY_SAVED] += amount_to_discharge + self._sensors[GRID_IMPORT_SIM] += net_import + self._sensors[GRID_EXPORT_SIM] += net_export + self._sensors[ATTR_ENERGY_BATTERY_IN] += amount_to_charge + self._sensors[ATTR_ENERGY_BATTERY_OUT] += amount_to_discharge + self._sensors[CHARGING_RATE] = amount_to_charge/(time_since_last_battery_update/3600) + self._sensors[DISCHARGING_RATE] = amount_to_discharge/(time_since_last_battery_update/3600) + + self._charge_percentage = round(100*self._charge_state/self._battery_size) + if self._tariff_sensor_id != "none": + self._sensors[ATTR_MONEY_SAVED] += net_money_saved + self._energy_saved_today += amount_to_discharge + self._energy_saved_week += amount_to_discharge + self._energy_saved_month += amount_to_discharge + + """Reset day/week/month counters""" + if time.strftime("%w") != time.strftime("%w", time.gmtime(self._last_battery_update_time)): + self._energy_saved_today = 0 + if time.strftime("%U") != time.strftime("%U", time.gmtime(self._last_battery_update_time)): + self._energy_saved_week = 0 + if time.strftime("%m") != time.strftime("%m", time.gmtime(self._last_battery_update_time)): + self._energy_saved_month = 0 + + self._last_battery_update_time = time_now + dispatcher_send(self._hass, f"{self._name}-BatteryUpdateMessage") + _LOGGER.debug("Battery update complete (%s). Sensors: %s", self._name, self._sensors) diff --git a/custom_components/battery_sim/button.py b/custom_components/battery_sim/button.py new file mode 100644 index 0000000..c9cc535 --- /dev/null +++ b/custom_components/battery_sim/button.py @@ -0,0 +1,95 @@ +""" +Switch Platform Device for Battery Sim +""" +import logging + +from homeassistant.components.button import ButtonEntity +from homeassistant.helpers.dispatcher import dispatcher_send, async_dispatcher_connect + +from .const import DOMAIN, CONF_BATTERY, RESET_BATTERY, GRID_IMPORT_SIM, GRID_EXPORT_SIM + +_LOGGER = logging.getLogger(__name__) + +BATTERY_BUTTONS =[ + { + "name": RESET_BATTERY, + "key": "overide_charging_enabled", + "icon": "mdi:fast-forward", + }] + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add the Wiser System Switch entities.""" + handle = hass.data[DOMAIN][config_entry.entry_id] # Get Handler + + # Add Defined Buttons + battery_buttons = [] + for button in BATTERY_BUTTONS: + battery_buttons.append( + BatteryButton(handle, button["name"], button["key"], button["icon"]) + ) + + async_add_entities(battery_buttons) + + return True + +async def async_setup_platform(hass, configuration, async_add_entities, discovery_info=None): + if discovery_info is None: + _LOGGER.error("This platform is only available through discovery") + return + + for conf in discovery_info: + battery = conf[CONF_BATTERY] + handle = hass.data[DOMAIN][battery] + + battery_buttons = [] + for button in BATTERY_BUTTONS: + battery_buttons.append( + BatteryButton(handle, button["name"], button["key"], button["icon"]) + ) + + async_add_entities(battery_buttons) + return True + +class BatteryButton(ButtonEntity): + """Switch to set the status of the Wiser Operation Mode (Away/Normal).""" + + def __init__(self, handle, button_type, key, icon): + """Initialize the sensor.""" + self._handle = handle + self._key = key + self._icon = icon + self._button_type = button_type + self._device_name = handle._name + self._name = handle._name + " - " + button_type + self._type = type + + @property + def unique_id(self): + """Return uniqueid.""" + return self._name + + @property + def name(self): + return self._name + + @property + def device_info(self): + return { + "name": self._device_name, + "identifiers": { + (DOMAIN, self._device_name) + }, + } + + @property + def icon(self): + """Return icon.""" + return self._icon + + @property + def should_poll(self): + """Return the polling state.""" + return False + + async def async_press(self): + dispatcher_send(self.hass, f"{self._name}-BatteryResetMessage") diff --git a/custom_components/battery_sim/config_flow.py b/custom_components/battery_sim/config_flow.py index 1ca5be9..96c7389 100644 --- a/custom_components/battery_sim/config_flow.py +++ b/custom_components/battery_sim/config_flow.py @@ -19,7 +19,9 @@ CONF_BATTERY_EFFICIENCY, CONF_IMPORT_SENSOR, CONF_EXPORT_SENSOR, - CONF_ENERGY_TARIFF + CONF_ENERGY_TARIFF, + SETUP_TYPE, + CONFIG_FLOW ) import voluptuous as vol @@ -78,6 +80,8 @@ async def async_step_connectsensors(self, user_input = None): if user_input is not None: self._data[CONF_IMPORT_SENSOR] = user_input[CONF_IMPORT_SENSOR] self._data[CONF_EXPORT_SENSOR] = user_input[CONF_EXPORT_SENSOR] + self._data[CONF_ENERGY_TARIFF] = user_input[CONF_ENERGY_TARIFF] + self._data[SETUP_TYPE] = CONFIG_FLOW return self.async_create_entry(title=self._data["name"], data=self._data) entities = self.hass.states.async_entity_ids() diff --git a/custom_components/battery_sim/const.py b/custom_components/battery_sim/const.py index b9f0310..c002112 100644 --- a/custom_components/battery_sim/const.py +++ b/custom_components/battery_sim/const.py @@ -5,6 +5,8 @@ BATTERY_TYPE = "battery" +BATTERY_PLATFORMS = ["sensor", "switch", "button"] + QUARTER_HOURLY = "quarter-hourly" HOURLY = "hourly" DAILY = "daily" @@ -16,6 +18,10 @@ DATA_UTILITY = "battery_sim_data" +SETUP_TYPE = "setup_type" +CONFIG_FLOW = "config_flow" +YAML = "yaml" + CONF_BATTERY = "battery" CONF_IMPORT_SENSOR = "import_sensor" CONF_EXPORT_SENSOR = "export_sensor" @@ -46,6 +52,10 @@ GRID_IMPORT_SIM = "simulated grid import after battery discharging" ICON_CHARGING = "mdi:battery-charging-50" ICON_DISCHARGING = "mdi:battery-50" +OVERIDE_CHARGING = "overide_charging" +PAUSE_BATTERY = "pause_battery" +RESET_BATTERY = "reset_battery" +PERCENTAGE_ENERGY_IMPORT_SAVED = "percentage_import_energy_saved" BATTERY_OPTIONS = { "Tesla Powerwall": { diff --git a/custom_components/battery_sim/sensor.py b/custom_components/battery_sim/sensor.py index 8402ecc..42c3544 100644 --- a/custom_components/battery_sim/sensor.py +++ b/custom_components/battery_sim/sensor.py @@ -3,28 +3,22 @@ import logging import homeassistant.util.dt as dt_util +from homeassistant.helpers.dispatcher import dispatcher_send, async_dispatcher_connect from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL, - STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.const import ( - CONF_NAME, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR, POWER_KILO_WATT, - EVENT_HOMEASSISTANT_START, - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ATTR_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_MONETARY, ENERGY_WATT_HOUR, - ENERGY_KILO_WATT_HOUR, + ENERGY_KILO_WATT_HOUR ) -from homeassistant.core import callback -from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.restore_state import RestoreEntity from .const import ( @@ -34,11 +28,7 @@ CONF_BATTERY_MAX_DISCHARGE_RATE, CONF_BATTERY_MAX_CHARGE_RATE, CONF_BATTERY_SIZE, - CONF_IMPORT_SENSOR, - CONF_EXPORT_SENSOR, - CONF_ENERGY_TARIFF, ATTR_MONEY_SAVED, - DATA_UTILITY, ATTR_SOURCE_ID, ATTR_STATUS, ATTR_ENERGY_SAVED, @@ -56,7 +46,8 @@ GRID_IMPORT_SIM, GRID_EXPORT_SIM, ICON_CHARGING, - ICON_DISCHARGING + ICON_DISCHARGING, + PERCENTAGE_ENERGY_IMPORT_SAVED ) _LOGGER = logging.getLogger(__name__) @@ -67,104 +58,72 @@ } async def async_setup_entry(hass, config_entry, async_add_entities): - config = hass.data[DOMAIN][config_entry.entry_id] - sensors = await define_sensors(hass, config) + handle = hass.data[DOMAIN][config_entry.entry_id] + sensors = await define_sensors(hass, handle) async_add_entities(sensors) async def async_setup_platform(hass, configuration, async_add_entities, discovery_info=None): if discovery_info is None: - _LOGGER.error("This platform is only available through discovery") return for conf in discovery_info: battery = conf[CONF_BATTERY] - config = hass.data[DATA_UTILITY][battery] - sensors = await define_sensors(hass, config) + handle = hass.data[DOMAIN][battery] + sensors = await define_sensors(hass, handle) async_add_entities(sensors) -async def define_sensors(hass, config): - if CONF_ENERGY_TARIFF not in config: - conf_tarrif = "none" - else: - conf_tarrif = config[CONF_ENERGY_TARIFF] - import_sensor = hass.states.get(config[CONF_IMPORT_SENSOR]) - export_sensor = hass.states.get(config[CONF_EXPORT_SENSOR]) - +async def define_sensors(hass, handle): sensors = [] - energySavedSensor = DisplayOnlySensor(config[CONF_NAME], ATTR_ENERGY_SAVED, DEVICE_CLASS_ENERGY, ENERGY_KILO_WATT_HOUR) - energyBatteryOutSensor = DisplayOnlySensor(config[CONF_NAME], ATTR_ENERGY_BATTERY_OUT, DEVICE_CLASS_ENERGY, ENERGY_KILO_WATT_HOUR) - energyBatteryInSensor = DisplayOnlySensor(config[CONF_NAME], ATTR_ENERGY_BATTERY_IN, DEVICE_CLASS_ENERGY, ENERGY_KILO_WATT_HOUR) - chargingRateSensor = DisplayOnlySensor(config[CONF_NAME], CHARGING_RATE, DEVICE_CLASS_POWER, POWER_KILO_WATT) - dischargingRateSensor = DisplayOnlySensor(config[CONF_NAME], DISCHARGING_RATE, DEVICE_CLASS_POWER, POWER_KILO_WATT) - simulatedExportSensor = DisplayOnlySensor(config[CONF_NAME], GRID_EXPORT_SIM, DEVICE_CLASS_ENERGY, ENERGY_KILO_WATT_HOUR, export_sensor) - simulatedImportSensor = DisplayOnlySensor(config[CONF_NAME], GRID_IMPORT_SIM, DEVICE_CLASS_ENERGY, ENERGY_KILO_WATT_HOUR, import_sensor) - sensors.append(energySavedSensor) - sensors.append(energyBatteryOutSensor) - sensors.append(energyBatteryInSensor) - sensors.append(chargingRateSensor) - sensors.append(dischargingRateSensor) - sensors.append(simulatedExportSensor) - sensors.append(simulatedImportSensor) - sensors.append( - SimulatedBattery( - config[CONF_IMPORT_SENSOR], - config[CONF_EXPORT_SENSOR], - conf_tarrif, - config[CONF_BATTERY_SIZE], - config[CONF_BATTERY_MAX_DISCHARGE_RATE], - config[CONF_BATTERY_MAX_CHARGE_RATE], - config[CONF_BATTERY_EFFICIENCY], - config[CONF_NAME], - energySavedSensor, - energyBatteryOutSensor, - energyBatteryInSensor, - chargingRateSensor, - dischargingRateSensor, - simulatedImportSensor, - simulatedExportSensor - ) - ) + sensors.append(DisplayOnlySensor(handle, ATTR_ENERGY_SAVED, DEVICE_CLASS_ENERGY, ENERGY_KILO_WATT_HOUR)) + sensors.append(DisplayOnlySensor(handle, ATTR_ENERGY_BATTERY_OUT, DEVICE_CLASS_ENERGY, ENERGY_KILO_WATT_HOUR)) + sensors.append(DisplayOnlySensor(handle, ATTR_ENERGY_BATTERY_IN, DEVICE_CLASS_ENERGY, ENERGY_KILO_WATT_HOUR)) + sensors.append(DisplayOnlySensor(handle, CHARGING_RATE, DEVICE_CLASS_POWER, POWER_KILO_WATT)) + sensors.append(DisplayOnlySensor(handle, DISCHARGING_RATE, DEVICE_CLASS_POWER, POWER_KILO_WATT)) + sensors.append(DisplayOnlySensor(handle, GRID_EXPORT_SIM, DEVICE_CLASS_ENERGY, ENERGY_KILO_WATT_HOUR)) + sensors.append(DisplayOnlySensor(handle, GRID_IMPORT_SIM, DEVICE_CLASS_ENERGY, ENERGY_KILO_WATT_HOUR)) + if handle._tariff_sensor_id != "none": + sensors.append(DisplayOnlySensor(handle, ATTR_MONEY_SAVED, DEVICE_CLASS_MONETARY, hass.config.currency)) + sensors.append(SimulatedBattery(handle)) return sensors class DisplayOnlySensor(RestoreEntity, SensorEntity): """Representation of a sensor which simply displays a value calculated in another sensor""" def __init__( self, - device_name, + handle, sensor_name, type_of_sensor, - units, - comparitor_sensor=None + units ): + self._handle = handle self._units = units - self._name = device_name + " - " + sensor_name - self._device_name = device_name + self._name = handle._name + " - " + sensor_name + self._device_name = handle._name + self._sensor_type = sensor_name self._type_of_sensor = type_of_sensor self._last_reset = dt_util.utcnow() - self._comparitor_sensor = comparitor_sensor async def async_added_to_hass(self): - """Handle entity which will be added.""" + """Subscribe for update from the battery.""" + await super().async_added_to_hass() state = await self.async_get_last_state() if state: - self._state = float(state.state) - elif self._comparitor_sensor is not None and self._comparitor_sensor.state not in [STATE_UNAVAILABLE, STATE_UNKNOWN]: - self._state = float(self._comparitor_sensor.state) + self._handle._sensors[self._sensor_type] = float(state.state) else: - self._state = 0.0 + if self._sensor_type == GRID_IMPORT_SIM: + dispatcher_send(self.hass, f"{self._name}-BatteryResetImportSim") + elif self._sensor_type == GRID_EXPORT_SIM: + dispatcher_send(self.hass, f"{self._name}-BatteryResetExportSim") - @callback - def update_value(self, value): - self._state = value - self.schedule_update_ha_state(True) - self._last_reset = dt_util.utcnow() - - @callback - def increment_value(self, value): - self._state += float(value) - self.schedule_update_ha_state(True) + async def async_update_state(): + """Update sensor state.""" + await self.async_update_ha_state(True) + + async_dispatcher_connect( + self.hass, f"{self._handle._name}-BatteryUpdateMessage", async_update_state + ) @property def name(self): @@ -182,13 +141,13 @@ def device_info(self): "name": self._device_name, "identifiers": { (DOMAIN, self._device_name) - }, + } } @property def native_value(self): """Return the state of the sensor.""" - return round(float(self._state),2) + return round(float(self._handle._sensors[self._sensor_type]),2) @property def device_class(self): @@ -212,6 +171,31 @@ def should_poll(self): """No polling needed.""" return False + @property + def extra_state_attributes(self): + """Return the state attributes of the sensor.""" + state_attr = {} + if(self._sensor_type == ATTR_ENERGY_SAVED): + state_attr = { + ATTR_ENERGY_SAVED_TODAY: round(float(self._handle._energy_saved_today),2), + ATTR_ENERGY_SAVED_WEEK: round(float(self._handle._energy_saved_week),2), + ATTR_ENERGY_SAVED_MONTH: round(float(self._handle._energy_saved_month),2) + } + elif(self._sensor_type == GRID_IMPORT_SIM): + real_world_import = self._handle._last_import_cumulative_reading + simulated_import = self._handle._sensors[GRID_IMPORT_SIM] + if real_world_import==0: + _LOGGER.warning("Division by zero, real world: %s, simulated: %s, battery: %s", real_world_import, simulated_import, self._name) + state_attr = { + PERCENTAGE_ENERGY_IMPORT_SAVED: 0 + } + else: + percentage_import_saved = 100*(real_world_import-simulated_import)/real_world_import + state_attr = { + PERCENTAGE_ENERGY_IMPORT_SAVED: round(float(percentage_import_saved),0) + } + return state_attr + @property def icon(self): """Return the icon to use in the frontend, if any.""" @@ -219,7 +203,7 @@ def icon(self): @property def state(self): """Return the state of the sensor.""" - return round(float(self._state),2) + return round(float(self._handle._sensors[self._sensor_type]),2) def update(self): """Not used""" @@ -234,187 +218,11 @@ class SimulatedBattery(RestoreEntity, SensorEntity): def __init__( self, - import_sensor, - export_sensor, - tariff_sensor, - battery_size, - max_discharge_rate, - max_charge_rate, - battery_efficiency, - name, - energySavedSensor, - energyBatteryOutSensor, - energyBatteryInSensor, - chargingRateSensor, - dischargingRateSensor, - simulatedImportSensor, - simulatedExportSensor + handle ): - """Initialize the Battery.""" - self._import_sensor_id = import_sensor - self._export_sensor_id = export_sensor - self._tariff_sensor_id = tariff_sensor - self._state = 0.0 - self._energy_saved = 0.0 - self._money_saved = 0.0 - self._energy_saved_today = 0.0 - self._energy_saved_week = 0.0 - self._energy_saved_month = 0.0 + self.handle = handle self._date_recording_started = time.asctime() - self._collecting1 = None - self._collecting2 = None - self._charging = False - self._name = name - self._battery_size = battery_size - self._max_discharge_rate = max_discharge_rate - self._max_charge_rate = max_charge_rate - self._battery_efficiency = battery_efficiency - self._last_import_reading_time = time.time() - self._last_export_reading_time = time.time() - self._max_discharge = 0.0 - self._charge_percentage = 0.0 - self._energy_battery_in_sensor = energyBatteryInSensor - self._energy_battery_out_sensor = energyBatteryOutSensor - self._energy_saved_sensor = energySavedSensor - self._charging_rate_sensor = chargingRateSensor - self._discharging_rate_sensor = dischargingRateSensor - self._simulated_grid_import_sensor = simulatedImportSensor - self._simulated_grid_export_sensor = simulatedExportSensor - - @callback - def async_export_reading(self, event): - - """Handle the sensor state changes.""" - old_state = event.data.get("old_state") - new_state = event.data.get("new_state") - if ( - old_state is None - or new_state is None - or old_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] - or new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] - ): - return - - if self._state=='unknown': self._state = 0.0 - - """Calculate maximum possible charge based on battery specifications""" - time_now = time.time() - time_since_last_export = time_now-self._last_export_reading_time - self._last_export_reading_time = time_now - max_charge = time_since_last_export*self._max_charge_rate/3600 - - conversion_factor = 1.0 - units = self.hass.states.get(self._export_sensor_id).attributes.get(ATTR_UNIT_OF_MEASUREMENT) - if units == ENERGY_WATT_HOUR: - conversion_factor = 0.001 - elif units == ENERGY_KILO_WATT_HOUR: - conversion_factor = 1 - else: - _LOGGER.warning("Units of import sensor not recognised - may give wrong results") - - diff = conversion_factor*(float(new_state.state) - float(old_state.state)) - - """Check for sensor reset""" - if float(self._simulated_grid_export_sensor.state) > float(old_state.state): - self._simulated_grid_export_sensor.update_value(float(old_state.state)) - if diff <= 0: - self._charging_rate_sensor.update_value(0) - return - - """fix bug where if there is no change in import reading then discharging doesn't update""" - self._discharging_rate_sensor.update_value(0) - - available_capacity = self._battery_size - float(self._state) - - amount_to_charge = min(diff, max_charge, available_capacity) - - self._state = float(self._state) + amount_to_charge - self._simulated_grid_export_sensor.increment_value(diff - amount_to_charge) - self._charging = True - self._charge_percentage = round(100*float(self._state)/float(self._battery_size)) - self._charging_rate_sensor.update_value( - amount_to_charge/(time_since_last_export/3600) - ) - self._energy_battery_in_sensor.increment_value(amount_to_charge) - - self.schedule_update_ha_state(True) - - @callback - def async_import_reading(self, event): - """Handle the import sensor state changes - energy being imported from grid to be drawn from battery instead""" - old_state = event.data.get("old_state") - new_state = event.data.get("new_state") - - """If data missing return""" - if ( - old_state is None - or new_state is None - or old_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] - or new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] - ): - return - - if self._state=='unknown': self._state = 0.0 - - """Reset day/week/month counters""" - if time.strftime("%w") != time.strftime("%w", time.gmtime(self._last_import_reading_time)): - self._energy_saved_today = 0 - if time.strftime("%U") != time.strftime("%U", time.gmtime(self._last_import_reading_time)): - self._energy_saved_week = 0 - if time.strftime("%m") != time.strftime("%m", time.gmtime(self._last_import_reading_time)): - self._energy_saved_month = 0 - - """Calculate maximum possible discharge based on battery specifications and time since last discharge""" - time_now = time.time() - time_since_last_import = time_now-self._last_import_reading_time - self._last_import_reading_time = time_now - max_discharge = time_since_last_import*self._max_discharge_rate/3600 - - """Check units of import sensor and calculate import amount in kWh""" - conversion_factor = 1.0 - units = self.hass.states.get(self._import_sensor_id).attributes.get(ATTR_UNIT_OF_MEASUREMENT) - if units == ENERGY_WATT_HOUR: - conversion_factor = 0.001 - elif units == ENERGY_KILO_WATT_HOUR: - conversion_factor = 1 - else: - _LOGGER.warning("Units of import sensor not recognised - may give wrong results") - diff = conversion_factor*(float(new_state.state) - float(old_state.state)) - - """Check for sensor reset""" - if float(self._simulated_grid_import_sensor.state) > float(old_state.state): - self._simulated_grid_import_sensor.update_value(float(old_state.state)) - if diff <= 0: - self._discharging_rate_sensor.update_value(0) - return - - """fix bug where if there is no change in export reading then charging doesn't update""" - self._charging_rate_sensor.update_value(0) - - amount_to_discharge = min(diff, max_discharge, float(self._state)*float(self._battery_efficiency)) - - self._state = float(self._state) - amount_to_discharge/float(self._battery_efficiency) - self._energy_battery_out_sensor.increment_value(amount_to_discharge) - self._energy_saved += amount_to_discharge - self._energy_saved_sensor.update_value(self._energy_saved) - self._energy_saved_today += amount_to_discharge - self._energy_saved_week += amount_to_discharge - self._energy_saved_month += amount_to_discharge - self._charge_percentage = round(100*self._state/self._battery_size) - - self._simulated_grid_import_sensor.increment_value( - diff - amount_to_discharge - ) - - if self._tariff_sensor_id != "none": - self._money_saved += amount_to_discharge*float(self.hass.states.get(self._tariff_sensor_id).state) - - self._charging = False - self._discharging_rate_sensor.update_value( - amount_to_discharge/(time_since_last_import/3600) - ) - - self.schedule_update_ha_state(True) + self._name = handle._name async def async_added_to_hass(self): """Handle entity which will be added.""" @@ -422,60 +230,47 @@ async def async_added_to_hass(self): state = await self.async_get_last_state() if state: - self._state = state.state - if ATTR_ENERGY_SAVED in state.attributes: - self._energy_saved = state.attributes[ATTR_ENERGY_SAVED] + self.handle._charge_state = float(state.state) if ATTR_DATE_RECORDING_STARTED in state.attributes: - self._date_recording_started = state.attributes[ATTR_DATE_RECORDING_STARTED] + self.handle._date_recording_started = state.attributes[ATTR_DATE_RECORDING_STARTED] if ATTR_ENERGY_SAVED_TODAY in state.attributes: self._energy_saved_today = state.attributes[ATTR_ENERGY_SAVED_TODAY] if ATTR_ENERGY_SAVED_WEEK in state.attributes: self._energy_saved_week = state.attributes[ATTR_ENERGY_SAVED_WEEK] if ATTR_ENERGY_SAVED_MONTH in state.attributes: self._energy_saved_month = state.attributes[ATTR_ENERGY_SAVED_MONTH] - if self._tariff_sensor_id != "none" and ATTR_MONEY_SAVED in state.attributes: - self._money_saved = state.attributes[ATTR_MONEY_SAVED] - - @callback - def async_source_tracking(event): - """Wait for source to be ready, then start.""" - - _LOGGER.debug("<%s> collecting from %s", self.name, self._import_sensor_id) - self._collecting1 = async_track_state_change_event( - self.hass, [self._import_sensor_id], self.async_import_reading - ) - _LOGGER.debug("<%s> collecting from %s", self.name, self._export_sensor_id) - self._collecting2 = async_track_state_change_event( - self.hass, [self._export_sensor_id], self.async_export_reading - ) - - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, async_source_tracking + + async def async_update_state(): + """Update sensor state.""" + await self.async_update_ha_state(True) + + async_dispatcher_connect( + self.hass, f"{self.handle._name}-BatteryUpdateMessage", async_update_state ) @property def name(self): """Return the name of the sensor.""" - return self._name + return self.handle._name @property def unique_id(self): """Return uniqueid.""" - return self._name + return self.handle._name @property def device_info(self): return { - "name": self._name, + "name": self.handle._name, "identifiers": { - (DOMAIN, self.name) + (DOMAIN, self.handle._name) }, } @property def native_value(self): """Return the state of the sensor.""" - return round(float(self._state),2) + return round(float(self.handle._charge_state),2) @property def device_class(self): @@ -508,25 +303,24 @@ def should_poll(self): def extra_state_attributes(self): """Return the state attributes of the sensor.""" state_attr = { - ATTR_SOURCE_ID: self._export_sensor_id, - ATTR_STATUS: CHARGING if self._charging else DISCHARGING, - ATTR_CHARGE_PERCENTAGE: int(self._charge_percentage), - ATTR_DATE_RECORDING_STARTED: self._date_recording_started, - ATTR_MONEY_SAVED: round(float(self._money_saved),2), - ATTR_ENERGY_SAVED_TODAY: round(float(self._energy_saved_today),2), - ATTR_ENERGY_SAVED_WEEK: round(float(self._energy_saved_week),2), - ATTR_ENERGY_SAVED_MONTH: round(float(self._energy_saved_month),2), - CONF_BATTERY_SIZE: self._battery_size, - CONF_BATTERY_EFFICIENCY: float(self._battery_efficiency), - CONF_BATTERY_MAX_DISCHARGE_RATE: float(self._max_discharge_rate), - CONF_BATTERY_MAX_CHARGE_RATE: float(self._max_charge_rate), + ATTR_STATUS: CHARGING if self.handle._charging else DISCHARGING, + ATTR_CHARGE_PERCENTAGE: int(self.handle._charge_percentage), + ATTR_DATE_RECORDING_STARTED: self.handle._date_recording_started, + ATTR_ENERGY_SAVED_TODAY: round(float(self.handle._energy_saved_today),2), + ATTR_ENERGY_SAVED_WEEK: round(float(self.handle._energy_saved_week),2), + ATTR_ENERGY_SAVED_MONTH: round(float(self.handle._energy_saved_month),2), + CONF_BATTERY_SIZE: self.handle._battery_size, + CONF_BATTERY_EFFICIENCY: float(self.handle._battery_efficiency), + CONF_BATTERY_MAX_DISCHARGE_RATE: float(self.handle._max_discharge_rate), + CONF_BATTERY_MAX_CHARGE_RATE: float(self.handle._max_charge_rate), + ATTR_SOURCE_ID: self.handle._export_sensor_id } return state_attr @property def icon(self): """Return the icon to use in the frontend, if any.""" - if self._charging: + if self.handle._charging: return ICON_CHARGING else: return ICON_DISCHARGING @@ -534,7 +328,7 @@ def icon(self): @property def state(self): """Return the state of the sensor.""" - return round(float(self._state),2) + return round(float(self.handle._charge_state),2) def update(self): - """Not used""" + """Not used""" \ No newline at end of file diff --git a/custom_components/battery_sim/switch.py b/custom_components/battery_sim/switch.py new file mode 100644 index 0000000..3d76330 --- /dev/null +++ b/custom_components/battery_sim/switch.py @@ -0,0 +1,113 @@ +""" +Switch Platform Device for Battery Sim +""" +import logging + +from homeassistant.components.switch import SwitchEntity + +from .const import DOMAIN, CONF_BATTERY, OVERIDE_CHARGING, PAUSE_BATTERY + +_LOGGER = logging.getLogger(__name__) + +BATTERY_SWITCHES = [ + { + "name": OVERIDE_CHARGING, + "key": "overide_charging_enabled", + "icon": "mdi:fast-forward", + }, + { + "name": PAUSE_BATTERY, + "key": "pause_battery_enabled", + "icon": "mdi:pause", + } +] + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add the Wiser System Switch entities.""" + handle = hass.data[DOMAIN][config_entry.entry_id] # Get Handler + + # Add Defined Switches + battery_switches = [] + for switch in BATTERY_SWITCHES: + battery_switches.append( + BatterySwitch(handle, switch["name"], switch["key"], switch["icon"]) + ) + + async_add_entities(battery_switches) + + return True + +async def async_setup_platform(hass, configuration, async_add_entities, discovery_info=None): + if discovery_info is None: + _LOGGER.error("This platform is only available through discovery") + return + + for conf in discovery_info: + battery = conf[CONF_BATTERY] + handle = hass.data[DOMAIN][battery] + + battery_switches = [] + for switch in BATTERY_SWITCHES: + battery_switches.append( + BatterySwitch(handle, switch["name"], switch["key"], switch["icon"]) + ) + + async_add_entities(battery_switches) + return True + +class BatterySwitch(SwitchEntity): + """Switch to set the status of the Wiser Operation Mode (Away/Normal).""" + + def __init__(self, handle, switch_type, key, icon): + """Initialize the sensor.""" + self.handle = handle + self._key = key + self._icon = icon + self._switch_type = switch_type + self._device_name = handle._name + self._name = handle._name + " - " + switch_type + self._is_on = False + self._type = type + + @property + def unique_id(self): + """Return uniqueid.""" + return self._name + + @property + def name(self): + return self._name + + @property + def device_info(self): + return { + "name": self._device_name, + "identifiers": { + (DOMAIN, self._device_name) + }, + } + + @property + def icon(self): + """Return icon.""" + return self._icon + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def is_on(self): + """Return true if device is on.""" + return self.handle._switches[self._switch_type] + + async def async_turn_on(self, **kwargs): + self.handle._switches[self._switch_type] = True + self.schedule_update_ha_state(True) + return True + + async def async_turn_off(self, **kwargs): + self.handle._switches[self._switch_type] = False + self.schedule_update_ha_state(True) + return True