Repository: hif2k1/battery_sim
Branch: master
Commit: 570e7bc2ea7a
Files: 22
Total size: 162.8 KB
Directory structure:
gitextract_1ow9x04h/
├── .github/
│ ├── dependabot.yml
│ └── workflows/
│ ├── hacsaction.yml
│ └── main.yml
├── .gitignore
├── README.md
├── custom_components/
│ └── battery_sim/
│ ├── __init__.py
│ ├── button.py
│ ├── config_flow.py
│ ├── const.py
│ ├── helpers.py
│ ├── manifest.json
│ ├── number.py
│ ├── select.py
│ ├── sensor.py
│ ├── services.yaml
│ ├── switch.py
│ └── translations/
│ ├── de.json
│ ├── en.json
│ ├── nl.json
│ └── sv.json
├── hacs.json
└── scripts/
└── check_translations_usage.py
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/dependabot.yml
================================================
# Set update schedule for GitHub Actions
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
================================================
FILE: .github/workflows/hacsaction.yml
================================================
name: HACS Action
on:
push:
pull_request:
schedule:
- cron: "0 0 * * *"
jobs:
hacs:
name: HACS Action
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v6"
- name: HACS Action
uses: "hacs/action@main"
with:
category: "integration"
================================================
FILE: .github/workflows/main.yml
================================================
name: Validate with hassfest
on:
push:
pull_request:
jobs:
validate:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v6"
- uses: "home-assistant/actions/hassfest@master"
================================================
FILE: .gitignore
================================================
.DS_Store
================================================
FILE: README.md
================================================
# Battery Simulator integration/custom component for home assistant
[](https://github.com/dewi-ny-je/battery_sim/)
Allows you to model how much energy you would save with a home battery if you currently export energy to the grid e.g. from solar panels. Requires an energy monitor that can measure import and export energy. Whenever you are exporting energy your simulated battery will charge up and whenevery you are importing it will discharge. Battery charge percentage and total energy saved are in the attributes.
Please note this is a simulation and a real battery may behave differently and not all batteries will support all the features available in this simulation. In particular battery_sim allows you to simulate batteries that charge and discharge across multiple phases and various modes including charge_only, discharge_only etc that may not be available in all real world batteries.
## Setup
The easiest way to get battery_sim is to use HACS to add it as an integration. If you do not want to use HACS, copy this repository into the `custom_components` folder in your Home Assistant configuration directory.
After installation, create one or more batteries. The recommended approach is to go to **Settings > Devices & Services**, click **Add Integration**, search for **Battery Simulation**, and work through the dialog for each battery you want to simulate.
You can also define batteries in `configuration.yaml`. Each battery is created under `battery_sim:` using a unique slug key. All YAML parameters currently supported by the integration are listed below.
### YAML parameters
| Parameter | Required | Description |
| --- | --- | --- |
| `import_sensor` | Yes | Entity ID of the cumulative energy-import sensor in kWh, for example the output of a `utility_meter`. |
| `export_sensor` | Yes | Entity ID of the cumulative energy-export sensor in kWh. |
| `size_kwh` | Yes | Usable battery capacity in kWh. Use a floating-point value such as `13.5`. |
| `max_discharge_rate_kw` | Yes | Maximum rated discharge power in kW. Use a floating-point value such as `5.0`. The user can limit if further using a field in the device page. |
| `max_charge_rate_kw` | No | Maximum rated charge power in kW. Defaults to `1.0` if omitted. The user can limit if further using a field in the device page. |
| `discharge_efficiency` | No | Battery discharge efficiency from `0` to `1`. If omitted, the integration falls back to `efficiency` when that legacy key is present, otherwise `1.0`. You can enter either a single value between 0 and 1, or a power curve such as `0:0.90, 2.5:0.94, 5:0.95`. |
| `charge_efficiency` | No | Battery charge efficiency from `0` to `1`. Defaults to `1.0` if omitted. You can enter either a single value between 0 and 1, or a power curve such as `0:0.90, 2.5:0.94, 5:0.95`. |
| `efficiency` | No | Legacy single-value efficiency key kept for backward compatibility. It is used as the default for `discharge_efficiency` when the newer split efficiency keys are not set. |
| `energy_tariff` | No | Entity ID of a tariff sensor. For backward-compatible YAML setups this populates the import tariff input. |
| `energy_import_tariff` | No | Entity ID of an import tariff sensor. |
| `energy_export_tariff` | No | Entity ID of an export tariff sensor. |
| `solar_energy_sensor` | No | Entity ID of a cumulative solar energy production sensor in kWh. When configured, the maximum charge power is capped by the solar production rate during each update interval. Seldomly needed, see below. |
| `nominal_inverter_power_kw` | No | Nominal inverter AC power limit in kW. Used together with `solar_energy_sensor` to cap battery discharge to `max(0, nominal_inverter_power_kw - current_solar_power_kw)` each update interval. |
| `name` | No | Friendly name shown in Home Assistant. If omitted, the YAML object key is used. |
| `rated_battery_cycles` | No | Number of full cycles at which end-of-life degradation is reached. Defaults to `6000`. |
| `end_of_life_degradation` | No | Remaining usable capacity at `rated_battery_cycles`, expressed from `0` to `1`. Defaults to `0.8`. |
| `update_frequency` | No | Maximum interval between updates in seconds. Defaults to `60`, which is also the recommended value. Faster updates do not improve accuracy. |
### Example YAML
```yaml
battery_sim:
tesla_powerwall:
name: Tesla Powerwall
import_sensor: sensor.circuitsetup_cumulative_import_energy_kwh
export_sensor: sensor.circuitsetup_cumulative_export_energy_kwh
size_kwh: 13.5
max_discharge_rate_kw: 5.0
max_charge_rate_kw: 3.68
discharge_efficiency: 0:0.92, 2.5:0.95, 5:0.95
charge_efficiency: 0:0.90, 2:0.94, 3.68:0.95
solar_energy_sensor: sensor.solar_generation_energy_kwh
nominal_inverter_power_kw: 5.0
rated_battery_cycles: 6000
end_of_life_degradation: 0.8
update_frequency: 60
energy_tariff: sensor.energy_tariff
lg_chem_resu10h:
name: LG Chem
import_sensor: sensor.circuitsetup_cumulative_import_energy_kwh
export_sensor: sensor.circuitsetup_cumulative_export_energy_kwh
size_kwh: 9.3
max_discharge_rate_kw: 5.0
max_charge_rate_kw: 3.3
discharge_efficiency: 0.975
charge_efficiency: 0.975
energy_import_tariff: sensor.grid_import_tariff
energy_export_tariff: sensor.grid_export_tariff
```
## Sensors
The integration creates the following sensors for each battery:
| Sensor | Description | Unit |
| --- | --- | --- |
| `current charging rate` | Real-time charging power based on the energy transferred during the last update interval. | kW |
| `current discharging rate` | Real-time discharging power based on the energy transferred during the last update interval. | kW |
| `solar power cap` | Average power corresponding to the solar generation cap, updated each interval. Only available when a solar energy sensor is configured. | kW |
| `battery_energy_in` | Cumulative energy charged into the battery since initialization or last reset. | kWh |
| `battery_energy_out` | Cumulative energy discharged from the battery since initialization or last reset. | kWh |
| `total energy saved` | Total energy saved compared to direct grid use. | kWh |
| `total_money_saved` | Total money saved by the battery operation. | Currency |
| `money_saved_on_imports` | Money saved by reducing energy imports from the grid. | Currency |
| `extra_money_earned_on_exports` | Extra revenue earned by exporting energy to the grid. | Currency |
| `last charge efficiency` | Charge efficiency used in the most recent update. | Ratio |
| `last discharge efficiency` | Discharge efficiency used in the most recent update. | Ratio |
| `battery_cycles` | Number of full charge/discharge cycles accumulated. | Cycles |
| `battery_degradation` | Current degradation factor (1.0 = no degradation). | Ratio |
| `Battery_mode_now` | Current operating mode (Charging, Discharging, Idle, etc.). | State |
| `percentage` | Current charge level as a percentage. | % |
| `status` | Status indicator showing if battery is Full, Empty, or Normal. | State |
## Solar Power Cap : Important Remarks
When a solar energy sensor is configured via the `solar_energy_sensor` parameter, the integration uses solar generation data to cap the maximum charging power during each update interval.
For this feature to work, **it is essential that the entity tracking the solar energy production gets updated more often than the battery simulator**, which is by default once per 60 seconds. If thats not the case, the battery simulator would not detect any energy generation for some of the update intervals and incorrectly pause the battery. A practical example: APsystems' microinverters communicate via ZigBee and publish power and energy updates every 5 minutes. They are therefore incompatible with this feature.
If `nominal_inverter_power_kw` is also configured, discharge is additionally capped to the inverter headroom, taking into account the solar energy sensor change over the current update interval.
This is useful only in the scenario in which batteries are connected to inverters which are "one way", meaning these inverters can use energy from the panels to charge the battery and use the battery to provide power to the rest of the network, but which cannot use energy from the grid to charge the batteries.
This parameter is needed also2 when there are more batteries (and inverters) than the available energy readings (which typically means two of such batteries and inverters), because if there is only one of such batteries and inverters, the only excess power seen by the smart meter is inevitably the power from the only inverter, and this parameter is not needed.
In such a scenario the simulator would not be able to know whether the excess energy (the one normally exported to the grid) come from one or the other inverter, so it would potentially use excess production from one inverter to charge a battery connected behind the other inverter.
In such an unusual scenario this parameter fixes the issue.
To be clear: do NOT set the charge cap to the smart meter energy production: it does not represent how the battery or inverter behave and it will cause undesired (and unrealistic) results.
## Battery Efficiencies
This integration supports separate `charge_efficiency` and `discharge_efficiency` values because battery efficiency is not flat across the full operating range. In practice, manufacturers often publish an efficiency curve: efficiency changes with charge or discharge power, and lower power levels typically produce worse results than the headline datasheet number.
You can configure each efficiency either as:
- a single value, for example `0.95`
- or a power curve, for example `0:0.88, 0.5:0.90, 2.5:0.94, 5:0.95`
The power values are in kW. During each battery update, the integration computes the average charging or discharging power as:
`energy transferred during the interval / interval duration`
It then linearly interpolates the efficiency from the configured points and uses that value for the update. Two extra sensors report the charge and discharge efficiency used for the most recent update.
A simplified efficiency curve usually looks something like this:
| Charge/discharge power | Typical behavior |
| --- | --- |
| Very low power | Efficiency drops because fixed inverter and standby losses dominate. |
| Medium power | Efficiency is usually at or near the best point on the curve. |
| Very high power | Efficiency can taper off again because of conversion and thermal losses. |
If you use fixed efficiencies rather than an efficiency curve, the best approach is to choose values that represent your most common operating range. If most of your simulated battery activity happens at low power, set lower `charge_efficiency` and `discharge_efficiency` values to approximate that part of the curve and choose more conservative efficiency values than the best-case number in the datasheet.
When reading a datasheet, make sure the quoted efficiency covers the whole path you care about. Some manufacturers quote inverter efficiency only, which may describe battery-to-AC conversion while excluding charging losses into the battery. In those cases, use conservative values for both charge and discharge.
## Battery Degradation
This integration models the degradation of the battery linearly, from 100% usable capacity (no degradation) at 0 cycles and (by default)
80% usable capacity at 6000 cycles. The values can be provided in the settings.
The state of charge (SOC) is not limited progressively, the capacity associated with 100% SOC simply decreases over time.
A new action is provided to manually set the current number of battery cycles, to simulate immediately old batteries.
## Energy Dashboard
You can configure battery_sim to display your simulated battery on your Energy Dashboard:


## Debug
If you are having problems it is helpful to get the debug log for the battery by adding:
```
logger:
default: critical
logs:
custom_components.battery_sim: debug
```
to your configuration.yaml and then restarting. If you leave it to run for a few minutes go to logs then and click "load full log" you should see entries from the battery saying it's been set up and then each time it receives an update. If you need to raise an issue then including this code is helpful.
## Acknowledgements
Original idea and integration developed by hif2k1. Further work in cooperation with dewi-ny-je.
================================================
FILE: custom_components/battery_sim/__init__.py
================================================
"""Simulates a battery to evaluate how much energy it could save."""
import logging
import asyncio
from datetime import timedelta
import voluptuous as vol
import homeassistant.util.dt as dt_util
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import discovery
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.start import async_at_start
from homeassistant.helpers.event import (
async_call_later,
async_track_state_change_event,
async_track_time_interval,
)
from homeassistant.helpers.dispatcher import dispatcher_send, async_dispatcher_connect
from homeassistant.const import (
ATTR_UNIT_OF_MEASUREMENT,
CONF_NAME,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
UnitOfEnergy,
)
from .const import (
ATTR_ENERGY_BATTERY_IN,
ATTR_ENERGY_BATTERY_OUT,
ATTR_ENERGY_SAVED,
ATTR_STATUS,
ATTR_MONEY_SAVED_EXPORT,
ATTR_MONEY_SAVED_IMPORT,
ATTR_MONEY_SAVED,
ATTR_LAST_CHARGE_EFFICIENCY,
ATTR_LAST_DISCHARGE_EFFICIENCY,
BATTERY_DEGRADATION,
BATTERY_CYCLES,
BATTERY_MODE,
BATTERY_PLATFORMS,
CHARGE_ONLY,
CHARGING_RATE,
CONF_BATTERY_CHARGE_EFFICIENCY,
CONF_BATTERY_DISCHARGE_EFFICIENCY,
CONF_BATTERY_EFFICIENCY,
CONF_BATTERY_MAX_CHARGE_RATE,
CONF_BATTERY_MAX_DISCHARGE_RATE,
CONF_BATTERY_SIZE,
CONF_BATTERY,
CONF_END_OF_LIFE_DEGRADATION,
CONF_ENERGY_EXPORT_TARIFF,
CONF_ENERGY_IMPORT_TARIFF,
CONF_ENERGY_TARIFF,
CONF_EXPORT_SENSOR,
CONF_IMPORT_SENSOR,
CONF_SOLAR_ENERGY_SENSOR,
CONF_NOMINAL_INVERTER_POWER,
CONF_UPDATE_FREQUENCY,
CONF_INPUT_LIST,
CONF_RATED_BATTERY_CYCLES,
DEFAULT_MODE,
DISCHARGE_ONLY,
DISCHARGING_RATE,
DOMAIN,
FORCE_DISCHARGE,
MESSAGE_TYPE_BATTERY_UPDATE,
MESSAGE_TYPE_GENERAL,
MODE_CHARGING,
MODE_DISCHARGING,
MODE_EMPTY,
MODE_FORCE_CHARGING,
MODE_FORCE_DISCHARGING,
MODE_FULL,
MODE_IDLE,
MINIMUM_UPDATE_INTERVAL_SECONDS,
NO_TARIFF_INFO,
OVERRIDE_CHARGING,
PAUSE_BATTERY,
FIXED_TARIFF,
TARIFF_TYPE,
SENSOR_ID,
SENSOR_TYPE,
TARIFF_SENSOR,
IMPORT,
EXPORT,
SOLAR_POWER_CAP,
SIMULATED_SENSOR,
)
from .helpers import (
generate_input_list,
interpolate_efficiency,
parse_efficiency_curve,
validate_efficiency_config,
)
BATTERY_CONFIG_SCHEMA = vol.Schema(
vol.All(
{
vol.Required(CONF_IMPORT_SENSOR): cv.entity_id,
vol.Required(CONF_EXPORT_SENSOR): cv.entity_id,
vol.Optional(CONF_SOLAR_ENERGY_SENSOR): cv.entity_id,
vol.Optional(CONF_NOMINAL_INVERTER_POWER): vol.All(vol.Coerce(float), vol.Range(min=0)),
vol.Optional(CONF_ENERGY_TARIFF): cv.entity_id,
vol.Optional(CONF_ENERGY_EXPORT_TARIFF): cv.entity_id,
vol.Optional(CONF_ENERGY_IMPORT_TARIFF): cv.entity_id,
vol.Optional(CONF_NAME): cv.string,
vol.Required(CONF_BATTERY_SIZE): vol.All(float),
vol.Required(CONF_BATTERY_MAX_DISCHARGE_RATE): vol.All(float),
vol.Optional(CONF_BATTERY_MAX_CHARGE_RATE, default=1.0): vol.All(float),
vol.Optional(CONF_BATTERY_DISCHARGE_EFFICIENCY): vol.Any(
vol.Coerce(float), vol.All(cv.string, validate_efficiency_config)
),
vol.Optional(CONF_BATTERY_CHARGE_EFFICIENCY): vol.Any(
vol.Coerce(float), vol.All(cv.string, validate_efficiency_config)
),
vol.Optional(CONF_BATTERY_EFFICIENCY, default=1.0): vol.Any(
vol.Coerce(float), vol.All(cv.string, validate_efficiency_config)
),
vol.Optional(CONF_RATED_BATTERY_CYCLES, default=6000): vol.All(
vol.Coerce(float), vol.Range(min=1)
),
vol.Optional(CONF_END_OF_LIFE_DEGRADATION, default=0.8): vol.All(
vol.Coerce(float), vol.Range(min=0, max=1)
),
vol.Optional(CONF_UPDATE_FREQUENCY, default=60): vol.All(
vol.Coerce(int), vol.Range(min=1)
),
},
)
)
CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.Schema({cv.slug: BATTERY_CONFIG_SCHEMA})}, extra=vol.ALLOW_EXTRA
)
_LOGGER = logging.getLogger(__name__)
SERVICE_REGISTRATION_KEY = f"{DOMAIN}_services_registered"
INITIAL_SOC_RATIO = 0.5
INITIAL_CHARGE_PERCENTAGE = 50
DEFAULT_BATTERY_STATUS = "Normal"
DEFAULT_BATTERY_DEGRADATION = 1.0
async def async_setup(hass, config):
"""Set up battery platforms from a YAML."""
hass.data.setdefault(DOMAIN, {})
if config.get(DOMAIN) is None:
return True
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
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 battery platforms from a Config Flow Entry."""
hass.data.setdefault(DOMAIN, {})
_LOGGER.debug("Setup %s.%s", DOMAIN, entry.data[CONF_NAME])
handle = SimulatedBatteryHandle(entry.data, hass, entry.entry_id)
hass.data[DOMAIN][entry.entry_id] = handle
# Register service
async def handle_set_charge(call):
device_id = call.data.get("device_id")
state = call.data.get("charge_state")
_LOGGER.debug("Calling set_battery_charge_state with: %s", state)
# Lookup the device to get the correct handle
dev_reg = dr.async_get(hass)
device = dev_reg.async_get(device_id)
if not device:
_LOGGER.error("Device not found: %s", device_id)
return
# Match to correct handle by comparing identifiers
for handle_entry in hass.data[DOMAIN].values():
if handle_entry.matches_device_identifiers(device.identifiers):
handle_entry.async_set_battery_charge_state(state)
_LOGGER.debug("Battery charge updated for device %s", handle_entry._name)
break
else:
_LOGGER.error("No handle matched for device_id: %s", device_id)
async def handle_set_cycles(call):
device_id = call.data.get("device_id")
cycles = call.data.get("battery_cycles")
_LOGGER.debug("Calling set_battery_cycles with: %s", cycles)
dev_reg = dr.async_get(hass)
device = dev_reg.async_get(device_id)
if not device:
_LOGGER.error("Device not found: %s", device_id)
return
for handle_entry in hass.data[DOMAIN].values():
if handle_entry.matches_device_identifiers(device.identifiers):
handle_entry.async_set_battery_cycles(cycles)
_LOGGER.debug("Battery cycles updated for device %s", handle_entry._name)
break
else:
_LOGGER.error("No handle matched for device_id: %s", device_id)
if not hass.data.get(SERVICE_REGISTRATION_KEY):
hass.services.async_register(
DOMAIN,
"set_battery_charge_state",
handle_set_charge,
schema=vol.Schema({
vol.Required("device_id"): str,
vol.Required("charge_state"): vol.All(vol.Coerce(float), vol.Range(min=0))
}),
)
hass.services.async_register(
DOMAIN,
"set_battery_cycles",
handle_set_cycles,
schema=vol.Schema({
vol.Required("device_id"): str,
vol.Required("battery_cycles"): vol.All(vol.Coerce(float), vol.Range(min=0))
}),
)
hass.data[SERVICE_REGISTRATION_KEY] = True
handle._listeners.append(entry.add_update_listener(async_update_settings))
hass.async_create_task(
hass.config_entries.async_forward_entry_setups(entry, BATTERY_PLATFORMS)
)
return True
async def async_update_settings(hass, entry):
_LOGGER.warning(f"Config change detected {entry.data[CONF_NAME]}")
await hass.config_entries.async_reload(entry.entry_id)
return
async def async_unload_entry(hass, config_entry):
"""Unload a config entry"""
# Unload a config entry
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(config_entry, platform)
for platform in BATTERY_PLATFORMS
]
)
)
"""Remove listeners"""
handle = hass.data[DOMAIN][config_entry.entry_id]
for listener in handle._listeners:
if listener is not None:
outcome = listener()
_LOGGER.warning(f"unloading listener: {outcome}")
if handle._pending_update_cancel is not None:
handle._pending_update_cancel()
handle._pending_update_cancel = None
_LOGGER.debug("Unload integration")
if unload_ok:
if DOMAIN in hass.data:
hass.data[DOMAIN].pop(config_entry.entry_id, None)
if DOMAIN in hass.data and not hass.data[DOMAIN]:
hass.services.async_remove(DOMAIN, "set_battery_charge_state")
hass.services.async_remove(DOMAIN, "set_battery_cycles")
hass.data.pop(SERVICE_REGISTRATION_KEY, None)
hass.data.pop(DOMAIN, None)
return unload_ok
class SimulatedBatteryHandle:
"""Representation of the battery itself."""
@staticmethod
def _safe_curve_efficiency(curve, fallback=1.0):
"""Return first efficiency value from a curve, or fallback when unavailable."""
if curve and isinstance(curve[0], (list, tuple)) and len(curve[0]) > 1:
return curve[0][1]
return fallback
def __init__(self, config, hass, entry_id=None):
"""Initialize the Battery."""
self._hass = hass
self._entry_id = entry_id
self._date_recording_started = dt_util.now().isoformat()
self._name = config[CONF_NAME]
self._sensor_collection: list = []
self._charging: bool = False
self._accumulated_import_reading: float = 0.0
self._last_battery_update_time = dt_util.utcnow().timestamp()
# Periodic update cadence (seconds). Falls back to 60 for backwards compatibility.
self._update_frequency = config.get(CONF_UPDATE_FREQUENCY, 60)
self._max_discharge: float = 0.0
self._charge_limit = config[CONF_BATTERY_MAX_CHARGE_RATE]
self._discharge_limit = config[CONF_BATTERY_MAX_DISCHARGE_RATE]
self._minimum_soc: float = 0
self._maximum_soc: float = 100
self._charge_percentage: float = INITIAL_CHARGE_PERCENTAGE
self._charge_state: float = config[CONF_BATTERY_SIZE] * INITIAL_SOC_RATIO
self._accumulated_export_reading: float = 0.0
self._accumulated_solar_reading: float = 0.0
self._last_import_reading_sensor_data = None
self._last_export_reading_sensor_data = None
self._energy_saved_today: float = 0.0
self._energy_saved_week: float = 0.0
self._energy_saved_month: float = 0.0
self._solar_entity_id = config.get(CONF_SOLAR_ENERGY_SENSOR)
self._nominal_inverter_power = config.get(CONF_NOMINAL_INVERTER_POWER)
self._listeners = []
self._pending_update_cancel = None
self._battery_size = config[CONF_BATTERY_SIZE]
self._rated_battery_cycles = config.get(CONF_RATED_BATTERY_CYCLES, 6000.0)
self._end_of_life_degradation = config.get(CONF_END_OF_LIFE_DEGRADATION, 0.8)
if self._charge_state > self._battery_size:
self._charge_state = self._battery_size
self._max_discharge_rate = config[CONF_BATTERY_MAX_DISCHARGE_RATE]
self._max_charge_rate = config[CONF_BATTERY_MAX_CHARGE_RATE]
default_discharge_efficiency = config.get(CONF_BATTERY_EFFICIENCY, 1.0)
self._battery_discharge_efficiency = config.get(
CONF_BATTERY_DISCHARGE_EFFICIENCY, default_discharge_efficiency
)
self._battery_charge_efficiency = config.get(
CONF_BATTERY_CHARGE_EFFICIENCY, default_discharge_efficiency
)
self._battery_discharge_efficiency_curve = parse_efficiency_curve(
self._battery_discharge_efficiency
)
self._battery_charge_efficiency_curve = parse_efficiency_curve(
self._battery_charge_efficiency
)
if CONF_INPUT_LIST in config:
self._inputs = config[CONF_INPUT_LIST]
else:
"""Needed for backwards compatability"""
self._inputs = generate_input_list(config=config)
self._switches: dict = {
PAUSE_BATTERY: False,
}
self._battery_mode = DEFAULT_MODE
default_charge_efficiency = self._safe_curve_efficiency(
self._battery_charge_efficiency_curve
)
default_discharge_efficiency = self._safe_curve_efficiency(
self._battery_discharge_efficiency_curve
)
self._sensors: dict = {
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,
SOLAR_POWER_CAP: 0.0,
ATTR_MONEY_SAVED: 0.0,
BATTERY_MODE: MODE_IDLE,
ATTR_STATUS: DEFAULT_BATTERY_STATUS,
ATTR_MONEY_SAVED_IMPORT: 0.0,
ATTR_MONEY_SAVED_EXPORT: 0.0,
BATTERY_CYCLES: 0.0,
BATTERY_DEGRADATION: DEFAULT_BATTERY_DEGRADATION,
ATTR_LAST_CHARGE_EFFICIENCY: default_charge_efficiency,
ATTR_LAST_DISCHARGE_EFFICIENCY: default_discharge_efficiency,
}
for input_details in self._inputs:
self._sensors[input_details[SIMULATED_SENSOR]] = 0.0
async_at_start(self._hass, self.async_source_tracking)
self._listeners.append(
async_dispatcher_connect(
self._hass,
f"{self._name}-{MESSAGE_TYPE_GENERAL}",
self.async_reset_battery,
)
)
@property
def device_identifier(self):
"""Return a stable identifier tuple used for device registry linking."""
return (DOMAIN, self._entry_id or self._name)
def matches_device_identifiers(self, identifiers):
"""Return true when any known identifier matches this handle."""
known_identifiers = {
self.device_identifier,
(DOMAIN, self._name), # Backward compatibility for existing devices.
}
return bool(known_identifiers.intersection(identifiers))
def async_set_battery_charge_state(self, state: float):
"""Reset the battery to start over."""
_LOGGER.debug("Set battery charge state")
if state <= 0:
self._charge_state = 0
elif state <= self.current_max_capacity:
self._charge_state = state
else:
self._charge_state = self.current_max_capacity
dispatcher_send(self._hass, f"{self._name}-{MESSAGE_TYPE_BATTERY_UPDATE}")
return
def async_set_battery_cycles(self, cycles: float):
"""Set battery cycles to simulate ageing on demand."""
self._sensors[BATTERY_CYCLES] = max(float(cycles), 0.0)
self._sensors[ATTR_ENERGY_BATTERY_IN] = self._sensors[BATTERY_CYCLES] * float(
self._battery_size
)
self._sensors[BATTERY_DEGRADATION] = self.degradation_factor
self._charge_state = min(float(self._charge_state), self.current_max_capacity)
self._charge_percentage = round(100 * self._charge_state / self.current_max_capacity)
dispatcher_send(self._hass, f"{self._name}-{MESSAGE_TYPE_BATTERY_UPDATE}")
return
def async_reset_battery(self):
"""Reset the battery to start over."""
_LOGGER.debug("Reset battery")
for input in self._inputs:
self.reset_sim_sensor(input[SIMULATED_SENSOR])
self._charge_state = self.current_max_capacity * INITIAL_SOC_RATIO
self._charge_percentage = INITIAL_CHARGE_PERCENTAGE
default_charge_efficiency = self._safe_curve_efficiency(
self._battery_charge_efficiency_curve
)
default_discharge_efficiency = self._safe_curve_efficiency(
self._battery_discharge_efficiency_curve
)
self._sensors[ATTR_ENERGY_SAVED] = 0.0
self._sensors[ATTR_MONEY_SAVED] = 0.0
self._sensors[ATTR_ENERGY_BATTERY_OUT] = 0.0
self._sensors[ATTR_ENERGY_BATTERY_IN] = 0.0
self._sensors[CHARGING_RATE] = 0.0
self._sensors[DISCHARGING_RATE] = 0.0
self._sensors[BATTERY_MODE] = MODE_IDLE
self._sensors[ATTR_STATUS] = DEFAULT_BATTERY_STATUS
self._sensors[ATTR_MONEY_SAVED_IMPORT] = 0.0
self._sensors[ATTR_MONEY_SAVED_EXPORT] = 0.0
self._sensors[BATTERY_CYCLES] = 0.0
self._sensors[BATTERY_DEGRADATION] = DEFAULT_BATTERY_DEGRADATION
self._sensors[ATTR_LAST_CHARGE_EFFICIENCY] = default_charge_efficiency
self._sensors[ATTR_LAST_DISCHARGE_EFFICIENCY] = default_discharge_efficiency
self._sensors[SOLAR_POWER_CAP] = 0.0
self._accumulated_solar_reading = 0.0
self._energy_saved_today = 0.0
self._energy_saved_week = 0.0
self._energy_saved_month = 0.0
self._date_recording_started = dt_util.now().isoformat()
dispatcher_send(self._hass, f"{self._name}-{MESSAGE_TYPE_BATTERY_UPDATE}")
return
def reset_sim_sensor(self, target_sensor_key):
"""Reset the Simulated Sensor."""
_LOGGER.debug(f"Reset {target_sensor_key} sim sensor")
self._sensors[target_sensor_key] = 0.0
for input_details in self._inputs:
if input_details[SIMULATED_SENSOR] == target_sensor_key:
_LOGGER.warning(input_details[SENSOR_ID])
if self._hass.states.get(input_details[SENSOR_ID]).state not in [
STATE_UNAVAILABLE,
STATE_UNKNOWN,
]:
self._sensors[target_sensor_key] = float(
self._hass.states.get(input_details[SENSOR_ID]).state
)
dispatcher_send(self._hass, f"{self._name}-{MESSAGE_TYPE_BATTERY_UPDATE}")
@callback
def async_source_tracking(self, event):
"""Wait for source to be ready, then start."""
for input_details in self._inputs:
"""Start tracking state changes for a sensor."""
self._listeners.append(
async_track_state_change_event(
self._hass, [input_details[SENSOR_ID]], self.async_reading_handler
)
)
_LOGGER.debug(f"{self._name} monitoring {input_details[SENSOR_ID]}")
# Track solar sensor if configured
if self._solar_entity_id is not None:
self._listeners.append(
async_track_state_change_event(
self._hass, [self._solar_entity_id], self.async_solar_reading_handler
)
)
_LOGGER.debug(f"{self._name} monitoring solar sensor {self._solar_entity_id}")
# Also update on a fixed cadence so the battery reacts even when meters
# publish infrequently or when only switches/controls change.
self._listeners.append(
async_track_time_interval(
self._hass,
self.async_periodic_update,
timedelta(seconds=int(self._update_frequency)),
)
)
return
@callback
def async_periodic_update(self, now):
"""Update battery on a fixed cadence using accumulated readings."""
self._async_maybe_update_battery()
@callback
def async_reading_handler(
self,
event,
):
sensor_id = event.data.get("entity_id")
for input_details in self._inputs:
if sensor_id == input_details[SENSOR_ID]:
break
else:
_LOGGER.warning(
f"Error reading input sensor {sensor_id} not found in input sensors"
)
return
"""Handle the sensor state changes for import or export."""
sensor_charge_rate = (
DISCHARGING_RATE if input_details[SENSOR_TYPE] == IMPORT else CHARGING_RATE
)
old_state = event.data.get("old_state")
new_state = event.data.get("new_state")
if (
old_state is None
or sensor_id 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]
):
# Incorrect Setup or Sensors are not ready
return
units = self._hass.states.get(sensor_id).attributes.get(
ATTR_UNIT_OF_MEASUREMENT
)
if units not in [UnitOfEnergy.KILO_WATT_HOUR, UnitOfEnergy.WATT_HOUR]:
_LOGGER.warning(
"(%s) Unsupported energy unit '%s' for sensor %s; expected kWh or Wh. Ignoring update.",
self._name,
units,
sensor_id,
)
return
conversion_factor = 1.0 if units == UnitOfEnergy.KILO_WATT_HOUR else 0.001
unit_of_energy = "kWh" if units == UnitOfEnergy.KILO_WATT_HOUR else "Wh"
new_state_value = float(new_state.state) * conversion_factor
old_state_value = float(old_state.state) * conversion_factor
if self._sensors[input_details[SIMULATED_SENSOR]] is None:
self._sensors[input_details[SIMULATED_SENSOR]] = old_state_value
if new_state_value == old_state_value:
# _LOGGER.debug("(%s) No change in readings .. ", self._name)
return
reading_variance = new_state_value - old_state_value
_LOGGER.debug(
f"({self._name}) {sensor_id} {input_details[SENSOR_TYPE]}: {old_state_value} {unit_of_energy} => {new_state_value} {unit_of_energy} = Δ {reading_variance} {unit_of_energy}"
)
if reading_variance < 0:
_LOGGER.debug(
"(%s) %s sensor value decreased - rebasing simulated sensor %s",
self._name,
input_details[SENSOR_TYPE],
input_details[SIMULATED_SENSOR],
)
self._sensors[sensor_charge_rate] = 0
self._sensors[input_details[SIMULATED_SENSOR]] = new_state_value
dispatcher_send(self._hass, f"{self._name}-{MESSAGE_TYPE_BATTERY_UPDATE}")
return
if input_details[SENSOR_TYPE] == IMPORT:
self._last_import_reading_sensor_data = input_details
self._accumulated_import_reading += reading_variance
if input_details[SENSOR_TYPE] == EXPORT:
self._last_export_reading_sensor_data = input_details
self._accumulated_export_reading += reading_variance
# NOTE: battery updates are handled by async_periodic_update().
return
@callback
def async_solar_reading_handler(self, event):
"""Handle state changes for solar energy sensor."""
sensor_id = event.data.get("entity_id")
old_state = event.data.get("old_state")
new_state = event.data.get("new_state")
if (
old_state is None
or sensor_id 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]
):
# Sensor not ready
return
units = self._hass.states.get(sensor_id).attributes.get(
ATTR_UNIT_OF_MEASUREMENT
)
if units in [UnitOfEnergy.KILO_WATT_HOUR, UnitOfEnergy.WATT_HOUR]:
conversion_factor = 1.0 if units == UnitOfEnergy.KILO_WATT_HOUR else 0.001
unit_of_energy = "kWh" if units == UnitOfEnergy.KILO_WATT_HOUR else "Wh"
else:
return
new_state_value = float(new_state.state) * conversion_factor
old_state_value = float(old_state.state) * conversion_factor
if new_state_value == old_state_value:
return
reading_variance = new_state_value - old_state_value
_LOGGER.debug(
f"({self._name}) Solar sensor {sensor_id}: {old_state_value} {unit_of_energy} => {new_state_value} {unit_of_energy} = Δ {reading_variance} {unit_of_energy}"
)
if reading_variance < 0:
_LOGGER.debug(
"(%s) Solar sensor value decreased - meter may have been reset",
self._name,
)
self._accumulated_solar_reading = 0
return
self._accumulated_solar_reading += reading_variance
# NOTE: battery updates are handled by async_periodic_update().
return
def get_tariff_information(self, input_details):
if input_details is None:
return None
"""Get Tarrif information to be used for calculating."""
if input_details[TARIFF_TYPE] == NO_TARIFF_INFO:
return None
elif input_details[TARIFF_TYPE] == FIXED_TARIFF:
return input_details[FIXED_TARIFF]
# Default behavior - assume sensor entities
if (
TARIFF_SENSOR not in input_details
or input_details[TARIFF_SENSOR] is None
or len(input_details[TARIFF_SENSOR]) < 6
or self._hass.states.get(input_details[TARIFF_SENSOR]) is None
or self._hass.states.get(input_details[TARIFF_SENSOR]).state
in [STATE_UNAVAILABLE, STATE_UNKNOWN]
):
return None
return float(self._hass.states.get(input_details[TARIFF_SENSOR]).state)
def set_slider_limit(self, value: float, key: str):
"""Called by slider to update internal charge limit."""
if key == "charge_limit":
self._charge_limit = value
elif key == "discharge_limit":
self._discharge_limit = value
elif key == "minimum_soc":
self._minimum_soc = value
elif key == "maximum_soc":
self._maximum_soc = value
else:
_LOGGER.error("Unknown slider type in __init__.py")
@callback
def async_trigger_update(self):
"""Apply pending readings and current controls immediately."""
self._async_maybe_update_battery()
def _async_maybe_update_battery(self):
"""Apply pending readings once the minimum update interval has elapsed."""
elapsed_seconds = dt_util.utcnow().timestamp() - self._last_battery_update_time
if elapsed_seconds < MINIMUM_UPDATE_INTERVAL_SECONDS:
delay = MINIMUM_UPDATE_INTERVAL_SECONDS - elapsed_seconds
if self._pending_update_cancel is None:
_LOGGER.debug(
"(%s) Delaying battery update by %.3f seconds to satisfy minimum interval.",
self._name,
delay,
)
self._pending_update_cancel = async_call_later(
self._hass, delay, self._async_delayed_update
)
return
if self._pending_update_cancel is not None:
self._pending_update_cancel()
self._pending_update_cancel = None
self.update_battery(
self._accumulated_import_reading,
self._accumulated_export_reading,
self._accumulated_solar_reading,
)
self._accumulated_export_reading = 0.0
self._accumulated_import_reading = 0.0
self._accumulated_solar_reading = 0.0
@callback
def _async_delayed_update(self, _now):
"""Run a delayed update created to enforce the minimum update interval."""
self._pending_update_cancel = None
self._async_maybe_update_battery()
@property
def degradation_factor(self) -> float:
"""Return current degradation factor based on charge/discharge cycles."""
cycles = float(self._sensors.get(BATTERY_CYCLES, 0.0))
capped_progress = min(max(cycles / float(self._rated_battery_cycles), 0.0), 1.0)
return 1.0 - ((1.0 - float(self._end_of_life_degradation)) * capped_progress)
@property
def current_max_capacity(self) -> float:
"""Return current degraded maximum battery capacity in kWh."""
return max(float(self._battery_size) * self.degradation_factor, 0.000001)
def update_battery(self, import_amount, export_amount, solar_amount=0.0):
"""Update battery statistics based on the reading for Im- or Export."""
amount_to_charge: float = 0.0
amount_to_discharge: float = 0.0
net_export: float = 0.0
net_import: float = 0.0
if self._charge_state == "unknown":
self._charge_state = 0.0
"""
Calculate maximum possible charge and discharge based on battery
specifications and time since last discharge
"""
time_now = dt_util.utcnow().timestamp()
time_last_update = self._last_battery_update_time
time_since_last_battery_update = time_now - time_last_update
_LOGGER.debug(
"(%s), Size: (%s)kWh, Import: (%s), Export: (%s), Initial charge level: (%s) .... Timings: %s = Now / %s = Last Update / %s Time (sec).",
self._name,
self._battery_size,
import_amount,
export_amount,
self._charge_state,
time_now,
time_last_update,
time_since_last_battery_update,
)
max_discharge = time_since_last_battery_update * (
self._max_discharge_rate / 3600
)
max_charge = time_since_last_battery_update * (self._max_charge_rate / 3600)
interval_hours = max(time_since_last_battery_update / 3600, 1 / 3600)
charge_limit = time_since_last_battery_update * (self._charge_limit / 3600)
discharge_limit = time_since_last_battery_update * (self._discharge_limit / 3600)
if self._solar_entity_id is not None:
solar_cap = max(float(solar_amount), 0.0)
max_charge = min(max_charge, solar_cap)
self._sensors[SOLAR_POWER_CAP] = solar_cap / interval_hours
if self._nominal_inverter_power is not None:
available_inverter_discharge_power = max(
float(self._nominal_inverter_power) - self._sensors[SOLAR_POWER_CAP],
0.0,
)
max_discharge = min(
max_discharge, available_inverter_discharge_power * interval_hours
)
_LOGGER.debug(
f"({self._name}) Solar cap: {solar_cap} kWh over {interval_hours:.4f} hours = {self._sensors[SOLAR_POWER_CAP]:.3f} kW"
)
else:
self._sensors[SOLAR_POWER_CAP] = 0.0
effective_max_capacity = self.current_max_capacity
max_charge_soc_capacity = effective_max_capacity * float(self._maximum_soc) / 100
min_discharge_soc_capacity = (
effective_max_capacity * float(self._minimum_soc) / 100
)
available_capacity_to_charge = max(
max_charge_soc_capacity - float(self._charge_state), 0
)
available_capacity_to_discharge = max(
float(self._charge_state) - min_discharge_soc_capacity, 0
)
if self._switches[PAUSE_BATTERY] or self._battery_mode == PAUSE_BATTERY:
_LOGGER.debug("(%s) Battery paused.", self._name)
amount_to_charge = 0.0
amount_to_discharge = 0.0
elif self._battery_mode == OVERRIDE_CHARGING:
_LOGGER.debug("(%s) Battery override charging.", self._name)
amount_to_charge = min(max_charge, charge_limit)
amount_to_discharge = 0.0
self._charging = True
elif self._battery_mode == FORCE_DISCHARGE:
_LOGGER.debug("(%s) Battery forced discharging.", self._name)
amount_to_charge = 0.0
amount_to_discharge = min(max_discharge, discharge_limit)
elif self._battery_mode == CHARGE_ONLY:
_LOGGER.debug("(%s) Battery charge only mode.", self._name)
amount_to_charge = min(export_amount, max_charge, charge_limit)
amount_to_discharge = 0.0
elif self._battery_mode == DISCHARGE_ONLY:
_LOGGER.debug("(%s) Battery discharge only mode.", self._name)
amount_to_charge = 0.0
amount_to_discharge = min(import_amount, max_discharge, discharge_limit)
else:
_LOGGER.debug("(%s) Battery normal mode.", self._name)
amount_to_charge = min(export_amount, max_charge, charge_limit)
amount_to_discharge = min(import_amount, max_discharge, discharge_limit)
# Keep amount_to_charge as input-side energy and amount_to_discharge as
# output-side delivered energy. The SoC capacities are battery-internal,
# so convert those limits through the efficiency curve. Because the
# efficiency curve is power-dependent, recompute it after each clipping
# step until the clipped amount and efficiency agree.
for _ in range(10):
if amount_to_charge <= 0.0:
break
charge_efficiency = interpolate_efficiency(
self._battery_charge_efficiency_curve,
amount_to_charge / interval_hours,
)
clipped_amount_to_charge = min(
amount_to_charge,
available_capacity_to_charge / max(charge_efficiency, 0.000001),
)
if abs(clipped_amount_to_charge - amount_to_charge) < 0.000001:
break
amount_to_charge = clipped_amount_to_charge
for _ in range(10):
if amount_to_discharge <= 0.0:
break
discharge_efficiency = interpolate_efficiency(
self._battery_discharge_efficiency_curve,
amount_to_discharge / interval_hours,
)
clipped_amount_to_discharge = min(
amount_to_discharge,
available_capacity_to_discharge * discharge_efficiency,
)
if abs(clipped_amount_to_discharge - amount_to_discharge) < 0.000001:
break
amount_to_discharge = clipped_amount_to_discharge
requested_charge_power = (
amount_to_charge / interval_hours if amount_to_charge > 0 else 0.0
)
requested_discharge_power = (
amount_to_discharge / interval_hours if amount_to_discharge > 0 else 0.0
)
charge_efficiency = interpolate_efficiency(
self._battery_charge_efficiency_curve, requested_charge_power
)
discharge_efficiency = interpolate_efficiency(
self._battery_discharge_efficiency_curve, requested_discharge_power
)
self._sensors[ATTR_LAST_CHARGE_EFFICIENCY] = (
charge_efficiency if amount_to_charge > 0 else None
)
self._sensors[ATTR_LAST_DISCHARGE_EFFICIENCY] = (
discharge_efficiency if amount_to_discharge > 0 else None
)
if self._switches[PAUSE_BATTERY] or self._battery_mode == PAUSE_BATTERY:
self._sensors[BATTERY_MODE] = MODE_IDLE
elif self._battery_mode == OVERRIDE_CHARGING:
self._sensors[BATTERY_MODE] = (
MODE_FORCE_CHARGING if amount_to_charge > 0.0 else MODE_IDLE
)
elif self._battery_mode == FORCE_DISCHARGE:
self._sensors[BATTERY_MODE] = (
MODE_FORCE_DISCHARGING if amount_to_discharge > 0.0 else MODE_IDLE
)
elif amount_to_charge > 0.0 and amount_to_charge >= amount_to_discharge:
self._sensors[BATTERY_MODE] = MODE_CHARGING
elif amount_to_discharge > 0.0:
self._sensors[BATTERY_MODE] = MODE_DISCHARGING
else:
self._sensors[BATTERY_MODE] = MODE_IDLE
# Calculate net grid import/export once, using efficiency-adjusted
# charge/discharge amounts.
if self._battery_mode == OVERRIDE_CHARGING:
net_export = max(export_amount - amount_to_charge, 0)
net_import = max(amount_to_charge - export_amount, 0) + import_amount
elif self._battery_mode == FORCE_DISCHARGE:
net_export = max(amount_to_discharge - import_amount, 0) + export_amount
net_import = max(import_amount - amount_to_discharge, 0)
elif self._battery_mode == CHARGE_ONLY:
net_import = import_amount
net_export = export_amount - amount_to_charge
elif self._battery_mode == DISCHARGE_ONLY:
net_import = import_amount - amount_to_discharge
net_export = export_amount
elif self._switches[PAUSE_BATTERY] or self._battery_mode == PAUSE_BATTERY:
net_export = export_amount
net_import = import_amount
else:
net_import = import_amount - amount_to_discharge
net_export = export_amount - amount_to_charge
current_import_tariff = self.get_tariff_information(
self._last_import_reading_sensor_data
)
current_export_tariff = self.get_tariff_information(
self._last_export_reading_sensor_data
)
if current_import_tariff is not None:
self._sensors[ATTR_MONEY_SAVED_IMPORT] += (
import_amount - net_import
) * current_import_tariff
if current_export_tariff is not None:
self._sensors[ATTR_MONEY_SAVED_EXPORT] += (
net_export - export_amount
) * current_export_tariff
self._sensors[ATTR_MONEY_SAVED] = (
self._sensors[ATTR_MONEY_SAVED_IMPORT]
+ self._sensors[ATTR_MONEY_SAVED_EXPORT]
)
self._charge_state = (
float(self._charge_state)
+ (amount_to_charge * charge_efficiency)
- (amount_to_discharge / max(discharge_efficiency, 0.000001))
)
self._sensors[ATTR_ENERGY_SAVED] += import_amount - net_import
if self._last_import_reading_sensor_data is not None:
self._sensors[
self._last_import_reading_sensor_data[SIMULATED_SENSOR]
] += net_import
if self._last_export_reading_sensor_data is not None:
self._sensors[
self._last_export_reading_sensor_data[SIMULATED_SENSOR]
] += 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 / interval_hours
self._sensors[DISCHARGING_RATE] = amount_to_discharge / interval_hours
self._sensors[BATTERY_CYCLES] = (
self._sensors[ATTR_ENERGY_BATTERY_IN] / self._battery_size
)
self._sensors[BATTERY_DEGRADATION] = self.degradation_factor
self._charge_state = min(float(self._charge_state), effective_max_capacity)
self._charge_percentage = round(100 * self._charge_state / effective_max_capacity)
# Keep "mode" (how the battery operates) separate from capacity "status".
if self._charge_percentage < 2:
self._sensors[ATTR_STATUS] = MODE_EMPTY
elif self._charge_percentage > 98:
self._sensors[ATTR_STATUS] = MODE_FULL
else:
self._sensors[ATTR_STATUS] = "Normal"
# Reset day/week/month counters using Home Assistant's configured timezone.
now_local = dt_util.now()
last_update_local = dt_util.as_local(
dt_util.utc_from_timestamp(time_last_update)
)
if now_local.date() != last_update_local.date():
self._energy_saved_today = 0
if now_local.isocalendar()[:2] != last_update_local.isocalendar()[:2]:
self._energy_saved_week = 0
if (now_local.year, now_local.month) != (
last_update_local.year,
last_update_local.month,
):
self._energy_saved_month = 0
self._last_battery_update_time = time_now
dispatcher_send(self._hass, f"{self._name}-{MESSAGE_TYPE_BATTERY_UPDATE}")
_LOGGER.debug("(%s) Battery update complete. New Charge level: (%s)", self._name, self._charge_state)
================================================
FILE: custom_components/battery_sim/button.py
================================================
"""Switch Platform Device for Battery Sim."""
import logging
from homeassistant.components.button import ButtonEntity
from homeassistant.helpers.dispatcher import dispatcher_send
from .const import DOMAIN, CONF_BATTERY, RESET_BATTERY, MESSAGE_TYPE_GENERAL
_LOGGER = logging.getLogger(__name__)
BATTERY_BUTTONS = [
{
"name": RESET_BATTERY,
"key": "override_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
battery_buttons = [
BatteryButton(handle, button["name"], button["key"], button["icon"])
for button in BATTERY_BUTTONS
]
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 = [
BatteryButton(handle, button["name"], button["key"], button["icon"])
for button in BATTERY_BUTTONS
]
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._device_identifier = handle.device_identifier
self._name = f"{handle._name} ".replace("_", " ") + f"{button_type}".replace("_", " ").capitalize()
self._attr_unique_id = f"{handle._name} - {button_type}"
self._type = type
@property
def unique_id(self):
"""Return uniqueid."""
return self._attr_unique_id
@property
def name(self):
return self._name
@property
def device_info(self):
return {
"name": self._device_name,
"identifiers": {self._device_identifier},
}
@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._device_name}-{MESSAGE_TYPE_GENERAL}")
================================================
FILE: custom_components/battery_sim/config_flow.py
================================================
"""Configuration flow for the Battery."""
import logging
import voluptuous as vol
import time
from homeassistant import config_entries
from homeassistant.helpers.selector import (
EntitySelector,
EntitySelectorConfig,
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.const import CONF_NAME
from homeassistant.core import callback
from .const import (
DOMAIN,
BATTERY_OPTIONS,
BATTERY_TYPE,
CONF_BATTERY_SIZE,
CONF_BATTERY_MAX_DISCHARGE_RATE,
CONF_BATTERY_MAX_CHARGE_RATE,
CONF_BATTERY_CHARGE_EFFICIENCY,
CONF_BATTERY_DISCHARGE_EFFICIENCY,
CONF_BATTERY_EFFICIENCY,
CONF_END_OF_LIFE_DEGRADATION,
CONF_UPDATE_FREQUENCY,
CONF_INPUT_LIST,
CONF_RATED_BATTERY_CYCLES,
CONF_SOLAR_ENERGY_SENSOR,
CONF_NOMINAL_INVERTER_POWER,
CONF_UNIQUE_NAME,
SETUP_TYPE,
CONFIG_FLOW,
TARIFF_TYPE,
NO_TARIFF_INFO,
IMPORT,
EXPORT,
SENSOR_ID,
SENSOR_TYPE,
TARIFF_SENSOR,
FIXED_TARIFF,
SIMULATED_SENSOR,
)
from .helpers import generate_input_list, validate_efficiency_config
EFFICIENCY_TEXT_SELECTOR = TextSelector(
TextSelectorConfig(type=TextSelectorType.TEXT)
)
_LOGGER = logging.getLogger(__name__)
@config_entries.HANDLERS.register(DOMAIN)
class BatterySetupConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Config flow."""
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Return flow options."""
return BatteryOptionsFlowHandler(config_entry)
@staticmethod
def _validate_efficiency_fields(user_input):
"""Return field errors for invalid efficiency inputs."""
errors = {}
for key in (
CONF_BATTERY_DISCHARGE_EFFICIENCY,
CONF_BATTERY_CHARGE_EFFICIENCY,
):
try:
validate_efficiency_config(user_input[key])
except (ValueError, TypeError):
errors[key] = "invalid_input"
return errors
async def async_step_user(self, user_input):
"""Handle a flow initialized by the user."""
if user_input is not None:
if user_input[BATTERY_TYPE] == "Custom":
return await self.async_step_custom()
self._data = BATTERY_OPTIONS[user_input[BATTERY_TYPE]]
self._data[SETUP_TYPE] = CONFIG_FLOW
self._data[CONF_NAME] = f"{user_input[BATTERY_TYPE]}"
self._data[CONF_RATED_BATTERY_CYCLES] = 6000
self._data[CONF_END_OF_LIFE_DEGRADATION] = 0.8
self._data[CONF_UPDATE_FREQUENCY] = 60
await self.async_set_unique_id(self._data[CONF_NAME])
self._abort_if_unique_id_configured()
self._data[CONF_INPUT_LIST] = []
return await self.async_step_meter_menu()
battery_options_names = list(BATTERY_OPTIONS)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(BATTERY_TYPE): vol.In(battery_options_names),
}
),
)
async def async_step_custom(self, user_input=None):
errors = {}
if user_input is not None:
errors = self._validate_efficiency_fields(user_input)
if not errors:
self._data = user_input
self._data[SETUP_TYPE] = CONFIG_FLOW
self._data[CONF_NAME] = f"{self._data[CONF_UNIQUE_NAME]}"
self._data[CONF_INPUT_LIST] = []
solar_sensor = user_input.get(CONF_SOLAR_ENERGY_SENSOR)
if solar_sensor:
self._data[CONF_SOLAR_ENERGY_SENSOR] = solar_sensor
else:
self._data.pop(CONF_SOLAR_ENERGY_SENSOR, None)
nominal_inverter_power = user_input.get(CONF_NOMINAL_INVERTER_POWER)
if nominal_inverter_power is not None:
self._data[CONF_NOMINAL_INVERTER_POWER] = nominal_inverter_power
else:
self._data.pop(CONF_NOMINAL_INVERTER_POWER, None)
await self.async_set_unique_id(self._data[CONF_NAME])
self._abort_if_unique_id_configured()
return await self.async_step_meter_menu()
return self.async_show_form(
step_id="custom",
data_schema=vol.Schema(
{
vol.Required(CONF_UNIQUE_NAME): vol.All(str),
vol.Required(CONF_BATTERY_SIZE): vol.All(vol.Coerce(float)),
vol.Required(CONF_BATTERY_MAX_DISCHARGE_RATE): vol.All(
vol.Coerce(float)
),
vol.Required(CONF_BATTERY_MAX_CHARGE_RATE): vol.All(
vol.Coerce(float)
),
vol.Required(
CONF_BATTERY_DISCHARGE_EFFICIENCY, default="0.9"
): EFFICIENCY_TEXT_SELECTOR,
vol.Required(
CONF_BATTERY_CHARGE_EFFICIENCY, default="0.9"
): EFFICIENCY_TEXT_SELECTOR,
vol.Required(CONF_RATED_BATTERY_CYCLES, default=6000): vol.All(
vol.Coerce(float), vol.Range(min=1)
),
vol.Required(CONF_END_OF_LIFE_DEGRADATION, default=0.8): vol.All(
vol.Coerce(float), vol.Range(min=0, max=1)
),
vol.Required(CONF_UPDATE_FREQUENCY, default=60): vol.All(
vol.Coerce(int), vol.Range(min=1)
),
vol.Optional(CONF_SOLAR_ENERGY_SENSOR): EntitySelector(
EntitySelectorConfig(device_class=SensorDeviceClass.ENERGY)
),
vol.Optional(CONF_NOMINAL_INVERTER_POWER): vol.All(
vol.Coerce(float), vol.Range(min=0)
),
}
),
errors=errors,
)
async def async_step_meter_menu(self, user_input=None):
menu_options = ["add_import_meter", "add_export_meter"]
import_meter: bool = False
export_meter: bool = False
for input in self._data[CONF_INPUT_LIST]:
if input[SENSOR_TYPE] == IMPORT:
import_meter = True
if input[SENSOR_TYPE] == EXPORT:
export_meter = True
if import_meter and export_meter:
menu_options.append("all_done")
return self.async_show_menu(step_id="meter_menu", menu_options=menu_options)
async def async_step_add_import_meter(self, user_input=None):
if user_input is not None:
self.current_input_entry: dict = {
SENSOR_ID: user_input[SENSOR_ID],
SENSOR_TYPE: IMPORT,
SIMULATED_SENSOR: f"simulated_{user_input[SENSOR_ID]}",
}
return await self.async_step_tariff_menu()
return self.async_show_form(
step_id="add_import_meter",
data_schema=vol.Schema(
{
vol.Required(SENSOR_ID): EntitySelector(
EntitySelectorConfig(device_class=SensorDeviceClass.ENERGY)
),
}
),
)
async def async_step_add_export_meter(self, user_input=None):
if user_input is not None:
self.current_input_entry: dict = {
SENSOR_ID: user_input[SENSOR_ID],
SENSOR_TYPE: EXPORT,
SIMULATED_SENSOR: f"simulated_{user_input[SENSOR_ID]}",
}
return await self.async_step_tariff_menu()
return self.async_show_form(
step_id="add_export_meter",
data_schema=vol.Schema(
{
vol.Required(SENSOR_ID): EntitySelector(
EntitySelectorConfig(device_class=SensorDeviceClass.ENERGY)
),
}
),
)
async def async_step_tariff_menu(self, user_input=None):
return self.async_show_menu(
step_id="tariff_menu",
menu_options=["no_tariff_info", "fixed_tariff", "tariff_sensor"],
)
async def async_step_no_tariff_info(self, user_input=None):
self.current_input_entry[TARIFF_TYPE] = NO_TARIFF_INFO
self._data[CONF_INPUT_LIST].append(self.current_input_entry)
return await self.async_step_meter_menu()
async def async_step_fixed_tariff(self, user_input=None):
if user_input is not None:
self.current_input_entry[TARIFF_TYPE] = FIXED_TARIFF
self.current_input_entry[FIXED_TARIFF] = user_input[FIXED_TARIFF]
self._data[CONF_INPUT_LIST].append(self.current_input_entry)
return await self.async_step_meter_menu()
return self.async_show_form(
step_id="fixed_tariff",
data_schema=vol.Schema(
{
vol.Optional(FIXED_TARIFF): vol.All(
vol.Coerce(float), vol.Range(min=0, max=100)
)
}
),
)
async def async_step_tariff_sensor(self, user_input=None):
if user_input is not None:
self.current_input_entry[TARIFF_TYPE] = TARIFF_SENSOR
self.current_input_entry[TARIFF_SENSOR] = user_input[TARIFF_SENSOR]
self._data[CONF_INPUT_LIST].append(self.current_input_entry)
return await self.async_step_meter_menu()
return self.async_show_form(
step_id="tariff_sensor",
data_schema=vol.Schema(
{vol.Required(TARIFF_SENSOR): EntitySelector(EntitySelectorConfig())}
),
)
async def async_step_all_done(self, user_input=None):
return self.async_create_entry(title=self._data[CONF_NAME], data=self._data)
class BatteryOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle a option flow for battery."""
def __init__(self, config_entry=None):
"""Initialize options flow."""
self._config_entry_compat = config_entry
self.updated_entry = None
self.current_input_entry = None
@property
def _battery_config_entry(self):
"""Return the config entry for both old and new Home Assistant cores."""
return getattr(self, "config_entry", None) or self._config_entry_compat
@staticmethod
def _validate_efficiency_fields(user_input):
"""Return field errors for invalid efficiency inputs."""
errors = {}
for key in (
CONF_BATTERY_DISCHARGE_EFFICIENCY,
CONF_BATTERY_CHARGE_EFFICIENCY,
):
try:
validate_efficiency_config(user_input[key])
except (ValueError, TypeError):
errors[key] = "invalid_input"
return errors
async def async_step_init(self, user_input=None):
"""Handle options flow."""
config_entry = self._battery_config_entry
self.updated_entry = config_entry.data.copy()
self._active_config_entry = config_entry
if CONF_INPUT_LIST not in self.updated_entry:
self.updated_entry[CONF_INPUT_LIST] = generate_input_list(
config=self.updated_entry
)
return self.async_show_menu(
step_id="init", menu_options=["main_params", "input_sensors", "all_done"]
)
async def async_step_main_params(self, user_input=None):
errors = {}
if user_input is not None:
errors = self._validate_efficiency_fields(user_input)
if not errors:
self.updated_entry[CONF_BATTERY_SIZE] = user_input[CONF_BATTERY_SIZE]
self.updated_entry[CONF_BATTERY_MAX_CHARGE_RATE] = user_input[
CONF_BATTERY_MAX_CHARGE_RATE
]
self.updated_entry[CONF_BATTERY_MAX_DISCHARGE_RATE] = user_input[
CONF_BATTERY_MAX_DISCHARGE_RATE
]
self.updated_entry[CONF_BATTERY_DISCHARGE_EFFICIENCY] = user_input[
CONF_BATTERY_DISCHARGE_EFFICIENCY
]
self.updated_entry[CONF_BATTERY_CHARGE_EFFICIENCY] = user_input[
CONF_BATTERY_CHARGE_EFFICIENCY
]
self.updated_entry[CONF_RATED_BATTERY_CYCLES] = user_input[
CONF_RATED_BATTERY_CYCLES
]
self.updated_entry[CONF_END_OF_LIFE_DEGRADATION] = user_input[
CONF_END_OF_LIFE_DEGRADATION
]
self.updated_entry.pop(CONF_BATTERY_EFFICIENCY, None)
self.updated_entry[CONF_UPDATE_FREQUENCY] = user_input[
CONF_UPDATE_FREQUENCY
]
if user_input.get(CONF_SOLAR_ENERGY_SENSOR):
self.updated_entry[CONF_SOLAR_ENERGY_SENSOR] = user_input[
CONF_SOLAR_ENERGY_SENSOR
]
else:
self.updated_entry.pop(CONF_SOLAR_ENERGY_SENSOR, None)
if user_input.get(CONF_NOMINAL_INVERTER_POWER) is not None:
self.updated_entry[CONF_NOMINAL_INVERTER_POWER] = user_input[
CONF_NOMINAL_INVERTER_POWER
]
else:
self.updated_entry.pop(CONF_NOMINAL_INVERTER_POWER, None)
self.hass.config_entries.async_update_entry(
self._active_config_entry,
data=self.updated_entry,
options=self._active_config_entry.options,
)
return await self.async_step_init()
data_schema = {
vol.Required(
CONF_BATTERY_SIZE, default=self.updated_entry[CONF_BATTERY_SIZE]
): vol.All(vol.Coerce(float)),
vol.Required(
CONF_BATTERY_MAX_CHARGE_RATE,
default=self.updated_entry[CONF_BATTERY_MAX_CHARGE_RATE],
): vol.All(vol.Coerce(float)),
vol.Required(
CONF_BATTERY_MAX_DISCHARGE_RATE,
default=self.updated_entry[CONF_BATTERY_MAX_DISCHARGE_RATE],
): vol.All(vol.Coerce(float)),
# Use .get() so existing entries using legacy `efficiency` keep working.
vol.Required(
CONF_BATTERY_DISCHARGE_EFFICIENCY,
default=str(
self.updated_entry.get(
CONF_BATTERY_DISCHARGE_EFFICIENCY,
self.updated_entry.get(CONF_BATTERY_EFFICIENCY, 0.9),
)
),
): EFFICIENCY_TEXT_SELECTOR,
vol.Required(
CONF_BATTERY_CHARGE_EFFICIENCY,
default=str(
self.updated_entry.get(
CONF_BATTERY_CHARGE_EFFICIENCY,
self.updated_entry.get(CONF_BATTERY_EFFICIENCY, 1.0),
)
),
): EFFICIENCY_TEXT_SELECTOR,
vol.Required(
CONF_RATED_BATTERY_CYCLES,
default=self.updated_entry.get(CONF_RATED_BATTERY_CYCLES, 6000),
): vol.All(vol.Coerce(float), vol.Range(min=1)),
vol.Required(
CONF_END_OF_LIFE_DEGRADATION,
default=self.updated_entry.get(CONF_END_OF_LIFE_DEGRADATION, 0.8),
): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
vol.Required(
CONF_UPDATE_FREQUENCY,
default=self.updated_entry.get(CONF_UPDATE_FREQUENCY, 60),
): vol.All(vol.Coerce(int), vol.Range(min=1)),
vol.Optional(
CONF_SOLAR_ENERGY_SENSOR,
description={
"suggested_value": self.updated_entry.get(CONF_SOLAR_ENERGY_SENSOR)
},
): EntitySelector(
EntitySelectorConfig(device_class=SensorDeviceClass.ENERGY)
),
vol.Optional(
CONF_NOMINAL_INVERTER_POWER,
default=self.updated_entry.get(CONF_NOMINAL_INVERTER_POWER),
): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0))),
}
return self.async_show_form(
step_id="main_params",
data_schema=vol.Schema(data_schema),
errors=errors,
)
async def async_step_input_sensors(self, user_input=None):
"""Handle options flow."""
self.current_input_entry = None
return self.async_show_menu(
step_id="input_sensors",
menu_options=[
"add_import_meter",
"add_export_meter",
"edit_input_tariff",
"delete_input",
],
)
async def async_step_delete_input(self, user_input=None):
if user_input is not None:
for input in self.updated_entry[CONF_INPUT_LIST]:
if input[SENSOR_ID] == user_input[CONF_INPUT_LIST]:
self.updated_entry[CONF_INPUT_LIST].remove(input)
self.hass.config_entries.async_update_entry(
self._active_config_entry,
data=self.updated_entry,
options=self._active_config_entry.options,
)
return await self.async_step_init()
list_of_inputs = []
for input in self.updated_entry[CONF_INPUT_LIST]:
list_of_inputs.append(input[SENSOR_ID])
data_schema = {
vol.Required(CONF_INPUT_LIST): vol.In(list_of_inputs),
}
return self.async_show_form(
step_id="delete_input", data_schema=vol.Schema(data_schema)
)
async def async_step_edit_input_tariff(self, user_input=None):
if user_input is not None:
for input in self.updated_entry[CONF_INPUT_LIST]:
if input[SENSOR_ID] == user_input[CONF_INPUT_LIST]:
self.current_input_entry = input
return await self.async_step_tariff_menu()
list_of_inputs = []
for input in self.updated_entry[CONF_INPUT_LIST]:
list_of_inputs.append(input[SENSOR_ID])
data_schema = {
vol.Required(CONF_INPUT_LIST): vol.In(list_of_inputs),
}
return self.async_show_form(
step_id="edit_input_tariff", data_schema=vol.Schema(data_schema)
)
async def async_step_add_import_meter(self, user_input=None):
if user_input is not None:
self.current_input_entry: dict = {
SENSOR_ID: user_input[SENSOR_ID],
SENSOR_TYPE: IMPORT,
SIMULATED_SENSOR: f"simulated_{user_input[SENSOR_ID]}",
}
self.updated_entry[CONF_INPUT_LIST].append(self.current_input_entry)
return await self.async_step_tariff_menu()
return self.async_show_form(
step_id="add_import_meter",
data_schema=vol.Schema(
{
vol.Required(SENSOR_ID): EntitySelector(
EntitySelectorConfig(device_class=SensorDeviceClass.ENERGY)
),
}
),
)
async def async_step_add_export_meter(self, user_input=None):
if user_input is not None:
self.current_input_entry: dict = {
SENSOR_ID: user_input[SENSOR_ID],
SENSOR_TYPE: EXPORT,
SIMULATED_SENSOR: f"simulated_{user_input[SENSOR_ID]}",
}
self.updated_entry[CONF_INPUT_LIST].append(self.current_input_entry)
return await self.async_step_tariff_menu()
return self.async_show_form(
step_id="add_export_meter",
data_schema=vol.Schema(
{
vol.Required(SENSOR_ID): EntitySelector(
EntitySelectorConfig(device_class=SensorDeviceClass.ENERGY)
),
}
),
)
async def async_step_tariff_menu(self, user_input=None):
return self.async_show_menu(
step_id="tariff_menu",
menu_options=["no_tariff_info", "fixed_tariff", "tariff_sensor"],
)
async def async_step_no_tariff_info(self, user_input=None):
self.current_input_entry[TARIFF_TYPE] = NO_TARIFF_INFO
self.hass.config_entries.async_update_entry(
self._active_config_entry,
data=self.updated_entry,
options=self._active_config_entry.options,
)
return await self.async_step_init()
async def async_step_fixed_tariff(self, user_input=None):
if user_input is not None:
self.current_input_entry[TARIFF_TYPE] = FIXED_TARIFF
self.current_input_entry[FIXED_TARIFF] = user_input[FIXED_TARIFF]
self.hass.config_entries.async_update_entry(
self._active_config_entry,
data=self.updated_entry,
options=self._active_config_entry.options,
)
return await self.async_step_init()
current_val = self.current_input_entry.get(FIXED_TARIFF, None)
return self.async_show_form(
step_id="fixed_tariff",
data_schema=vol.Schema(
{
vol.Optional(FIXED_TARIFF, default=current_val): vol.All(
vol.Coerce(float),
vol.Range(min=0, max=100),
)
}
),
)
async def async_step_tariff_sensor(self, user_input=None):
if user_input is not None:
self.current_input_entry[TARIFF_TYPE] = TARIFF_SENSOR
self.current_input_entry[TARIFF_SENSOR] = user_input[TARIFF_SENSOR]
self.hass.config_entries.async_update_entry(
self._active_config_entry,
data=self.updated_entry,
options=self._active_config_entry.options,
)
return await self.async_step_init()
current_val = self.current_input_entry.get(TARIFF_SENSOR, None)
return self.async_show_form(
step_id="tariff_sensor",
data_schema=vol.Schema(
{vol.Required(TARIFF_SENSOR): EntitySelector(EntitySelectorConfig())}
),
)
async def async_step_all_done(self, user_input=None):
data = {"time": time.asctime()}
return self.async_create_entry(title="", data=data)
================================================
FILE: custom_components/battery_sim/const.py
================================================
"""Constants for the battery_sim component."""
DOMAIN = "battery_sim"
BATTERY_TYPE = "battery"
BATTERY_PLATFORMS = ["sensor", "switch", "button", "select", "number"]
MESSAGE_TYPE_GENERAL = "BatteryResetMessage"
MESSAGE_TYPE_BATTERY_RESET_IMP = "BatteryResetImportSim"
MESSAGE_TYPE_BATTERY_RESET_EXP = "BatteryResetExportSim"
MESSAGE_TYPE_BATTERY_UPDATE = "BatteryUpdateMessage"
DATA_UTILITY = "battery_sim_data"
SETUP_TYPE = "setup_type"
CONFIG_FLOW = "config_flow"
YAML = "yaml"
CONF_BATTERY = "battery"
CONF_INPUT_LIST = "input_list"
CONF_IMPORT_SENSOR = "import_sensor"
CONF_SECOND_IMPORT_SENSOR = "second_import_sensor"
CONF_EXPORT_SENSOR = "export_sensor"
CONF_SECOND_EXPORT_SENSOR = "second_export_sensor"
CONF_SOLAR_ENERGY_SENSOR = "solar_energy_sensor"
CONF_NOMINAL_INVERTER_POWER = "nominal_inverter_power_kw"
CONF_BATTERY_SIZE = "size_kwh"
CONF_BATTERY_MAX_DISCHARGE_RATE = "max_discharge_rate_kw"
CONF_BATTERY_MAX_CHARGE_RATE = "max_charge_rate_kw"
CONF_BATTERY_EFFICIENCY = "efficiency" # Legacy key kept for backwards compatibility.
CONF_BATTERY_DISCHARGE_EFFICIENCY = "discharge_efficiency"
CONF_BATTERY_CHARGE_EFFICIENCY = "charge_efficiency"
ATTR_LAST_CHARGE_EFFICIENCY = "last charge efficiency"
ATTR_LAST_DISCHARGE_EFFICIENCY = "last discharge efficiency"
MINIMUM_UPDATE_INTERVAL_SECONDS = 5
CONF_ENERGY_TARIFF = "energy_tariff"
CONF_ENERGY_IMPORT_TARIFF = "energy_import_tariff"
CONF_ENERGY_EXPORT_TARIFF = "energy_export_tariff"
CONF_UNIQUE_NAME = "unique_name"
CONF_RATED_BATTERY_CYCLES = "rated_battery_cycles"
CONF_END_OF_LIFE_DEGRADATION = "end_of_life_degradation"
CONF_UPDATE_FREQUENCY = "update_frequency"
ATTR_VALUE = "value"
METER_TYPE = "type_of_energy_meter"
ONE_IMPORT_ONE_EXPORT_METER = "one_import_one_export"
TWO_IMPORT_ONE_EXPORT_METER = "two_import_one_export"
TWO_IMPORT_TWO_EXPORT_METER = "two_import_two_export"
TARIFF_TYPE = "tariff_type"
NO_TARIFF_INFO = "No tariff information"
TARIFF_SENSOR = "tariff_sensor"
FIXED_TARIFF = "fixed_tariff"
TARIFF_SENSOR_ENTITIES = "Sensors that track tariffs"
FIXED_NUMERICAL_TARIFFS = "Fixed value for tariffs"
NEXT_STEP = "next_step"
ADD_ANOTHER = "Add another"
ALL_DONE = "All done"
ATTR_SOURCE_ID = "sources"
ATTR_STATUS = "status"
PRECISION = 3
ATTR_ENERGY_SAVED = "total energy saved"
ATTR_ENERGY_SAVED_TODAY = "energy_saved_today"
ATTR_ENERGY_SAVED_WEEK = "energy_saved_this_week"
ATTR_ENERGY_SAVED_MONTH = "energy_saved_this_month"
ATTR_DATE_RECORDING_STARTED = "date_recording_started"
ATTR_ENERGY_BATTERY_OUT = "battery_energy_out"
ATTR_ENERGY_BATTERY_IN = "battery_energy_in"
ATTR_MONEY_SAVED = "total_money_saved"
ATTR_MONEY_SAVED_IMPORT = "money_saved_on_imports"
ATTR_MONEY_SAVED_EXPORT = "extra_money_earned_on_exports"
CHARGING_RATE = "current charging rate"
DISCHARGING_RATE = "current discharging rate"
SOLAR_POWER_CAP = "solar power cap"
ATTR_CHARGE_PERCENTAGE = "percentage"
GRID_EXPORT_SIM = "simulated grid export after battery charging"
GRID_IMPORT_SIM = "simulated grid import after battery discharging"
GRID_SECOND_EXPORT_SIM = "simulated second grid export after battery charging"
GRID_SECOND_IMPORT_SIM = "simulated second grid import after battery discharging"
ICON_CHARGING = "mdi:battery-charging-50"
ICON_DISCHARGING = "mdi:battery-50"
ICON_FULL = "mdi:battery"
ICON_EMPTY = "mdi:battery-outline"
CHARGE_LIMIT = "charge_limit"
DISCHARGE_LIMIT = "discharge_limit"
MINIMUM_SOC = "minimum_soc"
MAXIMUM_SOC = "maximum_soc"
OVERRIDE_CHARGING = "force_charge"
FORCE_DISCHARGE = "force_discharge"
CHARGE_ONLY = "charge_only"
DISCHARGE_ONLY = "discharge_only"
PAUSE_BATTERY = "pause_battery"
RESET_BATTERY = "reset_battery"
DEFAULT_MODE = "default_mode"
PERCENTAGE_ENERGY_IMPORT_SAVED = "percentage_import_energy_saved"
BATTERY_CYCLES = "battery_cycles"
BATTERY_DEGRADATION = "battery_degradation"
SENSOR_ID = "sensor_id"
SENSOR_TYPE = "sensor_type"
TARIFF = "tariff_sensor_or_value"
CONF_SECOND_ENERGY_IMPORT_TARIFF = "second_energy_import_tariff"
CONF_SECOND_ENERGY_EXPORT_TARIFF = "second_energy_export_tariff"
IMPORT = "Import"
EXPORT = "Export"
SOLAR = "Solar"
SIMULATED_SENSOR = "simulated_sensor"
BATTERY_MODE = "Battery_mode_now"
MODE_IDLE = "Idle/Paused"
MODE_CHARGING = "Charging"
MODE_DISCHARGING = "Discharging"
MODE_FORCE_CHARGING = "Forced charging"
MODE_FORCE_DISCHARGING = "Forced discharging"
MODE_FULL = "Full"
MODE_EMPTY = "Empty"
BATTERY_OPTIONS = {
"BYD Battery Box HVS 5.1kWh": {
CONF_BATTERY_SIZE: 5.1,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.98,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.98,
CONF_BATTERY_MAX_CHARGE_RATE: 5.7,
CONF_BATTERY_MAX_DISCHARGE_RATE: 5.7,
},
"BYD Battery Box HVS 7.7kWh": {
CONF_BATTERY_SIZE: 7.68,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.98,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.98,
CONF_BATTERY_MAX_CHARGE_RATE: 5.7,
CONF_BATTERY_MAX_DISCHARGE_RATE: 5.7,
},
"BYD Battery Box HVS 10.2kWh": {
CONF_BATTERY_SIZE: 10.2,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.98,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.98,
CONF_BATTERY_MAX_CHARGE_RATE: 5.7,
CONF_BATTERY_MAX_DISCHARGE_RATE: 5.7,
},
"BYD Battery Box HVS 12.8kWh": {
CONF_BATTERY_SIZE: 12.8,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.98,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.98,
CONF_BATTERY_MAX_CHARGE_RATE: 5.7,
CONF_BATTERY_MAX_DISCHARGE_RATE: 5.7,
},
"BYD Battery Box HVM 8.3kWh": {
CONF_BATTERY_SIZE: 8.28,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.98,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.98,
CONF_BATTERY_MAX_CHARGE_RATE: 7,
CONF_BATTERY_MAX_DISCHARGE_RATE: 7,
},
"BYD Battery Box HVM 11.0kWh": {
CONF_BATTERY_SIZE: 11.04,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.98,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.98,
CONF_BATTERY_MAX_CHARGE_RATE: 10.2,
CONF_BATTERY_MAX_DISCHARGE_RATE: 10.2,
},
"BYD Battery Box HVM 13.8kWh": {
CONF_BATTERY_SIZE: 13.8,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.98,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.98,
CONF_BATTERY_MAX_CHARGE_RATE: 11.5,
CONF_BATTERY_MAX_DISCHARGE_RATE: 11.5,
},
"BYD Battery Box HVM 16.6kWh": {
CONF_BATTERY_SIZE: 16.56,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.98,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.98,
CONF_BATTERY_MAX_CHARGE_RATE: 11.5,
CONF_BATTERY_MAX_DISCHARGE_RATE: 11.5,
},
"BYD Battery Box HVM 19.3kWh": {
CONF_BATTERY_SIZE: 19.32,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.98,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.98,
CONF_BATTERY_MAX_CHARGE_RATE: 11.5,
CONF_BATTERY_MAX_DISCHARGE_RATE: 11.5,
},
"BYD Battery Box HVM 22.1kWh": {
CONF_BATTERY_SIZE: 22.08,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.98,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.98,
CONF_BATTERY_MAX_CHARGE_RATE: 11.5,
CONF_BATTERY_MAX_DISCHARGE_RATE: 11.5,
},
"Enphase 3T (2nd Gen)": {
CONF_BATTERY_SIZE: 3.36,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.94,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.94,
CONF_BATTERY_MAX_DISCHARGE_RATE: 1.92,
CONF_BATTERY_MAX_CHARGE_RATE: 1.28,
},
"Enphase 10T (2nd Gen)": {
CONF_BATTERY_SIZE: 10.08,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.94,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.94,
CONF_BATTERY_MAX_DISCHARGE_RATE: 5.0,
CONF_BATTERY_MAX_CHARGE_RATE: 3.84,
},
"Enphase 5P (3rd Gen)": {
CONF_BATTERY_SIZE: 5.0,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.94,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.94,
CONF_BATTERY_MAX_DISCHARGE_RATE: 5.7,
CONF_BATTERY_MAX_CHARGE_RATE: 3.84,
},
"Fronius Reserva 6.3": {
CONF_BATTERY_SIZE: 6.31,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.975,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.975,
CONF_BATTERY_MAX_CHARGE_RATE: 5.94,
CONF_BATTERY_MAX_DISCHARGE_RATE: 5.94,
},
"Fronius Reserva 9.5": {
CONF_BATTERY_SIZE: 9.47,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.975,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.975,
CONF_BATTERY_MAX_CHARGE_RATE: 8.91,
CONF_BATTERY_MAX_DISCHARGE_RATE: 8.91,
},
"Fronius Reserva 12.6": {
CONF_BATTERY_SIZE: 12.63,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.975,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.975,
CONF_BATTERY_MAX_CHARGE_RATE: 11.88,
CONF_BATTERY_MAX_DISCHARGE_RATE: 11.88,
},
"Fronius Reserva 15.8": {
CONF_BATTERY_SIZE: 15.79,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.975,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.975,
CONF_BATTERY_MAX_CHARGE_RATE: 14.85,
CONF_BATTERY_MAX_DISCHARGE_RATE: 14.85,
},
"HomeWizard Energy Plug-in Battery 2.7kWh": {
CONF_BATTERY_SIZE: 2.473,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.865,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.865,
CONF_BATTERY_MAX_CHARGE_RATE: 0.8,
CONF_BATTERY_MAX_DISCHARGE_RATE: 0.8,
},
"Huawei Luna2000 5kW": {
CONF_BATTERY_SIZE: 5.0,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.95,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.95,
CONF_BATTERY_MAX_CHARGE_RATE: 2.5,
CONF_BATTERY_MAX_DISCHARGE_RATE: 2.5,
},
"Huawei Luna2000 10kW": {
CONF_BATTERY_SIZE: 10.0,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.95,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.95,
CONF_BATTERY_MAX_CHARGE_RATE: 5,
CONF_BATTERY_MAX_DISCHARGE_RATE: 5,
},
"Huawei Luna2000 15kW": {
CONF_BATTERY_SIZE: 15.0,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.95,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.95,
CONF_BATTERY_MAX_CHARGE_RATE: 5,
CONF_BATTERY_MAX_DISCHARGE_RATE: 5,
},
"LG Chem": {
CONF_BATTERY_SIZE: 9.3,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.975,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.975,
CONF_BATTERY_MAX_DISCHARGE_RATE: 5.0,
CONF_BATTERY_MAX_CHARGE_RATE: 3.3,
},
"Marstek Venus E 5.12kWh (2nd Gen)": {
CONF_BATTERY_SIZE: 5.12,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.90,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.90,
CONF_BATTERY_MAX_CHARGE_RATE: 2.5,
CONF_BATTERY_MAX_DISCHARGE_RATE: 0.8,
},
"Marstek Venus E 5.12kWh (3nd Gen)": {
CONF_BATTERY_SIZE: 5.12,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.91,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.91,
CONF_BATTERY_MAX_CHARGE_RATE: 2.5,
CONF_BATTERY_MAX_DISCHARGE_RATE: 0.8,
},
"Pika Harbour": {
CONF_BATTERY_SIZE: 8.6,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.975,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.975,
CONF_BATTERY_MAX_DISCHARGE_RATE: 4.2,
CONF_BATTERY_MAX_CHARGE_RATE: 4.2,
},
"Sonnen Eco": {
CONF_BATTERY_SIZE: 5.0,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.92,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.92,
CONF_BATTERY_MAX_DISCHARGE_RATE: 2.5,
CONF_BATTERY_MAX_CHARGE_RATE: 2.5,
},
"Sessy": {
CONF_BATTERY_SIZE: 5.0,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.92,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.92,
CONF_BATTERY_MAX_CHARGE_RATE: 2.2,
CONF_BATTERY_MAX_DISCHARGE_RATE: 1.7,
},
"Solax 5.8kWh Master": {
CONF_BATTERY_SIZE: 5.1,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.975,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.975,
CONF_BATTERY_MAX_CHARGE_RATE: 4,
CONF_BATTERY_MAX_DISCHARGE_RATE: 4,
},
"SolaX X3-IES-P 5kW": {
CONF_BATTERY_SIZE: 5.1,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.975,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.975,
CONF_BATTERY_MAX_CHARGE_RATE: 5.1,
CONF_BATTERY_MAX_DISCHARGE_RATE: 5.1,
},
"SolaX X3-IES-P 10kW": {
CONF_BATTERY_SIZE: 10.2,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.975,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.975,
CONF_BATTERY_MAX_CHARGE_RATE: 10.2,
CONF_BATTERY_MAX_DISCHARGE_RATE: 10.2,
},
"SolaX X3-IES-P 15kW": {
CONF_BATTERY_SIZE: 15.3,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.975,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.975,
CONF_BATTERY_MAX_CHARGE_RATE: 15.0,
CONF_BATTERY_MAX_DISCHARGE_RATE: 15.0,
},
"SolaX X3-IES-P 20kW": {
CONF_BATTERY_SIZE: 20.4,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.975,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.975,
CONF_BATTERY_MAX_CHARGE_RATE: 15.0,
CONF_BATTERY_MAX_DISCHARGE_RATE: 15.0,
},
"SolaX X3-IES-P 25kW": {
CONF_BATTERY_SIZE: 25.6,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.975,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.975,
CONF_BATTERY_MAX_CHARGE_RATE: 15.0,
CONF_BATTERY_MAX_DISCHARGE_RATE: 15.0,
},
"SolaX X3-IES-P 30kW": {
CONF_BATTERY_SIZE: 30.7,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.975,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.975,
CONF_BATTERY_MAX_CHARGE_RATE: 15.0,
CONF_BATTERY_MAX_DISCHARGE_RATE: 15.0,
},
"Tesla Powerwall": {
CONF_BATTERY_SIZE: 13.5,
CONF_BATTERY_CHARGE_EFFICIENCY: 0.95,
CONF_BATTERY_DISCHARGE_EFFICIENCY: 0.95,
CONF_BATTERY_MAX_DISCHARGE_RATE: 5.0,
CONF_BATTERY_MAX_CHARGE_RATE: 3.68,
},
"Custom": {},
}
================================================
FILE: custom_components/battery_sim/helpers.py
================================================
import re
from .const import (
CONF_ENERGY_EXPORT_TARIFF,
CONF_ENERGY_IMPORT_TARIFF,
CONF_ENERGY_TARIFF,
CONF_EXPORT_SENSOR,
CONF_IMPORT_SENSOR,
CONF_SECOND_EXPORT_SENSOR,
CONF_SECOND_IMPORT_SENSOR,
FIXED_NUMERICAL_TARIFFS,
GRID_EXPORT_SIM,
GRID_IMPORT_SIM,
GRID_SECOND_EXPORT_SIM,
GRID_SECOND_IMPORT_SIM,
NO_TARIFF_INFO,
FIXED_TARIFF,
TARIFF_TYPE,
SENSOR_ID,
SENSOR_TYPE,
TARIFF_SENSOR,
CONF_SECOND_ENERGY_IMPORT_TARIFF,
CONF_SECOND_ENERGY_EXPORT_TARIFF,
IMPORT,
EXPORT,
SIMULATED_SENSOR,
)
"""For backwards compatability with old configs"""
def generate_input_list(config):
tariff_type: str = TARIFF_SENSOR
if TARIFF_TYPE in config:
if config[TARIFF_TYPE] == NO_TARIFF_INFO:
tariff_type = NO_TARIFF_INFO
elif config[TARIFF_TYPE] == FIXED_NUMERICAL_TARIFFS:
tariff_type = FIXED_TARIFF
inputs = [
{
SENSOR_ID: config[CONF_IMPORT_SENSOR],
SENSOR_TYPE: IMPORT,
SIMULATED_SENSOR: GRID_IMPORT_SIM,
TARIFF_TYPE: tariff_type,
},
{
SENSOR_ID: config[CONF_EXPORT_SENSOR],
SENSOR_TYPE: EXPORT,
SIMULATED_SENSOR: GRID_EXPORT_SIM,
TARIFF_TYPE: tariff_type,
},
]
if len(config.get(CONF_SECOND_IMPORT_SENSOR, "")) > 6:
inputs.append(
{
SENSOR_ID: config[CONF_SECOND_IMPORT_SENSOR],
SENSOR_TYPE: IMPORT,
SIMULATED_SENSOR: GRID_SECOND_IMPORT_SIM,
TARIFF_TYPE: tariff_type,
}
)
if len(config.get(CONF_SECOND_EXPORT_SENSOR, "")) > 6:
inputs.append(
{
SENSOR_ID: config[CONF_SECOND_EXPORT_SENSOR],
SENSOR_TYPE: EXPORT,
SIMULATED_SENSOR: GRID_SECOND_EXPORT_SIM,
TARIFF_TYPE: tariff_type,
}
)
"""Default sensor entities for backwards compatibility"""
if CONF_ENERGY_IMPORT_TARIFF in config:
inputs[0][tariff_type] = config[CONF_ENERGY_IMPORT_TARIFF]
elif CONF_ENERGY_TARIFF in config:
"""For backwards compatibility"""
inputs[0][tariff_type] = config[CONF_ENERGY_TARIFF]
if CONF_ENERGY_EXPORT_TARIFF in config:
inputs[1][tariff_type] = config[CONF_ENERGY_EXPORT_TARIFF]
def _set_tariff_for_sensor(simulated_sensor, tariff_config_key):
if tariff_config_key not in config:
return
matching_input = next(
(
input_entry
for input_entry in inputs
if input_entry[SIMULATED_SENSOR] == simulated_sensor
),
None,
)
if matching_input is not None:
matching_input[tariff_type] = config[tariff_config_key]
_set_tariff_for_sensor(
GRID_SECOND_IMPORT_SIM,
CONF_SECOND_ENERGY_IMPORT_TARIFF,
)
_set_tariff_for_sensor(
GRID_SECOND_EXPORT_SIM,
CONF_SECOND_ENERGY_EXPORT_TARIFF,
)
return inputs
def parse_efficiency_curve(raw_value):
"""Parse an efficiency config value into sorted (power_kw, efficiency) points."""
if isinstance(raw_value, (int, float)):
value = float(raw_value)
_validate_efficiency(value)
return [(0.0, value)]
if raw_value is None:
raise ValueError("Efficiency value is required")
text = str(raw_value).strip()
if not text:
raise ValueError("Efficiency value is required")
try:
value = float(text)
except ValueError:
value = None
if value is not None:
_validate_efficiency(value)
return [(0.0, value)]
normalized = text.replace(";", ",")
pair_matches = re.findall(
r"\(?\s*(-?\d+(?:\.\d+)?)\s*[,:\s]\s*(-?\d+(?:\.\d+)?)\s*\)?",
normalized,
)
if not pair_matches:
raise ValueError(
"Use a number like 0.95 or power/efficiency pairs like 0:0.9, 5:0.95"
)
points = []
for power_text, efficiency_text in pair_matches:
power = float(power_text)
efficiency = float(efficiency_text)
if power < 0:
raise ValueError("Efficiency curve power values must be >= 0")
_validate_efficiency(efficiency)
points.append((power, efficiency))
points.sort(key=lambda item: item[0])
deduplicated_points = []
for power, efficiency in points:
if deduplicated_points and power == deduplicated_points[-1][0]:
deduplicated_points[-1] = (power, efficiency)
else:
deduplicated_points.append((power, efficiency))
return deduplicated_points
def validate_efficiency_config(raw_value):
"""Validate the configured efficiency value or curve and return the raw value."""
parse_efficiency_curve(raw_value)
return raw_value
def interpolate_efficiency(curve_points, power_kw):
"""Return the efficiency for the requested power using linear interpolation."""
if not curve_points:
raise ValueError("Efficiency curve must contain at least one point")
if len(curve_points) == 1 or power_kw <= curve_points[0][0]:
return curve_points[0][1]
for (start_power, start_efficiency), (end_power, end_efficiency) in zip(
curve_points, curve_points[1:]
):
if power_kw <= end_power:
if end_power == start_power:
return end_efficiency
ratio = (power_kw - start_power) / (end_power - start_power)
return start_efficiency + ratio * (end_efficiency - start_efficiency)
return curve_points[-1][1]
def _validate_efficiency(value):
if not 0 <= value <= 1:
raise ValueError("Efficiency values must be between 0 and 1")
================================================
FILE: custom_components/battery_sim/manifest.json
================================================
{
"domain": "battery_sim",
"name": "Home Battery Simulation",
"codeowners": ["@hif2k1", "@dewi-ny-je"],
"config_flow": true,
"documentation": "https://github.com/hif2k1/battery_sim/",
"iot_class": "local_push",
"issue_tracker": "https://github.com/hif2k1/battery_sim/issues",
"quality_scale": "internal",
"requirements": [],
"version": "2.3.0"
}
================================================
FILE: custom_components/battery_sim/number.py
================================================
#from homeassistant.components.number import NumberEntity
from homeassistant.components.number import RestoreNumber
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.typing import DiscoveryInfoType
from homeassistant.components.number import NumberEntity
from .const import (
DOMAIN,
CHARGE_LIMIT,
DISCHARGE_LIMIT,
MINIMUM_SOC,
MAXIMUM_SOC,
)
import logging
_LOGGER = logging.getLogger(__name__)
BATTERY_SLIDERS = [
{
"name": CHARGE_LIMIT,
"key": "charge_limit",
"icon": "mdi:car-speed-limiter",
"unit": "kW",
"precision": 0.01,
},
{
"name": DISCHARGE_LIMIT,
"key": "discharge_limit",
"icon": "mdi:car-speed-limiter",
"unit": "kW",
"precision": 0.01,
},
{
"name": MINIMUM_SOC,
"key": "minimum_soc",
"icon": "mdi:battery-10",
"unit": "%",
"precision": 1,
},
{
"name": MAXIMUM_SOC,
"key": "maximum_soc",
"icon": "mdi:battery-90",
"unit": "%",
"precision": 1,
},
]
async def async_setup_entry(hass, config_entry, async_add_entities):
handle = hass.data[DOMAIN][config_entry.entry_id]
sliders = [
BatterySlider(handle, slider["name"], slider["key"], slider["icon"], slider["unit"], slider["precision"])
for slider in BATTERY_SLIDERS
]
async_add_entities(sliders)
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]
sliders = [
BatterySlider(handle, slider["name"], slider["key"], slider["icon"], slider["unit"], slider["precision"])
for slider in BATTERY_SLIDERS
]
async_add_entities(sliders)
return True
class BatterySlider(RestoreNumber):
"""Slider to set a numeric parameter for the simulated battery."""
def __init__(self, handle, slider_type, key, icon, unit, precision):
"""Initialize the slider."""
self.handle = handle
self._key = key
self._icon = icon
self._slider_type = slider_type
self._precision = precision
self._device_name = handle._name
self._device_identifier = handle.device_identifier
self._name = f"{handle._name} ".replace("_", " ") + f"{slider_type}".replace("_", " ").capitalize()
self._attr_unique_id = f"{handle._name} - {slider_type}"
if key == "charge_limit":
self._max_value = handle._max_charge_rate
self._value = self._max_value
elif key == "discharge_limit":
self._max_value = handle._max_discharge_rate
self._value = self._max_value
elif key == "minimum_soc":
self._max_value = 100
self._value = 0
elif key == "maximum_soc":
self._max_value = 100
self._value = self._max_value
else:
_LOGGER.debug("Reached undefined state in number.py")
self._attr_icon = icon
self._attr_unit_of_measurement = unit
self._attr_mode = "box"
@property
def unique_id(self):
"""Return uniqueid."""
return self._attr_unique_id
@property
def name(self):
return self._name
@property
def device_info(self):
return {
"name": self._device_name,
"identifiers": {self._device_identifier},
}
@property
def native_min_value(self):
return 0.00
@property
def native_max_value(self):
return self._max_value
@property
def native_step(self):
return self._precision
@property
def native_value(self):
return self._value
async def async_set_native_value(self, value: float) -> None:
self._value = value
self.handle.set_slider_limit(value, self._key)
# Recompute immediately so UI/control changes take effect right away.
self.handle.async_trigger_update()
self.async_write_ha_state()
async def async_added_to_hass(self):
"""Restore previously saved value."""
await super().async_added_to_hass()
if (last_number_data := await self.async_get_last_number_data()) is not None:
self._value = last_number_data.native_value
_LOGGER.debug("Restored %s to %.2f", self._key, self._value)
self.handle.set_slider_limit(self._value, self._key) # Restore to handle too
================================================
FILE: custom_components/battery_sim/select.py
================================================
"""Select platform for Battery Sim."""
import logging
from homeassistant.components.select import SelectEntity
from .const import (
DOMAIN,
CONF_BATTERY,
OVERRIDE_CHARGING,
PAUSE_BATTERY,
FORCE_DISCHARGE,
CHARGE_ONLY,
DISCHARGE_ONLY,
DEFAULT_MODE,
ICON_FULL,
)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass, config_entry, async_add_entities):
handle = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities([BatteryMode(handle)])
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]
async_add_entities([BatteryMode(handle)])
return True
class BatteryMode(SelectEntity):
"""Select to set the battery operating mode."""
def __init__(self, handle):
self.handle = handle
self._device_name = handle._name
self._device_identifier = handle.device_identifier
self._name = f"{handle._name} ".replace("_", " ") + "Battery Mode"
self._attr_unique_id = f"{handle._name} - Battery Mode"
self._internal_options = [
DEFAULT_MODE,
OVERRIDE_CHARGING,
PAUSE_BATTERY,
FORCE_DISCHARGE,
CHARGE_ONLY,
DISCHARGE_ONLY,
]
@property
def unique_id(self):
return self._attr_unique_id
@property
def name(self):
return self._name
@property
def device_info(self):
return {
"name": self._device_name,
"identifiers": {self._device_identifier},
}
@property
def icon(self):
return ICON_FULL
@property
def current_option(self):
return self.handle._battery_mode.replace("_", " ").capitalize()
@property
def options(self):
return [opt.replace("_", " ").capitalize() for opt in self._internal_options]
async def async_select_option(self, option: str):
internal_option = next(
(
opt
for opt in self._internal_options
if opt.replace("_", " ").capitalize() == option
),
None,
)
if internal_option is None:
_LOGGER.warning("Invalid option selected: %s", option)
return
self.handle._battery_mode = internal_option
self.handle.async_trigger_update()
self.schedule_update_ha_state(True)
================================================
FILE: custom_components/battery_sim/sensor.py
================================================
"""Simulated battery and associated sensors."""
import time
import logging
import homeassistant.util.dt as dt_util
from homeassistant.helpers.dispatcher import dispatcher_send, async_dispatcher_connect
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
ATTR_LAST_RESET,
)
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.const import (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
UnitOfPower,
UnitOfEnergy,
)
from .const import (
DOMAIN,
CONF_BATTERY,
CONF_BATTERY_CHARGE_EFFICIENCY,
CONF_BATTERY_DISCHARGE_EFFICIENCY,
CONF_BATTERY_EFFICIENCY,
CONF_BATTERY_MAX_DISCHARGE_RATE,
CONF_BATTERY_MAX_CHARGE_RATE,
CONF_BATTERY_SIZE,
ATTR_MONEY_SAVED,
ATTR_MONEY_SAVED_IMPORT,
ATTR_MONEY_SAVED_EXPORT,
ATTR_LAST_CHARGE_EFFICIENCY,
ATTR_LAST_DISCHARGE_EFFICIENCY,
ATTR_SOURCE_ID,
ATTR_STATUS,
ATTR_ENERGY_SAVED,
ATTR_DATE_RECORDING_STARTED,
BATTERY_MODE,
ATTR_CHARGE_PERCENTAGE,
ATTR_ENERGY_BATTERY_OUT,
ATTR_ENERGY_BATTERY_IN,
CHARGING_RATE,
DISCHARGING_RATE,
SOLAR_POWER_CAP,
SENSOR_TYPE,
EXPORT,
SIMULATED_SENSOR,
ICON_CHARGING,
ICON_DISCHARGING,
ICON_FULL,
ICON_EMPTY,
PERCENTAGE_ENERGY_IMPORT_SAVED,
MODE_CHARGING,
MODE_FORCE_CHARGING,
MODE_FULL,
MODE_EMPTY,
BATTERY_CYCLES,
BATTERY_DEGRADATION,
CONF_END_OF_LIFE_DEGRADATION,
CONF_RATED_BATTERY_CYCLES,
MESSAGE_TYPE_BATTERY_UPDATE,
SENSOR_ID,
)
_LOGGER = logging.getLogger(__name__)
_INVALID_RESTORED_STATES = {None, "", STATE_UNKNOWN, STATE_UNAVAILABLE}
DEVICE_CLASS_MAP = {
UnitOfEnergy.WATT_HOUR: SensorDeviceClass.ENERGY,
UnitOfEnergy.KILO_WATT_HOUR: SensorDeviceClass.ENERGY,
}
async def async_setup_entry(hass, config_entry, async_add_entities):
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:
return
for conf in discovery_info:
battery = conf[CONF_BATTERY]
handle = hass.data[DOMAIN][battery]
sensors = await define_sensors(hass, handle)
async_add_entities(sensors)
async def define_sensors(hass, handle):
sensors = []
sensors.append(
DisplayOnlySensor(
handle,
ATTR_ENERGY_SAVED,
SensorDeviceClass.ENERGY,
UnitOfEnergy.KILO_WATT_HOUR,
)
)
sensors.append(
DisplayOnlySensor(
handle,
ATTR_ENERGY_BATTERY_OUT,
SensorDeviceClass.ENERGY,
UnitOfEnergy.KILO_WATT_HOUR,
)
)
sensors.append(
DisplayOnlySensor(
handle,
ATTR_ENERGY_BATTERY_IN,
SensorDeviceClass.ENERGY,
UnitOfEnergy.KILO_WATT_HOUR,
)
)
sensors.append(
DisplayOnlySensor(
handle, CHARGING_RATE, SensorDeviceClass.POWER, UnitOfPower.KILO_WATT
)
)
sensors.append(
DisplayOnlySensor(
handle, DISCHARGING_RATE, SensorDeviceClass.POWER, UnitOfPower.KILO_WATT
)
)
if handle._solar_entity_id is not None:
sensors.append(
DisplayOnlySensor(
handle, SOLAR_POWER_CAP, SensorDeviceClass.POWER, UnitOfPower.KILO_WATT
)
)
sensors.append(DisplayOnlySensor(handle, ATTR_LAST_CHARGE_EFFICIENCY, None, None))
sensors.append(
DisplayOnlySensor(handle, ATTR_LAST_DISCHARGE_EFFICIENCY, None, None)
)
for input in handle._inputs:
sensors.append(
DisplayOnlySensor(
handle,
input[SIMULATED_SENSOR],
SensorDeviceClass.ENERGY,
UnitOfEnergy.KILO_WATT_HOUR,
)
)
sensors.append(DisplayOnlySensor(handle, BATTERY_CYCLES, None, None))
sensors.append(DisplayOnlySensor(handle, BATTERY_DEGRADATION, None, None))
sensors.append(
DisplayOnlySensor(
handle,
ATTR_MONEY_SAVED_IMPORT,
SensorDeviceClass.MONETARY,
hass.config.currency,
)
)
sensors.append(
DisplayOnlySensor(
handle,
ATTR_MONEY_SAVED,
SensorDeviceClass.MONETARY,
hass.config.currency,
)
)
sensors.append(
DisplayOnlySensor(
handle,
ATTR_MONEY_SAVED_EXPORT,
SensorDeviceClass.MONETARY,
hass.config.currency,
)
)
sensors.append(SimulatedBattery(handle))
sensors.append(BatteryStatus(handle, BATTERY_MODE))
return sensors
class DisplayOnlySensor(RestoreEntity, SensorEntity):
"""
Representation of a sensor.
This reprisentation simply displays a value calculated
in the __init__ file.
"""
_attr_should_poll = False
def __init__(self, handle, sensor_name, type_of_sensor, units):
"""Initialize the display only sensors for the battery."""
self._handle = handle
self._units = units
self._name = f"{handle._name} ".replace("_", " ") + f"{sensor_name}".replace("_", " ").capitalize()
self._attr_unique_id = f"{handle._name} - {sensor_name}"
self._device_name = handle._name
self._device_identifier = handle.device_identifier
self._sensor_type = sensor_name
self._type_of_sensor = type_of_sensor
self._last_reset = dt_util.utcnow()
self._available = False
@property
def _supports_last_reset(self):
"""Return True when Home Assistant allows last_reset for this sensor."""
return self.state_class == SensorStateClass.TOTAL
async def async_added_to_hass(self):
"""Subscribe for update from the battery."""
await super().async_added_to_hass()
state = await self.async_get_last_state()
if state:
if state.state in _INVALID_RESTORED_STATES:
_LOGGER.debug(
"Ignoring invalid restored state '%s' for sensor '%s'.",
state.state,
self._sensor_type,
)
else:
try:
self._handle._sensors[self._sensor_type] = float(state.state)
last_reset = state.attributes.get(ATTR_LAST_RESET)
if self._supports_last_reset and last_reset is not None:
parsed_last_reset = dt_util.parse_datetime(last_reset)
if parsed_last_reset is not None:
self._last_reset = dt_util.as_utc(parsed_last_reset)
self._available = True
await self.async_update_ha_state(True)
except (TypeError, ValueError):
_LOGGER.debug(
"Sensor state '%s' not restored properly for '%s'.",
state.state,
self._sensor_type,
)
self._available = False
else:
_LOGGER.debug("No sensor state - presume new battery.")
self._available = False
async def async_update_state():
"""Update sensor state."""
if self._handle._sensors[self._sensor_type] is not None:
self._available = True
await self.async_update_ha_state(True)
async_dispatcher_connect(
self.hass,
f"{self._device_name}-{MESSAGE_TYPE_BATTERY_UPDATE}",
async_update_state,
)
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def unique_id(self):
"""Return uniqueid."""
return self._attr_unique_id
@property
def device_info(self):
return {"name": self._device_name, "identifiers": {self._device_identifier}}
@property
def native_value(self):
"""Return the state of the sensor."""
sensor_value = self._handle._sensors.get(self._sensor_type)
if sensor_value is None:
return None
if self._sensor_type == ATTR_MONEY_SAVED:
return round(sensor_value, 2)
else:
return round(sensor_value, 3)
@property
def device_class(self):
"""Return the device class of the sensor."""
return self._type_of_sensor
@property
def state_class(self):
"""Return the device class of the sensor."""
if self._sensor_type in [
CHARGING_RATE,
DISCHARGING_RATE,
SOLAR_POWER_CAP,
ATTR_LAST_CHARGE_EFFICIENCY,
ATTR_LAST_DISCHARGE_EFFICIENCY,
]:
return SensorStateClass.MEASUREMENT
return SensorStateClass.TOTAL
@property
def unit_of_measurement(self):
"""Return the unit the value is expressed in."""
return self._units
@property
def extra_state_attributes(self):
"""Return the state attributes of the sensor."""
state_attr = {}
for input in self._handle._inputs:
if self._sensor_type != input[SIMULATED_SENSOR]:
continue
if input[SENSOR_TYPE] == EXPORT:
continue
parent_sensor = input[SENSOR_ID]
if self.hass.states.get(parent_sensor) is None or self.hass.states.get(
parent_sensor
).state in [STATE_UNAVAILABLE, STATE_UNKNOWN]:
continue
real_world_value = float(self.hass.states.get(parent_sensor).state)
simulated_value = self._handle._sensors[self._sensor_type]
if real_world_value == 0:
_LOGGER.warning(
"Division by zero, real world: %s, simulated: %s, battery: %s",
real_world_value,
simulated_value,
self._name,
)
state_attr = {PERCENTAGE_ENERGY_IMPORT_SAVED: 0}
else:
percentage_value_saved = (
100 * (real_world_value - simulated_value) / real_world_value
)
state_attr = {
PERCENTAGE_ENERGY_IMPORT_SAVED: round(
float(percentage_value_saved), 0
)
}
break
return state_attr
@property
def icon(self):
"""Return the icon to use in the frontend, if any."""
@property
def state(self):
"""Return the state of the sensor."""
sensor_value = self._handle._sensors.get(self._sensor_type)
if sensor_value is None:
return None
if self._sensor_type in [
ATTR_MONEY_SAVED,
ATTR_MONEY_SAVED_EXPORT,
ATTR_MONEY_SAVED_IMPORT,
]:
return round(sensor_value, 2)
else:
return round(sensor_value, 3)
def update(self):
"""Not used."""
return
@property
def last_reset(self):
"""Return the time when the sensor was last reset."""
if not self._supports_last_reset:
return None
return self._last_reset
@property
def available(self) -> bool:
"""Needed to avoid spikes in energy dashboard on startup.
Return True if entity is available.
"""
return self._available
class SimulatedBattery(RestoreEntity, SensorEntity):
"""Representation of the battery itself."""
_attr_should_poll = False
def __init__(self, handle):
self.handle = handle
self._date_recording_started = time.asctime()
self._name = f"{handle._name}"
self._attr_unique_id = f"{handle._name}"
async def async_added_to_hass(self):
"""Handle entity which will be added."""
await super().async_added_to_hass()
state = await self.async_get_last_state()
if state:
if state.state in _INVALID_RESTORED_STATES:
_LOGGER.debug(
"Ignoring invalid restored battery state '%s' for '%s'.",
state.state,
self._name,
)
else:
try:
self.handle._charge_state = min(
float(state.state), self.handle.current_max_capacity
)
except (TypeError, ValueError):
_LOGGER.debug(
"Battery state '%s' not restored properly for '%s'.",
state.state,
self._name,
)
if ATTR_DATE_RECORDING_STARTED in state.attributes:
self.handle._date_recording_started = state.attributes[
ATTR_DATE_RECORDING_STARTED
]
async def async_update_state():
"""Update sensor state."""
await self.async_update_ha_state(True)
async_dispatcher_connect(
self.hass, f"{self._name}-{MESSAGE_TYPE_BATTERY_UPDATE}", async_update_state
)
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def unique_id(self):
"""Return uniqueid."""
return self._attr_unique_id
@property
def device_info(self):
return {
"name": self._name,
"identifiers": {self.handle.device_identifier},
}
@property
def native_value(self):
"""Return the state of the sensor."""
return round(float(self.handle._charge_state), 3)
@property
def device_class(self):
"""Return the device class of the sensor."""
return SensorDeviceClass.ENERGY
@property
def state_class(self):
"""Return the device class of the sensor."""
return SensorStateClass.MEASUREMENT
@property
def native_unit_of_measurement(self):
"""Return the unit the value is expressed in."""
return UnitOfEnergy.KILO_WATT_HOUR
@property
def unit_of_measurement(self):
"""Return the unit the value is expressed in."""
return UnitOfEnergy.KILO_WATT_HOUR
@property
def extra_state_attributes(self):
"""Return the state attributes of the sensor."""
sensor_list = ""
for input in self.handle._inputs:
sensor_list = f"{sensor_list}, {input[SENSOR_ID]}"
return {
ATTR_STATUS: self.handle._sensors[BATTERY_MODE],
ATTR_CHARGE_PERCENTAGE: int(self.handle._charge_percentage),
ATTR_DATE_RECORDING_STARTED: self.handle._date_recording_started,
CONF_BATTERY_SIZE: self.handle._battery_size,
CONF_BATTERY_DISCHARGE_EFFICIENCY: self.handle._battery_discharge_efficiency,
CONF_BATTERY_CHARGE_EFFICIENCY: self.handle._battery_charge_efficiency,
CONF_BATTERY_EFFICIENCY: self.handle._battery_discharge_efficiency,
CONF_BATTERY_MAX_DISCHARGE_RATE: float(self.handle._max_discharge_rate),
CONF_BATTERY_MAX_CHARGE_RATE: float(self.handle._max_charge_rate),
CONF_RATED_BATTERY_CYCLES: float(self.handle._rated_battery_cycles),
CONF_END_OF_LIFE_DEGRADATION: float(self.handle._end_of_life_degradation),
ATTR_SOURCE_ID: sensor_list,
}
@property
def icon(self):
"""Return the icon to use in the frontend."""
if self.handle._sensors[BATTERY_MODE] in [MODE_CHARGING, MODE_FORCE_CHARGING]:
return ICON_CHARGING
if self.handle._sensors[BATTERY_MODE] == MODE_FULL:
return ICON_FULL
if self.handle._sensors[BATTERY_MODE] == MODE_EMPTY:
return ICON_EMPTY
return ICON_DISCHARGING
@property
def state(self):
"""Return the state of the sensor."""
return round(float(self.handle._charge_state), 3)
class BatteryStatus(SensorEntity):
"""Representation of the battery itself."""
_attr_should_poll = False
def __init__(self, handle, sensor_name):
self.handle = handle
self._date_recording_started = time.asctime()
self._name = f"{handle._name} ".replace("_", " ") + f"{sensor_name}".replace("_", " ").capitalize()
self._attr_unique_id = f"{handle._name} - {sensor_name}"
self._device_name = handle._name
self._device_identifier = handle.device_identifier
self._sensor_type = sensor_name
async def async_added_to_hass(self):
"""Handle entity which will be added."""
await super().async_added_to_hass()
async def async_update_state():
"""Update sensor state."""
await self.async_update_ha_state(True)
async_dispatcher_connect(
self.hass,
f"{self._device_name}-{MESSAGE_TYPE_BATTERY_UPDATE}",
async_update_state,
)
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def unique_id(self):
"""Return uniqueid."""
return self._attr_unique_id
@property
def device_info(self):
return {"name": self._device_name, "identifiers": {self._device_identifier}}
@property
def native_value(self):
"""Return the state of the sensor."""
return self.handle._sensors[BATTERY_MODE]
@property
def device_class(self):
"""Return the device class of the sensor."""
return SensorDeviceClass.ENUM
@property
def extra_state_attributes(self):
"""Return the state attributes of the sensor."""
return {
ATTR_STATUS: self.handle._sensors.get(ATTR_STATUS),
ATTR_CHARGE_PERCENTAGE: getattr(self.handle, "_charge_percentage", None),
}
@property
def icon(self):
"""Return the icon to use in the frontend."""
status = self.handle._sensors.get(ATTR_STATUS)
if status == MODE_FULL:
return ICON_FULL
if status == MODE_EMPTY:
return ICON_EMPTY
if self.handle._sensors[BATTERY_MODE] in [MODE_CHARGING, MODE_FORCE_CHARGING]:
return ICON_CHARGING
return ICON_DISCHARGING
@property
def state(self):
"""Return the state of the sensor."""
return self.handle._sensors[BATTERY_MODE]
================================================
FILE: custom_components/battery_sim/services.yaml
================================================
set_battery_charge_state:
name: battery_sim.set_battery_charge_state.name
description: battery_sim.set_battery_charge_state.description
fields:
device_id:
name: battery_sim.set_battery_charge_state.fields.device_id.name
description: battery_sim.set_battery_charge_state.fields.device_id.description
required: true
selector:
device:
integration: battery_sim
charge_state:
name: battery_sim.set_battery_charge_state.fields.charge_state.name
description: battery_sim.set_battery_charge_state.fields.charge_state.description
required: true
selector:
number:
mode: box
min: 0
max: 10000
step: 0.1
set_battery_cycles:
name: battery_sim.set_battery_cycles.name
description: battery_sim.set_battery_cycles.description
fields:
device_id:
name: battery_sim.set_battery_cycles.fields.device_id.name
description: battery_sim.set_battery_cycles.fields.device_id.description
required: true
selector:
device:
integration: battery_sim
battery_cycles:
name: battery_sim.set_battery_cycles.fields.battery_cycles.name
description: battery_sim.set_battery_cycles.fields.battery_cycles.description
required: true
selector:
number:
mode: box
min: 0
max: 50000
step: 1
================================================
FILE: custom_components/battery_sim/switch.py
================================================
"""Switch platform for Battery Sim."""
import logging
from homeassistant.components.switch import SwitchEntity
from .const import DOMAIN, CONF_BATTERY, PAUSE_BATTERY
_LOGGER = logging.getLogger(__name__)
BATTERY_SWITCHES = [
{
"name": PAUSE_BATTERY,
"key": "pause_battery_enabled",
"icon": "mdi:pause",
}
]
async def async_setup_entry(hass, config_entry, async_add_entities):
handle = hass.data[DOMAIN][config_entry.entry_id]
battery_switches = [
BatterySwitch(handle, switch["name"], switch["key"], switch["icon"])
for switch in BATTERY_SWITCHES
]
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 = [
BatterySwitch(handle, switch["name"], switch["key"], switch["icon"])
for switch in BATTERY_SWITCHES
]
async_add_entities(battery_switches)
return True
class BatterySwitch(SwitchEntity):
"""Switch to pause or resume the simulated battery."""
def __init__(self, handle, switch_type, key, icon):
self.handle = handle
self._key = key
self._icon = icon
self._switch_type = switch_type
self._device_name = handle._name
self._device_identifier = handle.device_identifier
self._name = (
f"{handle._name} ".replace("_", " ")
+ f"{switch_type}".replace("_", " ").capitalize()
)
self._attr_unique_id = f"{handle._name} - {switch_type}"
self._type = type
@property
def unique_id(self):
return self._attr_unique_id
@property
def name(self):
return self._name
@property
def device_info(self):
return {
"name": self._device_name,
"identifiers": {self._device_identifier},
}
@property
def icon(self):
return self._icon
@property
def is_on(self):
return self.handle._switches[self._switch_type]
async def async_turn_on(self, **kwargs):
self.handle._switches[self._switch_type] = True
self.handle.async_trigger_update()
self.schedule_update_ha_state(True)
return True
async def async_turn_off(self, **kwargs):
self.handle._switches[self._switch_type] = False
self.handle.async_trigger_update()
self.schedule_update_ha_state(True)
return True
================================================
FILE: custom_components/battery_sim/translations/de.json
================================================
{
"config": {
"abort": {
"already_configured": "Dieses Gerät ist bereits konfiguriert."
},
"error": {
"invalid_input": "Ungültige Eingabe"
},
"flow_title": "Neue simulierte Batterie einrichten",
"step": {
"user": {
"title": "Batterie auswählen",
"data": {
"battery": "Batteriemodell"
},
"description": "Wähle ein zu simulierendes Batteriemodell aus der Liste oder wähle 'Benutzerdefiniert', um eines zu erstellen."
},
"custom": {
"title": "Benutzerdefinierte Batterie",
"data": {
"unique_name": "Eindeutiger Name",
"size_kwh": "Batteriegröße in kWh",
"max_discharge_rate_kw": "Maximale Entladerate in kW",
"max_charge_rate_kw": "Maximale Laderate in kW",
"discharge_efficiency": "Entladewirkungsgrad oder Kurve (z. B. 0.95 oder 0:0.9, 5:0.95)",
"charge_efficiency": "Ladewirkungsgrad oder Kurve (z. B. 0.95 oder 0:0.9, 5:0.95)",
"rated_battery_cycles": "Nennzyklen der Batterie",
"end_of_life_degradation": "Kapazität am Ende der Lebensdauer (0 bis 1)",
"update_frequency": "Aktualisierungsintervall in Sekunden",
"solar_energy_sensor": "Solarenergiezählersensor",
"nominal_inverter_power_kw": "Nennleistung des Wechselrichters in kW"
},
"description": "Lege die Spezifikationen der Batterie fest. Bitte erwäge, Details der von dir simulierten Batterie auf unserem GitHub zu veröffentlichen, damit wir sie zur Vorlagenliste hinzufügen können."
},
"meter_menu": {
"title": "Zähler hinzufügen",
"menu_options": {
"add_import_meter": "Bezugszähler hinzufügen (misst Energie, die vom Netz ins Haus kommt)",
"add_export_meter": "Einspeisezähler hinzufügen (misst Energie, die vom Haus ins Netz geht)",
"all_done": "Alles erledigt"
},
"description": "Es sind mindestens ein Bezugs- und ein Einspeisezähler erforderlich. Zähler, die die Solarerzeugung direkt überwachen, sollten nicht hinzugefügt werden."
},
"tariff_menu": {
"title": "Tarifart auswählen",
"menu_options": {
"no_tariff_info": "Kein Tarif für diesen Zähler",
"fixed_tariff": "Ein konstanter Festpreis für den Tarif",
"tariff_sensor": "Ein Sensor, der den Wert eines zeitlich variablen Tarifs enthält"
},
"description": ""
},
"add_import_meter": {
"title": "Bezugszähler zur Batterie hinzufügen",
"data": {
"sensor_id": "Energiezählersensor"
},
"description": "Zählersensor auswählen"
},
"add_export_meter": {
"title": "Einspeisezähler zur Batterie hinzufügen",
"data": {
"sensor_id": "Energiezählersensor"
},
"description": "Zählersensor auswählen"
},
"fixed_tariff": {
"title": "Details zum Festpreistarif hinzufügen",
"data": {
"fixed_tariff": "Fester Tarifwert (falls zutreffend)"
},
"description": ""
},
"tariff_sensor": {
"title": "Details zum Tarifsensor hinzufügen",
"data": {
"tariff_sensor": "Sensor, der den aktuellen Tarif anzeigt (falls zutreffend)"
},
"description": ""
}
}
},
"options": {
"step": {
"init": {
"title": "Batterie-Optionen",
"description": "Zu ändernde Parameter auswählen",
"menu_options": {
"main_params": "Hauptparameter",
"input_sensors": "Zähler/Sensoren bearbeiten",
"all_done": "Alles erledigt"
}
},
"main_params": {
"title": "Haupt-Batterieoptionen",
"description": "Hauptparameter",
"data": {
"size_kwh": "Batteriegröße in kWh",
"max_discharge_rate_kw": "Maximale Entladerate in kW",
"max_charge_rate_kw": "Maximale Laderate in kW",
"discharge_efficiency": "Entladewirkungsgrad oder Kurve (z. B. 0.95 oder 0:0.9, 5:0.95)",
"charge_efficiency": "Ladewirkungsgrad oder Kurve (z. B. 0.95 oder 0:0.9, 5:0.95)",
"rated_battery_cycles": "Nennzyklen der Batterie",
"end_of_life_degradation": "Kapazität am Ende der Lebensdauer (0 bis 1)",
"update_frequency": "Aktualisierungsintervall in Sekunden",
"solar_energy_sensor": "Solarenergiezählersensor",
"nominal_inverter_power_kw": "Nennleistung des Wechselrichters in kW"
}
},
"input_sensors": {
"title": "Zähler/Sensoren bearbeiten",
"menu_options": {
"add_import_meter": "Bezugszähler hinzufügen (misst Energie, die vom Netz ins Haus kommt)",
"add_export_meter": "Einspeisezähler hinzufügen (misst Energie, die vom Haus ins Netz geht)",
"edit_input_tariff": "Tarifdetails für einen Zähler bearbeiten",
"delete_input": "Einen Zähler löschen"
},
"description": "Es sind mindestens ein Bezugs- und ein Einspeisezähler erforderlich. Zähler, die die Solarerzeugung direkt überwachen, sollten nicht verwendet werden."
},
"tariff_menu": {
"title": "Tarifart auswählen",
"menu_options": {
"no_tariff_info": "Kein Tarif für diesen Zähler",
"fixed_tariff": "Ein konstanter Festpreis für den Tarif",
"tariff_sensor": "Ein Sensor, der den Wert eines zeitlich variablen Tarifs darstellt"
},
"description": ""
},
"add_import_meter": {
"title": "Bezugszähler zur Batterie hinzufügen",
"data": {
"sensor_id": "Energiezählersensor"
},
"description": "Zählersensor auswählen"
},
"add_export_meter": {
"title": "Einspeisezähler zur Batterie hinzufügen",
"data": {
"sensor_id": "Energiezählersensor"
},
"description": "Zählersensor auswählen"
},
"delete_input": {
"title": "Zu löschenden Zähler auswählen",
"data": {},
"description": ""
},
"edit_input_tariff": {
"title": "Zähler auswählen, für den der Tarif bearbeitet werden soll",
"data": {},
"description": ""
},
"fixed_tariff": {
"title": "Festpreistarif hinzufügen",
"data": {
"fixed_tariff": "Fester Tarifwert"
},
"description": ""
},
"tariff_sensor": {
"title": "Tarifsensor hinzufügen",
"data": {
"tariff_sensor": "Sensor, der den aktuellen Tarif anzeigt"
},
"description": ""
}
}
},
"services": {
"set_battery_charge_state": {
"name": "Batterieladung einstellen",
"description": "Stellt den Ladezustand für eine bestimmte simulierte Batterie ein",
"fields": {
"device_id": {
"name": "Ziel-Batteriegerät",
"description": "Gerät, für das die Batterieladung eingestellt werden soll"
},
"charge_state": {
"name": "Ladezustand",
"description": "Ladungswert in kWh"
}
}
},
"set_battery_cycles": {
"name": "Batteriezyklen einstellen",
"description": "Stellt die simulierten Batteriezyklen für ein bestimmtes Batteriegerät ein",
"fields": {
"device_id": {
"name": "Ziel-Batteriegerät",
"description": "Gerät, für das die Batteriezyklen eingestellt werden sollen"
},
"battery_cycles": {
"name": "Batteriezyklen",
"description": "Anzuwendende Anzahl Zyklen"
}
}
}
}
}
================================================
FILE: custom_components/battery_sim/translations/en.json
================================================
{
"config": {
"abort": {
"already_configured": "This device is already configured."
},
"error": {
"invalid_input": "Invalid input"
},
"flow_title": "Setup new simulated battery",
"step": {
"user": {
"title": "Select battery",
"data": {
"battery": "Battery model"
},
"description": "Select a battery model to simulate from the list or select Custom to create one."
},
"custom": {
"title": "Custom Battery",
"data": {
"unique_name": "Unique name",
"size_kwh": "Battery size in kWh",
"max_discharge_rate_kw": "Maximum discharge rate in kW",
"max_charge_rate_kw": "Maximum charging rate in kW",
"discharge_efficiency": "Discharge efficiency or curve (e.g. 0.95 or 0:0.9, 5:0.95)",
"charge_efficiency": "Charge efficiency or curve (e.g. 0.95 or 0:0.9, 5:0.95)",
"rated_battery_cycles": "Rated battery cycles",
"end_of_life_degradation": "Capacity at end of life (0 to 1)",
"update_frequency": "Update frequency in seconds",
"solar_energy_sensor": "Solar energy sensor",
"nominal_inverter_power_kw": "Nominal inverter power in kW"
},
"description": "Set the specifications of the battery. Efficiencies can be entered as a single value or as power/efficiency pairs such as 0:0.9, 5:0.95. The optional solar energy sensor caps charging to the solar energy produced during each update interval. Please consider posting details of the battery you are simulating on our github so we can add it to the template list."
},
"meter_menu": {
"title": "Add Meters",
"menu_options": {
"add_import_meter": "Add import meter (measuring energy coming into home from the grid)",
"add_export_meter": "Add export meter (measuring energy leaving into home to the grid)",
"all_done": "All finished"
},
"description": "At least one import and one export meter are required. Meters monitoring solar generation directly shouldn't be added."
},
"tariff_menu": {
"title": "Select Tariff Type",
"menu_options": {
"no_tariff_info": "No tariff for this meter",
"fixed_tariff": "A constant fixed number for the tariff",
"tariff_sensor": "A sensor that contains the value of a tariff varying over time"
},
"description": ""
},
"add_import_meter": {
"title": "Add Import Meter To Battery",
"data": {
"sensor_id": "Energy meter sensor"
},
"description": "Select meter sensor"
},
"add_export_meter": {
"title": "Add Export Meter To Battery",
"data": {
"sensor_id": "Energy meter sensor"
},
"description": "Select meter sensor"
},
"fixed_tariff": {
"title": "Add Fixed Tariff Details",
"data": {
"fixed_tariff": "Fixed tariff value (if applicable)"
},
"description": ""
},
"tariff_sensor": {
"title": "Add Tariff Sensor Details",
"data": {
"tariff_sensor": "Sensor that shows current tariff (if applicable)"
},
"description": ""
}
}
},
"options": {
"step": {
"init": {
"title": "Battery Options",
"description": "Select parameters to amend",
"menu_options": {
"main_params": "Main Parameters",
"input_sensors": "Edit Meters/Sensors",
"all_done": "All done"
}
},
"main_params": {
"title": "Main Battery Options",
"description": "Main Parameters",
"data": {
"size_kwh": "Battery size in kWh",
"max_discharge_rate_kw": "Maximum discharge rate in kW",
"max_charge_rate_kw": "Maximum charging rate in kW",
"discharge_efficiency": "Discharge efficiency or curve (e.g. 0.95 or 0:0.9, 5:0.95)",
"charge_efficiency": "Charge efficiency or curve (e.g. 0.95 or 0:0.9, 5:0.95)",
"rated_battery_cycles": "Rated battery cycles",
"end_of_life_degradation": "Capacity at end of life (0 to 1)",
"update_frequency": "Update frequency in seconds",
"solar_energy_sensor": "Solar energy sensor",
"nominal_inverter_power_kw": "Nominal inverter power in kW"
}
},
"input_sensors": {
"title": "Edit Meters/Sensors",
"menu_options": {
"add_import_meter": "Add import meter (measuring energy coming into home from the grid)",
"add_export_meter": "Add export meter (measuring energy leaving home to the grid)",
"edit_input_tariff": "Edit tariff details for a meter",
"delete_input": "Delete a meter"
},
"description": "At least one import and one export meter are required. Meters monitoring solar generation directly shouldn't be used."
},
"tariff_menu": {
"title": "Select Tariff Type",
"menu_options": {
"no_tariff_info": "No tariff for this meter",
"fixed_tariff": "A constant fixed price for the tariff",
"tariff_sensor": "A sensor that represents the value of a tariff varying over time"
},
"description": ""
},
"add_import_meter": {
"title": "Add Import Meter To Battery",
"data": {
"sensor_id": "Energy meter sensor"
},
"description": "Select meter sensor"
},
"add_export_meter": {
"title": "Add Export Meter To Battery",
"data": {
"sensor_id": "Energy meter sensor"
},
"description": "Select meter sensor"
},
"delete_input": {
"title": "Select Meter To Delete",
"data": {},
"description": ""
},
"edit_input_tariff": {
"title": "Select Meter To Edit Tariff For",
"data": {},
"description": ""
},
"fixed_tariff": {
"title": "Add Fixed Tariff",
"data": {
"fixed_tariff": "Fixed tariff value"
},
"description": ""
},
"tariff_sensor": {
"title": "Add Tariff Sensor",
"data": {
"tariff_sensor": "Sensor that shows current tariff"
},
"description": ""
}
}
},
"services": {
"set_battery_charge_state": {
"name": "Set Battery Charge",
"description": "Set the battery charge state for a specific simulated battery",
"fields": {
"device_id": {
"name": "Target Battery Device",
"description": "Device to set battery charge for"
},
"charge_state": {
"name": "Charge State",
"description": "Value of charge in kWh"
}
}
},
"set_battery_cycles": {
"name": "Set Battery Cycles",
"description": "Set the simulated battery cycles for a specific battery device",
"fields": {
"device_id": {
"name": "Target Battery Device",
"description": "Device to set battery cycles for"
},
"battery_cycles": {
"name": "Battery Cycles",
"description": "Cycle count to apply"
}
}
}
}
}
================================================
FILE: custom_components/battery_sim/translations/nl.json
================================================
{
"config": {
"abort": {
"already_configured": "Het apparaat is al geconfigureerd."
},
"error": {
"invalid_input": "Ongeldige invoer"
},
"flow_title": "Nieuwe gesimuleerde batterij instellen",
"step": {
"user": {
"title": "Selecteer batterij",
"data": {
"battery": "Batterijmodel"
},
"description": "Selecteer een batterijmodel om te simuleren uit de lijst of kies Aangepast om er zelf een te maken."
},
"custom": {
"title": "Aangepaste batterij",
"data": {
"unique_name": "Unieke naam",
"size_kwh": "Batterijgrootte in kWh",
"max_discharge_rate_kw": "Maximale ontlaadsnelheid in kW",
"max_charge_rate_kw": "Maximale laadsnelheid in kW",
"discharge_efficiency": "Ontlaadefficiëntie of curve (bijv. 0.95 of 0:0.9, 5:0.95)",
"charge_efficiency": "Laadefficiëntie of curve (bijv. 0.95 of 0:0.9, 5:0.95)",
"rated_battery_cycles": "Nominale batterijcycli",
"end_of_life_degradation": "Capaciteit aan het einde van de levensduur (0 t/m 1)",
"update_frequency": "Updatefrequentie in seconden",
"solar_energy_sensor": "Solarenergie sensor",
"nominal_inverter_power_kw": "Nominaal vermogen van de omvormer in kW"
},
"description": "Stel de specificaties van de batterij in. Efficiënties kunnen worden ingevoerd als een enkele waarde of als combinaties van vermogen/efficiëntie zoals 0:0.9, 5:0.95. Overweeg om details van de batterij die je simuleert op onze GitHub te delen, zodat we die aan de sjabloonlijst kunnen toevoegen."
},
"meter_menu": {
"title": "Meters toevoegen",
"menu_options": {
"add_import_meter": "Importmeter toevoegen (meet energie die vanuit het net het huis binnenkomt)",
"add_export_meter": "Exportmeter toevoegen (meet energie die vanuit het huis naar het net gaat)",
"all_done": "Alles klaar"
},
"description": "Er is minimaal één importmeter en één exportmeter vereist. Meters die direct zonne-opwek meten, moeten niet worden toegevoegd."
},
"tariff_menu": {
"title": "Selecteer tarieftype",
"menu_options": {
"no_tariff_info": "Geen tarief voor deze meter",
"fixed_tariff": "Een vaste constante waarde voor het tarief",
"tariff_sensor": "Een sensor die de waarde bevat van een tarief dat in de tijd varieert"
},
"description": ""
},
"add_import_meter": {
"title": "Importmeter aan batterij toevoegen",
"data": {
"sensor_id": "Energiemetersensor"
},
"description": "Selecteer metersensor"
},
"add_export_meter": {
"title": "Exportmeter aan batterij toevoegen",
"data": {
"sensor_id": "Energiemetersensor"
},
"description": "Selecteer metersensor"
},
"fixed_tariff": {
"title": "Details vast tarief toevoegen",
"data": {
"fixed_tariff": "Vaste tariefwaarde (indien van toepassing)"
},
"description": ""
},
"tariff_sensor": {
"title": "Details tariefsensors toevoegen",
"data": {
"tariff_sensor": "Sensor die het huidige tarief toont (indien van toepassing)"
},
"description": ""
}
}
},
"options": {
"step": {
"init": {
"title": "Batterijopties",
"description": "Selecteer parameters om aan te passen",
"menu_options": {
"main_params": "Hoofdparameters",
"input_sensors": "Meters/sensoren bewerken",
"all_done": "Alles klaar"
}
},
"main_params": {
"title": "Belangrijkste batterijopties",
"description": "Hoofdparameters",
"data": {
"size_kwh": "Batterijgrootte in kWh",
"max_discharge_rate_kw": "Maximale ontlaadsnelheid in kW",
"max_charge_rate_kw": "Maximale laadsnelheid in kW",
"discharge_efficiency": "Ontlaadefficiëntie of curve (bijv. 0.95 of 0:0.9, 5:0.95)",
"charge_efficiency": "Laadefficiëntie of curve (bijv. 0.95 of 0:0.9, 5:0.95)",
"rated_battery_cycles": "Nominale batterijcycli",
"end_of_life_degradation": "Capaciteit aan het einde van de levensduur (0 tot 1)",
"update_frequency": "Updatefrequentie in seconden",
"solar_energy_sensor": "Solarenergie sensor",
"nominal_inverter_power_kw": "Nominaal vermogen van de omvormer in kW"
}
},
"input_sensors": {
"title": "Meters/sensoren bewerken",
"menu_options": {
"add_import_meter": "Importmeter toevoegen (meet energie die vanuit het net het huis binnenkomt)",
"add_export_meter": "Exportmeter toevoegen (meet energie die vanuit het huis naar het net gaat)",
"edit_input_tariff": "Tariefdetails voor een meter bewerken",
"delete_input": "Een meter verwijderen"
},
"description": "Er is minimaal één importmeter en één exportmeter vereist. Meters die direct zonne-opwek meten, moeten niet worden gebruikt."
},
"tariff_menu": {
"title": "Selecteer tarieftype",
"menu_options": {
"no_tariff_info": "Geen tarief voor deze meter",
"fixed_tariff": "Een vaste constante prijs voor het tarief",
"tariff_sensor": "Een sensor die de waarde weergeeft van een tarief dat in de tijd varieert"
},
"description": ""
},
"add_import_meter": {
"title": "Importmeter aan batterij toevoegen",
"data": {
"sensor_id": "Energiemetersensor"
},
"description": "Selecteer metersensor"
},
"add_export_meter": {
"title": "Exportmeter aan batterij toevoegen",
"data": {
"sensor_id": "Energiemetersensor"
},
"description": "Selecteer metersensor"
},
"delete_input": {
"title": "Selecteer meter om te verwijderen",
"data": {},
"description": ""
},
"edit_input_tariff": {
"title": "Selecteer meter waarvan je het tarief wilt bewerken",
"data": {},
"description": ""
},
"fixed_tariff": {
"title": "Vast tarief toevoegen",
"data": {
"fixed_tariff": "Vaste tariefwaarde"
},
"description": ""
},
"tariff_sensor": {
"title": "Tariefsensors toevoegen",
"data": {
"tariff_sensor": "Sensor die het huidige tarief toont"
},
"description": ""
}
}
},
"services": {
"set_battery_charge_state": {
"name": "Batterijlading instellen",
"description": "Stelt de laadstatus van de gesimuleerde batterij in voor een specifiek apparaat",
"fields": {
"device_id": {
"name": "Doelbatterijapparaat",
"description": "Apparaat waarvoor de batterijlading moet worden ingesteld"
},
"charge_state": {
"name": "Laadstatus",
"description": "Laadwaarde in kWh"
}
}
},
"set_battery_cycles": {
"name": "Batterijcycli instellen",
"description": "Stelt de gesimuleerde batterijcycli in voor een specifiek batterijapparaat",
"fields": {
"device_id": {
"name": "Doelbatterijapparaat",
"description": "Apparaat waarvoor de batterijcycli moeten worden ingesteld"
},
"battery_cycles": {
"name": "Batterijcycli",
"description": "Aantal cycli om toe te passen"
}
}
}
}
}
================================================
FILE: custom_components/battery_sim/translations/sv.json
================================================
{
"config": {
"abort": {
"already_configured": "Den här enheten är redan konfigurerad."
},
"error": {
"invalid_input": "Ogiltig inmatning"
},
"flow_title": "Ställ in nytt simulerat batteri",
"step": {
"user": {
"title": "Välj batteri",
"data": {
"battery": "Batterimodell"
},
"description": "Välj en batterimodell att simulera från listan eller välj Anpassad för att skapa en ny."
},
"custom": {
"title": "Anpassat batteri",
"data": {
"unique_name": "Unikt namn",
"size_kwh": "Batteristorlek i kWh",
"max_discharge_rate_kw": "Maximal urladdningshastighet i kW",
"max_charge_rate_kw": "Maximal laddningshastighet i kW",
"discharge_efficiency": "Urladdningseffektivitet eller kurva (t.ex. 0.95 eller 0:0.9, 5:0.95)",
"charge_efficiency": "Laddningseffektivitet eller kurva (t.ex. 0.95 eller 0:0.9, 5:0.95)",
"rated_battery_cycles": "Nominella battericykler",
"end_of_life_degradation": "Kapacitet vid slutet av livslängden (0 till 1)",
"update_frequency": "Uppdateringsfrekvens i sekunder",
"solar_energy_sensor": "Solenergisensor",
"nominal_inverter_power_kw": "Inverterarens nominella effekt, kW"
},
"description": "Ange batteriets specifikationer. Effektiviteter kan anges som ett enskilt värde eller som effekt/effektivitetspar som 0:0.9, 5:0.95. Överväg gärna att dela detaljer om batteriet du simulerar på vår GitHub så att vi kan lägga till det i mallistan."
},
"meter_menu": {
"title": "Lägg till mätare",
"menu_options": {
"add_import_meter": "Lägg till importmätare (mäter energi som kommer in i hemmet från elnätet)",
"add_export_meter": "Lägg till exportmätare (mäter energi som lämnar hemmet till elnätet)",
"all_done": "Allt klart"
},
"description": "Minst en importmätare och en exportmätare krävs. Mätare som övervakar solelproduktion direkt ska inte läggas till."
},
"tariff_menu": {
"title": "Välj tarifftyp",
"menu_options": {
"no_tariff_info": "Ingen tariff för den här mätaren",
"fixed_tariff": "Ett konstant fast värde för tariffen",
"tariff_sensor": "En sensor som innehåller värdet för en tariff som varierar över tid"
},
"description": ""
},
"add_import_meter": {
"title": "Lägg till importmätare till batteriet",
"data": {
"sensor_id": "Energimätarsensor"
},
"description": "Välj mätarsensor"
},
"add_export_meter": {
"title": "Lägg till exportmätare till batteriet",
"data": {
"sensor_id": "Energimätarsensor"
},
"description": "Välj mätarsensor"
},
"fixed_tariff": {
"title": "Lägg till detaljer för fast tariff",
"data": {
"fixed_tariff": "Fast tariffvärde (om tillämpligt)"
},
"description": ""
},
"tariff_sensor": {
"title": "Lägg till detaljer för tariffsensor",
"data": {
"tariff_sensor": "Sensor som visar aktuell tariff (om tillämpligt)"
},
"description": ""
}
}
},
"options": {
"step": {
"init": {
"title": "Batteriinställningar",
"description": "Välj parametrar att ändra",
"menu_options": {
"main_params": "Huvudparametrar",
"input_sensors": "Redigera mätare/sensorer",
"all_done": "Allt klart"
}
},
"main_params": {
"title": "Huvudsakliga batteriinställningar",
"description": "Huvudparametrar",
"data": {
"size_kwh": "Batteristorlek i kWh",
"max_discharge_rate_kw": "Maximal urladdningshastighet i kW",
"max_charge_rate_kw": "Maximal laddningshastighet i kW",
"discharge_efficiency": "Urladdningseffektivitet eller kurva (t.ex. 0.95 eller 0:0.9, 5:0.95)",
"charge_efficiency": "Laddningseffektivitet eller kurva (t.ex. 0.95 eller 0:0.9, 5:0.95)",
"rated_battery_cycles": "Nominella battericykler",
"end_of_life_degradation": "Kapacitet vid slutet av livslängden (0 till 1)",
"update_frequency": "Uppdateringsfrekvens i sekunder",
"solar_energy_sensor": "Solenergisensor",
"nominal_inverter_power_kw": "Inverterarens nominella effekt, kW"
}
},
"input_sensors": {
"title": "Redigera mätare/sensorer",
"menu_options": {
"add_import_meter": "Lägg till importmätare (mäter energi som kommer in i hemmet från elnätet)",
"add_export_meter": "Lägg till exportmätare (mäter energi som lämnar hemmet till elnätet)",
"edit_input_tariff": "Redigera tariffdetaljer för en mätare",
"delete_input": "Ta bort en mätare"
},
"description": "Minst en importmätare och en exportmätare krävs. Mätare som övervakar solelproduktion direkt ska inte användas."
},
"tariff_menu": {
"title": "Välj tarifftyp",
"menu_options": {
"no_tariff_info": "Ingen tariff för den här mätaren",
"fixed_tariff": "Ett konstant fast pris för tariffen",
"tariff_sensor": "En sensor som representerar värdet för en tariff som varierar över tid"
},
"description": ""
},
"add_import_meter": {
"title": "Lägg till importmätare till batteriet",
"data": {
"sensor_id": "Energimätarsensor"
},
"description": "Välj mätarsensor"
},
"add_export_meter": {
"title": "Lägg till exportmätare till batteriet",
"data": {
"sensor_id": "Energimätarsensor"
},
"description": "Välj mätarsensor"
},
"delete_input": {
"title": "Välj mätare att ta bort",
"data": {},
"description": ""
},
"edit_input_tariff": {
"title": "Välj mätare vars tariff ska redigeras",
"data": {},
"description": ""
},
"fixed_tariff": {
"title": "Lägg till fast tariff",
"data": {
"fixed_tariff": "Fast tariffvärde"
},
"description": ""
},
"tariff_sensor": {
"title": "Lägg till tariffsensor",
"data": {
"tariff_sensor": "Sensor som visar aktuell tariff"
},
"description": ""
}
}
},
"services": {
"set_battery_charge_state": {
"name": "Ange batteriladdning",
"description": "Ställ in batteriets laddningsnivå för en specifik simulerad enhet",
"fields": {
"device_id": {
"name": "Målbatterienhet",
"description": "Enhet vars batteriladdning ska ställas in"
},
"charge_state": {
"name": "Laddningsnivå",
"description": "Laddningsvärde i kWh"
}
}
},
"set_battery_cycles": {
"name": "Ange battericykler",
"description": "Ställ in simulerade battericykler för en specifik batterienhet",
"fields": {
"device_id": {
"name": "Målbatterienhet",
"description": "Enhet vars battericykler ska ställas in"
},
"battery_cycles": {
"name": "Battericykler",
"description": "Antal cykler att tillämpa"
}
}
}
}
}
================================================
FILE: hacs.json
================================================
{
"name": "Battery Simulator",
"render_readme": true
}
================================================
FILE: scripts/check_translations_usage.py
================================================
#!/usr/bin/env python3
"""Check translation key usage and duplicate JSON keys for battery_sim."""
from __future__ import annotations
import ast
import json
import pathlib
import re
import sys
from typing import Any
ROOT = pathlib.Path(__file__).resolve().parents[1]
COMPONENT = ROOT / "custom_components" / "battery_sim"
TRANSLATIONS = COMPONENT / "translations"
CONFIG_FLOW = COMPONENT / "config_flow.py"
CONSTS = COMPONENT / "const.py"
def parse_consts() -> dict[str, str]:
consts: dict[str, str] = {}
for line in CONSTS.read_text(encoding="utf-8").splitlines():
match = re.match(r'([A-Z_]+)\s*=\s*"([^"]+)"', line)
if match:
consts[match.group(1)] = match.group(2)
return consts
def resolve_const(name: str, consts: dict[str, str]) -> str:
return consts.get(name, name)
def flatten_leaves(obj: Any, prefix: tuple[str, ...] = ()) -> set[tuple[str, ...]]:
leaves: set[tuple[str, ...]] = set()
if isinstance(obj, dict):
for key, value in obj.items():
leaves |= flatten_leaves(value, prefix + (key,))
else:
leaves.add(prefix)
return leaves
def load_json_and_duplicates(path: pathlib.Path) -> tuple[dict[str, Any], list[str]]:
duplicates: list[str] = []
def pairs_hook(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
out: dict[str, Any] = {}
seen: set[str] = set()
for key, value in pairs:
if key in seen:
duplicates.append(key)
seen.add(key)
out[key] = value
return out
data = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=pairs_hook)
return data, duplicates
def collect_used_paths() -> set[tuple[str, ...]]:
consts = parse_consts()
src = CONFIG_FLOW.read_text(encoding="utf-8")
tree = ast.parse(src)
step_ids: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
if node.func.attr in {"async_show_form", "async_show_menu"}:
for kw in node.keywords:
if (
kw.arg == "step_id"
and isinstance(kw.value, ast.Constant)
and isinstance(kw.value.value, str)
):
step_ids.add(kw.value.value)
used: set[tuple[str, ...]] = {
("config", "abort", "already_configured"),
("config", "flow_title"),
("config", "error", "invalid_input"),
}
for section in ("config", "options"):
for step_id in step_ids:
used.add((section, "step", step_id, "title"))
used.add((section, "step", step_id, "description"))
for opt in ("add_import_meter", "add_export_meter", "all_done"):
used.add(("config", "step", "meter_menu", "menu_options", opt))
for opt in ("no_tariff_info", "fixed_tariff", "tariff_sensor"):
used.add(("config", "step", "tariff_menu", "menu_options", opt))
used.add(("options", "step", "tariff_menu", "menu_options", opt))
for opt in ("main_params", "input_sensors", "all_done"):
used.add(("options", "step", "init", "menu_options", opt))
for opt in ("add_import_meter", "add_export_meter", "edit_input_tariff", "delete_input"):
used.add(("options", "step", "input_sensors", "menu_options", opt))
step_fields = {
("config", "user"): ["BATTERY_TYPE"],
("config", "custom"): [
"CONF_UNIQUE_NAME",
"CONF_BATTERY_SIZE",
"CONF_BATTERY_MAX_DISCHARGE_RATE",
"CONF_BATTERY_MAX_CHARGE_RATE",
"CONF_BATTERY_DISCHARGE_EFFICIENCY",
"CONF_BATTERY_CHARGE_EFFICIENCY",
"CONF_RATED_BATTERY_CYCLES",
"CONF_END_OF_LIFE_DEGRADATION",
"CONF_UPDATE_FREQUENCY",
],
("config", "add_import_meter"): ["SENSOR_ID"],
("config", "add_export_meter"): ["SENSOR_ID"],
("config", "fixed_tariff"): ["FIXED_TARIFF"],
("config", "tariff_sensor"): ["TARIFF_SENSOR"],
("options", "main_params"): [
"CONF_BATTERY_SIZE",
"CONF_BATTERY_MAX_DISCHARGE_RATE",
"CONF_BATTERY_MAX_CHARGE_RATE",
"CONF_BATTERY_DISCHARGE_EFFICIENCY",
"CONF_BATTERY_CHARGE_EFFICIENCY",
"CONF_RATED_BATTERY_CYCLES",
"CONF_END_OF_LIFE_DEGRADATION",
"CONF_UPDATE_FREQUENCY",
],
("options", "add_import_meter"): ["SENSOR_ID"],
("options", "add_export_meter"): ["SENSOR_ID"],
("options", "fixed_tariff"): ["FIXED_TARIFF"],
("options", "tariff_sensor"): ["TARIFF_SENSOR"],
("options", "delete_input"): ["CONF_INPUT_LIST"],
("options", "edit_input_tariff"): ["CONF_INPUT_LIST"],
}
for (section, step_id), fields in step_fields.items():
for field in fields:
used.add((section, "step", step_id, "data", resolve_const(field, consts)))
for svc in ("set_battery_charge_state", "set_battery_cycles"):
used.add(("services", svc, "name"))
used.add(("services", svc, "description"))
for svc, field in (
("set_battery_charge_state", "device_id"),
("set_battery_charge_state", "charge_state"),
("set_battery_cycles", "device_id"),
("set_battery_cycles", "battery_cycles"),
):
used.add(("services", svc, "fields", field, "name"))
used.add(("services", svc, "fields", field, "description"))
return used
def main() -> int:
used = collect_used_paths()
any_problem = False
for path in sorted(TRANSLATIONS.glob("*.json")):
data, duplicates = load_json_and_duplicates(path)
leaves = flatten_leaves(data)
unused = sorted(".".join(p) for p in leaves - used)
missing = sorted(".".join(p) for p in used - leaves)
print(f"{path.name}:")
print(f" duplicate keys: {len(duplicates)}")
print(f" unused leaf keys: {len(unused)}")
print(f" missing used keys: {len(missing)}")
if duplicates:
any_problem = True
print(" duplicate key names:")
for d in duplicates:
print(f" - {d}")
if unused:
any_problem = True
print(" unused keys:")
for u in unused:
print(f" - {u}")
if missing:
any_problem = True
print(" missing keys:")
for m in missing:
print(f" - {m}")
return 1 if any_problem else 0
if __name__ == "__main__":
raise SystemExit(main())
gitextract_1ow9x04h/
├── .github/
│ ├── dependabot.yml
│ └── workflows/
│ ├── hacsaction.yml
│ └── main.yml
├── .gitignore
├── README.md
├── custom_components/
│ └── battery_sim/
│ ├── __init__.py
│ ├── button.py
│ ├── config_flow.py
│ ├── const.py
│ ├── helpers.py
│ ├── manifest.json
│ ├── number.py
│ ├── select.py
│ ├── sensor.py
│ ├── services.yaml
│ ├── switch.py
│ └── translations/
│ ├── de.json
│ ├── en.json
│ ├── nl.json
│ └── sv.json
├── hacs.json
└── scripts/
└── check_translations_usage.py
SYMBOL INDEX (155 symbols across 9 files)
FILE: custom_components/battery_sim/__init__.py
function async_setup (line 147) | async def async_setup(hass, config):
function async_setup_entry (line 175) | async def async_setup_entry(hass, entry) -> bool:
function async_update_settings (line 255) | async def async_update_settings(hass, entry):
function async_unload_entry (line 261) | async def async_unload_entry(hass, config_entry):
class SimulatedBatteryHandle (line 296) | class SimulatedBatteryHandle:
method _safe_curve_efficiency (line 300) | def _safe_curve_efficiency(curve, fallback=1.0):
method __init__ (line 306) | def __init__(self, config, hass, entry_id=None):
method device_identifier (line 406) | def device_identifier(self):
method matches_device_identifiers (line 410) | def matches_device_identifiers(self, identifiers):
method async_set_battery_charge_state (line 418) | def async_set_battery_charge_state(self, state: float):
method async_set_battery_cycles (line 432) | def async_set_battery_cycles(self, cycles: float):
method async_reset_battery (line 445) | def async_reset_battery(self):
method reset_sim_sensor (line 486) | def reset_sim_sensor(self, target_sensor_key):
method async_source_tracking (line 506) | def async_source_tracking(self, event):
method async_periodic_update (line 539) | def async_periodic_update(self, now):
method async_reading_handler (line 544) | def async_reading_handler(
method async_solar_reading_handler (line 632) | def async_solar_reading_handler(self, event):
method get_tariff_information (line 683) | def get_tariff_information(self, input_details):
method set_slider_limit (line 705) | def set_slider_limit(self, value: float, key: str):
method async_trigger_update (line 719) | def async_trigger_update(self):
method _async_maybe_update_battery (line 723) | def _async_maybe_update_battery(self):
method _async_delayed_update (line 753) | def _async_delayed_update(self, _now):
method degradation_factor (line 759) | def degradation_factor(self) -> float:
method current_max_capacity (line 766) | def current_max_capacity(self) -> float:
method update_battery (line 770) | def update_battery(self, import_amount, export_amount, solar_amount=0.0):
FILE: custom_components/battery_sim/button.py
function async_setup_entry (line 20) | async def async_setup_entry(hass, config_entry, async_add_entities):
function async_setup_platform (line 33) | async def async_setup_platform(
class BatteryButton (line 52) | class BatteryButton(ButtonEntity):
method __init__ (line 55) | def __init__(self, handle, button_type, key, icon):
method unique_id (line 68) | def unique_id(self):
method name (line 73) | def name(self):
method device_info (line 77) | def device_info(self):
method icon (line 84) | def icon(self):
method should_poll (line 89) | def should_poll(self):
method async_press (line 93) | async def async_press(self):
FILE: custom_components/battery_sim/config_flow.py
class BatterySetupConfigFlow (line 57) | class BatterySetupConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
method async_get_options_flow (line 62) | def async_get_options_flow(config_entry):
method _validate_efficiency_fields (line 67) | def _validate_efficiency_fields(user_input):
method async_step_user (line 80) | async def async_step_user(self, user_input):
method async_step_custom (line 107) | async def async_step_custom(self, user_input=None):
method async_step_meter_menu (line 169) | async def async_step_meter_menu(self, user_input=None):
method async_step_add_import_meter (line 182) | async def async_step_add_import_meter(self, user_input=None):
method async_step_add_export_meter (line 202) | async def async_step_add_export_meter(self, user_input=None):
method async_step_tariff_menu (line 222) | async def async_step_tariff_menu(self, user_input=None):
method async_step_no_tariff_info (line 228) | async def async_step_no_tariff_info(self, user_input=None):
method async_step_fixed_tariff (line 233) | async def async_step_fixed_tariff(self, user_input=None):
method async_step_tariff_sensor (line 251) | async def async_step_tariff_sensor(self, user_input=None):
method async_step_all_done (line 265) | async def async_step_all_done(self, user_input=None):
class BatteryOptionsFlowHandler (line 269) | class BatteryOptionsFlowHandler(config_entries.OptionsFlow):
method __init__ (line 272) | def __init__(self, config_entry=None):
method _battery_config_entry (line 279) | def _battery_config_entry(self):
method _validate_efficiency_fields (line 284) | def _validate_efficiency_fields(user_input):
method async_step_init (line 297) | async def async_step_init(self, user_input=None):
method async_step_main_params (line 311) | async def async_step_main_params(self, user_input=None):
method async_step_input_sensors (line 420) | async def async_step_input_sensors(self, user_input=None):
method async_step_delete_input (line 433) | async def async_step_delete_input(self, user_input=None):
method async_step_edit_input_tariff (line 456) | async def async_step_edit_input_tariff(self, user_input=None):
method async_step_add_import_meter (line 474) | async def async_step_add_import_meter(self, user_input=None):
method async_step_add_export_meter (line 495) | async def async_step_add_export_meter(self, user_input=None):
method async_step_tariff_menu (line 516) | async def async_step_tariff_menu(self, user_input=None):
method async_step_no_tariff_info (line 522) | async def async_step_no_tariff_info(self, user_input=None):
method async_step_fixed_tariff (line 531) | async def async_step_fixed_tariff(self, user_input=None):
method async_step_tariff_sensor (line 556) | async def async_step_tariff_sensor(self, user_input=None):
method async_step_all_done (line 576) | async def async_step_all_done(self, user_input=None):
FILE: custom_components/battery_sim/helpers.py
function generate_input_list (line 32) | def generate_input_list(config):
function parse_efficiency_curve (line 108) | def parse_efficiency_curve(raw_value):
function validate_efficiency_config (line 160) | def validate_efficiency_config(raw_value):
function interpolate_efficiency (line 166) | def interpolate_efficiency(curve_points, power_kw):
function _validate_efficiency (line 186) | def _validate_efficiency(value):
FILE: custom_components/battery_sim/number.py
function async_setup_entry (line 53) | async def async_setup_entry(hass, config_entry, async_add_entities):
function async_setup_platform (line 65) | async def async_setup_platform( hass, configuration, async_add_entities,...
class BatterySlider (line 84) | class BatterySlider(RestoreNumber):
method __init__ (line 87) | def __init__(self, handle, slider_type, key, icon, unit, precision):
method unique_id (line 117) | def unique_id(self):
method name (line 122) | def name(self):
method device_info (line 126) | def device_info(self):
method native_min_value (line 133) | def native_min_value(self):
method native_max_value (line 137) | def native_max_value(self):
method native_step (line 141) | def native_step(self):
method native_value (line 145) | def native_value(self):
method async_set_native_value (line 148) | async def async_set_native_value(self, value: float) -> None:
method async_added_to_hass (line 156) | async def async_added_to_hass(self):
FILE: custom_components/battery_sim/select.py
function async_setup_entry (line 21) | async def async_setup_entry(hass, config_entry, async_add_entities):
function async_setup_platform (line 27) | async def async_setup_platform(
class BatteryMode (line 42) | class BatteryMode(SelectEntity):
method __init__ (line 45) | def __init__(self, handle):
method unique_id (line 61) | def unique_id(self):
method name (line 65) | def name(self):
method device_info (line 69) | def device_info(self):
method icon (line 76) | def icon(self):
method current_option (line 80) | def current_option(self):
method options (line 84) | def options(self):
method async_select_option (line 87) | async def async_select_option(self, option: str):
FILE: custom_components/battery_sim/sensor.py
function async_setup_entry (line 76) | async def async_setup_entry(hass, config_entry, async_add_entities):
function async_setup_platform (line 82) | async def async_setup_platform(
function define_sensors (line 95) | async def define_sensors(hass, handle):
class DisplayOnlySensor (line 184) | class DisplayOnlySensor(RestoreEntity, SensorEntity):
method __init__ (line 194) | def __init__(self, handle, sensor_name, type_of_sensor, units):
method _supports_last_reset (line 208) | def _supports_last_reset(self):
method async_added_to_hass (line 212) | async def async_added_to_hass(self):
method name (line 259) | def name(self):
method unique_id (line 264) | def unique_id(self):
method device_info (line 269) | def device_info(self):
method native_value (line 273) | def native_value(self):
method device_class (line 284) | def device_class(self):
method state_class (line 289) | def state_class(self):
method unit_of_measurement (line 302) | def unit_of_measurement(self):
method extra_state_attributes (line 307) | def extra_state_attributes(self):
method icon (line 343) | def icon(self):
method state (line 347) | def state(self):
method update (line 361) | def update(self):
method last_reset (line 366) | def last_reset(self):
method available (line 373) | def available(self) -> bool:
class SimulatedBattery (line 380) | class SimulatedBattery(RestoreEntity, SensorEntity):
method __init__ (line 385) | def __init__(self, handle):
method async_added_to_hass (line 391) | async def async_added_to_hass(self):
method name (line 428) | def name(self):
method unique_id (line 433) | def unique_id(self):
method device_info (line 438) | def device_info(self):
method native_value (line 445) | def native_value(self):
method device_class (line 450) | def device_class(self):
method state_class (line 455) | def state_class(self):
method native_unit_of_measurement (line 460) | def native_unit_of_measurement(self):
method unit_of_measurement (line 465) | def unit_of_measurement(self):
method extra_state_attributes (line 470) | def extra_state_attributes(self):
method icon (line 491) | def icon(self):
method state (line 502) | def state(self):
class BatteryStatus (line 507) | class BatteryStatus(SensorEntity):
method __init__ (line 512) | def __init__(self, handle, sensor_name):
method async_added_to_hass (line 521) | async def async_added_to_hass(self):
method name (line 536) | def name(self):
method unique_id (line 541) | def unique_id(self):
method device_info (line 546) | def device_info(self):
method native_value (line 550) | def native_value(self):
method device_class (line 555) | def device_class(self):
method extra_state_attributes (line 560) | def extra_state_attributes(self):
method icon (line 568) | def icon(self):
method state (line 580) | def state(self):
FILE: custom_components/battery_sim/switch.py
function async_setup_entry (line 19) | async def async_setup_entry(hass, config_entry, async_add_entities):
function async_setup_platform (line 31) | async def async_setup_platform(
class BatterySwitch (line 50) | class BatterySwitch(SwitchEntity):
method __init__ (line 53) | def __init__(self, handle, switch_type, key, icon):
method unique_id (line 68) | def unique_id(self):
method name (line 72) | def name(self):
method device_info (line 76) | def device_info(self):
method icon (line 83) | def icon(self):
method is_on (line 87) | def is_on(self):
method async_turn_on (line 90) | async def async_turn_on(self, **kwargs):
method async_turn_off (line 96) | async def async_turn_off(self, **kwargs):
FILE: scripts/check_translations_usage.py
function parse_consts (line 20) | def parse_consts() -> dict[str, str]:
function resolve_const (line 29) | def resolve_const(name: str, consts: dict[str, str]) -> str:
function flatten_leaves (line 33) | def flatten_leaves(obj: Any, prefix: tuple[str, ...] = ()) -> set[tuple[...
function load_json_and_duplicates (line 43) | def load_json_and_duplicates(path: pathlib.Path) -> tuple[dict[str, Any]...
function collect_used_paths (line 60) | def collect_used_paths() -> set[tuple[str, ...]]:
function main (line 154) | def main() -> int:
Condensed preview — 22 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (177K chars).
[
{
"path": ".github/dependabot.yml",
"chars": 159,
"preview": "# Set update schedule for GitHub Actions\nversion: 2\nupdates:\n - package-ecosystem: \"github-actions\"\n directory: \"/\"\n"
},
{
"path": ".github/workflows/hacsaction.yml",
"chars": 303,
"preview": "name: HACS Action\n\non:\n push:\n pull_request:\n schedule:\n - cron: \"0 0 * * *\"\n\njobs:\n hacs:\n name: HACS Action\n"
},
{
"path": ".github/workflows/main.yml",
"chars": 212,
"preview": "name: Validate with hassfest\n\non:\n push:\n pull_request:\n\njobs:\n validate:\n runs-on: \"ubuntu-latest\"\n steps:\n "
},
{
"path": ".gitignore",
"chars": 9,
"preview": ".DS_Store"
},
{
"path": "README.md",
"chars": 12970,
"preview": "# Battery Simulator integration/custom component for home assistant\n\n[. The extraction includes 22 files (162.8 KB), approximately 40.3k tokens, and a symbol index with 155 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.