From 15c89a6e51ca870ebfaea4744d5484d1fcb0328f Mon Sep 17 00:00:00 2001 From: Chaoscontrol Date: Fri, 29 Nov 2024 20:09:23 +0000 Subject: [PATCH 01/14] Add sensor platform --- custom_components/leafspy/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/leafspy/__init__.py b/custom_components/leafspy/__init__.py index d60eb2e..f514925 100755 --- a/custom_components/leafspy/__init__.py +++ b/custom_components/leafspy/__init__.py @@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["device_tracker"] +PLATFORMS = ["device_tracker", "sensor"] # Use empty_config_schema because the component does not have any config options CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) From f981b623bf58dbb52e43d20b906c9635e50154f3 Mon Sep 17 00:00:00 2001 From: Chaoscontrol Date: Fri, 29 Nov 2024 20:12:24 +0000 Subject: [PATCH 02/14] Add 3 sensors for SOH, SOC, and Odometer --- custom_components/leafspy/sensor.py | 176 ++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 custom_components/leafspy/sensor.py diff --git a/custom_components/leafspy/sensor.py b/custom_components/leafspy/sensor.py new file mode 100644 index 0000000..8784fbd --- /dev/null +++ b/custom_components/leafspy/sensor.py @@ -0,0 +1,176 @@ +"""Sensor platform that adds support for Leaf Spy.""" +import logging +from homeassistant.components.sensor import SensorEntity, SensorStateClass, SensorDeviceClass +from homeassistant.const import PERCENTAGE, UnitOfLength +from homeassistant.util import slugify +from .const import DOMAIN as LS_DOMAIN +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Leaf Spy sensors based on a config entry.""" + if 'sensors' not in hass.data[LS_DOMAIN]: + hass.data[LS_DOMAIN]['sensors'] = {} + + async def _process_message(context, message): + """Process the message.""" + try: + if 'VIN' not in message: + return + + dev_id = slugify(f'leaf_{message["VIN"]}') + + # Battery Health (SOH) Sensor + if 'SOH' in message: + soh_entity = hass.data[LS_DOMAIN]['sensors'].get(f"{dev_id}_soh") + if soh_entity is not None: + soh_entity.update_state(float(message['SOH'])) + else: + soh_entity = LeafSpyBatteryHealthSensor(dev_id, float(message['SOH'])) + hass.data[LS_DOMAIN]['sensors'][f"{dev_id}_soh"] = soh_entity + async_add_entities([soh_entity]) + + # Battery Level (SOC) Sensor + if 'SOC' in message: + soc_entity = hass.data[LS_DOMAIN]['sensors'].get(f"{dev_id}_soc") + if soc_entity is not None: + soc_entity.update_state(round(float(message['SOC']), 1)) + else: + soc_entity = LeafSpyBatteryLevelSensor(dev_id, round(float(message['SOC']), 1)) + hass.data[LS_DOMAIN]['sensors'][f"{dev_id}_soc"] = soc_entity + async_add_entities([soc_entity]) + + # Mileage Sensor + if 'Odo' in message: + mileage_entity = hass.data[LS_DOMAIN]['sensors'].get(f"{dev_id}_mileage") + if mileage_entity is not None: + mileage_entity.update_state(int(float(message['Odo']))) + else: + mileage_entity = LeafSpyMileageSensor(dev_id, int(float(message['Odo']))) + hass.data[LS_DOMAIN]['sensors'][f"{dev_id}_mileage"] = mileage_entity + async_add_entities([mileage_entity]) + + except Exception as err: + _LOGGER.error("Error processing message for Leaf Spy sensors: %s", err) + + async_dispatcher_connect(hass, LS_DOMAIN, _process_message) + return True + + +class LeafSpyBatteryHealthSensor(SensorEntity): + """Representation of the Battery Health (SOH) sensor.""" + + def __init__(self, device_id, soh): + """Initialize the sensor.""" + self._device_id = device_id + self._soh = soh + self._attr_icon = "mdi:battery-heart" + self._attr_state_class = SensorStateClass.MEASUREMENT + self._attr_native_unit_of_measurement = PERCENTAGE + + @property + def unique_id(self): + """Return a unique ID for the sensor.""" + return f"{self._device_id}_battery_health" + + @property + def name(self): + """Return the name of the sensor.""" + return "Leaf battery health (SOH)" + + @property + def native_value(self): + """Return the state of the sensor.""" + return self._soh + + @property + def device_info(self): + """Return device information.""" + return { + "identifiers": {(LS_DOMAIN, self._device_id)}, + } + + def update_state(self, new_soh): + """Update the sensor state.""" + self._soh = new_soh + self.async_write_ha_state() + + +class LeafSpyBatteryLevelSensor(SensorEntity): + """Representation of the Battery Level (SOC) sensor.""" + + def __init__(self, device_id, soc): + """Initialize the sensor.""" + self._device_id = device_id + self._soc = soc + self._attr_device_class = SensorDeviceClass.BATTERY + self._attr_state_class = SensorStateClass.MEASUREMENT + self._attr_native_unit_of_measurement = PERCENTAGE + + @property + def unique_id(self): + """Return a unique ID for the sensor.""" + return f"{self._device_id}_battery_level" + + @property + def name(self): + """Return the name of the sensor.""" + return "Leaf battery level (SOC)" + + @property + def native_value(self): + """Return the state of the sensor.""" + return self._soc + + @property + def device_info(self): + """Return device information.""" + return { + "identifiers": {(LS_DOMAIN, self._device_id)}, + } + + def update_state(self, new_soc): + """Update the sensor state.""" + self._soc = new_soc + self.async_write_ha_state() + + +class LeafSpyMileageSensor(SensorEntity): + """Representation of the Mileage sensor.""" + + def __init__(self, device_id, mileage): + """Initialize the sensor.""" + self._device_id = device_id + self._mileage = mileage + self._attr_device_class = SensorDeviceClass.DISTANCE + self._attr_state_class = SensorStateClass.TOTAL_INCREASING + self._attr_native_unit_of_measurement = UnitOfLength.KILOMETERS + self._attr_icon = "mdi:counter" + + @property + def unique_id(self): + """Return a unique ID for the sensor.""" + return f"{self._device_id}_mileage" + + @property + def name(self): + """Return the name of the sensor.""" + return "Leaf mileage" + + @property + def native_value(self): + """Return the state of the sensor.""" + return self._mileage + + @property + def device_info(self): + """Return device information.""" + return { + "identifiers": {(LS_DOMAIN, self._device_id)}, + } + + def update_state(self, new_mileage): + """Update the sensor state.""" + self._mileage = new_mileage + self.async_write_ha_state() From 95c7af2028c63574bef54e38e1c228e847f1e169 Mon Sep 17 00:00:00 2001 From: Chaoscontrol Date: Sat, 30 Nov 2024 20:04:58 +0000 Subject: [PATCH 03/14] Remove all attributes from device_tracker --- custom_components/leafspy/device_tracker.py | 27 +-------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/custom_components/leafspy/device_tracker.py b/custom_components/leafspy/device_tracker.py index 2216578..535b200 100755 --- a/custom_components/leafspy/device_tracker.py +++ b/custom_components/leafspy/device_tracker.py @@ -90,11 +90,6 @@ def battery_level(self): """Return the battery level of the car.""" return self._data.get('battery_level') - @property - def extra_state_attributes(self): - """Return extra attributes.""" - return self._data.get('attributes') - @property def latitude(self): """Return latitude value of the car.""" @@ -147,8 +142,6 @@ async def async_added_to_hass(self): 'latitude': attr.get(ATTR_LATITUDE), 'longitude': attr.get(ATTR_LONGITUDE), 'battery_level': attr.get(ATTR_BATTERY_LEVEL), - 'attributes': attr - } @callback @@ -166,25 +159,7 @@ def _parse_see_args(message): 'device_name': message['user'], 'latitude': float(message['Lat']), 'longitude': float(message['Long']), - 'battery_level': float(message['SOC']), - 'attributes': { - 'amp_hours': float(message['AHr']), - 'trip': int(message['Trip']), - 'odometer': float(message['Odo']), - 'battery_temperature': float(message['BatTemp']), - 'battery_health': float(message['SOH']), - 'outside_temperature': float(message['Amb']), - 'plug_state': PLUG_STATES[int(message['PlugState'])], - 'charge_mode': CHARGE_MODES[int(message['ChrgMode'])], - 'charge_power': int(message['ChrgPwr']), - 'vin': message['VIN'], - 'power_switch': message['PwrSw'] == '1', - 'device_battery': int(message['DevBat']), - 'rpm': int(message['RPM']), - 'gids': int(message['Gids']), - 'elevation': float(message['Elv']), - 'sequence': int(message['Seq']) - } + 'battery_level': float(message['SOC']) } return args From f52b1becc0100fb61b9436aaf46f73be32be0584 Mon Sep 17 00:00:00 2001 From: Chaoscontrol Date: Sat, 30 Nov 2024 20:05:43 +0000 Subject: [PATCH 04/14] Remove redundant code from old extra attributes --- custom_components/leafspy/device_tracker.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/custom_components/leafspy/device_tracker.py b/custom_components/leafspy/device_tracker.py index 535b200..7a13b13 100755 --- a/custom_components/leafspy/device_tracker.py +++ b/custom_components/leafspy/device_tracker.py @@ -18,20 +18,6 @@ _LOGGER = logging.getLogger(__name__) -PLUG_STATES = [ - "Not Plugged In", - "Partially Plugged In", - "Plugged In" -] - -CHARGE_MODES = [ - "Not Charging", - "Level 1 Charging (100-120 Volts)", - "Level 2 Charging (200-240 Volts)", - "Level 3 Quick Charging" -] - - async def async_setup_entry(hass, entry, async_add_entities): """Set up Leaf Spy based off an entry.""" async def _receive_data(dev_id, **data): From a5188af21e127b792371a38f7dc42dd901924ab1 Mon Sep 17 00:00:00 2001 From: Chaoscontrol Date: Sat, 30 Nov 2024 20:06:39 +0000 Subject: [PATCH 05/14] Added all existing data into independent sensors. Optimize code for easier sensor management. --- custom_components/leafspy/sensor.py | 437 +++++++++++++++++++--------- 1 file changed, 305 insertions(+), 132 deletions(-) diff --git a/custom_components/leafspy/sensor.py b/custom_components/leafspy/sensor.py index 8784fbd..a4e44eb 100644 --- a/custom_components/leafspy/sensor.py +++ b/custom_components/leafspy/sensor.py @@ -1,176 +1,349 @@ """Sensor platform that adds support for Leaf Spy.""" import logging -from homeassistant.components.sensor import SensorEntity, SensorStateClass, SensorDeviceClass -from homeassistant.const import PERCENTAGE, UnitOfLength -from homeassistant.util import slugify -from .const import DOMAIN as LS_DOMAIN +from dataclasses import dataclass, field +from typing import Any, Callable + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfLength, + UnitOfPower, + UnitOfSpeed, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import slugify + +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, entry, async_add_entities): +@dataclass(frozen=True) +class LeafSpySensorDescription(SensorEntityDescription): + """Describes Leaf Spy sensor.""" + value_fn: Callable[[dict], Any] = field(default=lambda data: None) + transform_fn: Callable[[Any], Any] = field(default=lambda x: x) + +SENSOR_TYPES = [ + LeafSpySensorDescription( + key="phone battery", + translation_key="device_battery", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.get("DevBat"), + ), + LeafSpySensorDescription( + key="Gids", + translation_key="gids", + native_unit_of_measurement="Gids", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.get("Gids"), + icon="mdi:battery", + ), + LeafSpySensorDescription( + key="elevation", + translation_key="elevation", + native_unit_of_measurement=UnitOfLength.METERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.get("Elv"), + icon="mdi:elevation-rise", + ), + LeafSpySensorDescription( + key="sequence", + translation_key="sequence_number", + value_fn=lambda data: data.get("Seq"), + icon="mdi:numeric", + ), + LeafSpySensorDescription( + key="trip number", + translation_key="trip_number", + value_fn=lambda data: data.get("Trip"), + icon="mdi:road-variant", + ), + LeafSpySensorDescription( + key="mileage", + translation_key="odometer", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.get("Odo"), + transform_fn=lambda x: int(float(x)) if x is not None else None, + icon="mdi:counter", + ), + LeafSpySensorDescription( + key="battery level (SOC)", + translation_key="state_of_charge", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.get("SOC"), + transform_fn=lambda x: float(x) if x is not None else None, + ), + LeafSpySensorDescription( + key="capacity (AHr)", + translation_key="AHr_capacity", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.get("AHr"), + transform_fn=lambda x: float(x) if x is not None else None, + icon="mdi:battery-heart-variant", + ), + LeafSpySensorDescription( + key="battery temperature", + translation_key="battery_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.get("BatTemp"), + transform_fn=lambda x: float(x) if x is not None else None, + icon="mdi:thermometer", + ), + LeafSpySensorDescription( + key="ambient temperature", + translation_key="ambient_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.get("Amb"), + transform_fn=lambda x: float(x) if x is not None else None, + icon="mdi:sun-thermometer", + ), + LeafSpySensorDescription( + key="charge power", + translation_key="charge_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.get("ChrgPwr"), + icon="mdi:lightning-bolt", + ), + LeafSpySensorDescription( + key="front wiper", + translation_key="front_wiper_status", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda data: { + 80: "High", + 40: "Low", + 20: "Switch", + 10: "Intermittent", + 8: "Stopped" + }.get(int(data.get("Wpr")), "unknown"), + icon="mdi:wiper", + options=[ + "High", + "Low", + "Switch", + "Intermittent", + "Stopped", + "unknown" + ] + ), + LeafSpySensorDescription( + key="plug state", + translation_key="plug_state", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda data: { + 0: "Not plugged", + 1: "Partial plugged", + 2: "Plugged" + }.get(int(data.get("PlugState")), "unknown"), + icon="mdi:power-plug", + options=[ + "Not plugged", + "Partial plugged", + "Plugged", + "unknown" + ] + ), + LeafSpySensorDescription( + key="charge mode", + translation_key="charge_mode", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda data: { + 0: "Not charging", + 1: "Level 1 charging", + 2: "Level 2 charging", + 3: "Level 3 quick charging" + }.get(int(data.get("ChrgMode")), "unknown"), + icon="mdi:battery-charging-wireless", + options=[ + "Not charging", + "Level 1 charging", + "Level 2 charging", + "Level 3 quick charging", + "unknown" + ] + ), + LeafSpySensorDescription( + key="VIN", + translation_key="vehicle_identification_number", + value_fn=lambda data: data.get("VIN"), + icon="mdi:identifier", + ), + LeafSpySensorDescription( + key="power switch", + translation_key="power_switch_state", + value_fn=lambda data: data.get("PwrSw"), + icon="mdi:power", + ), + LeafSpySensorDescription( + key="temperature units", + translation_key="temperature_units", + value_fn=lambda data: data.get("Tunits"), + icon="mdi:temperature-celsius", + ), + LeafSpySensorDescription( + key="motor rpm", + translation_key="motor_rpm", + native_unit_of_measurement="RPM", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.get("RPM"), + icon="mdi:engine", + ), + LeafSpySensorDescription( + key="battery health (SOH)", + translation_key="state_of_health", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.get("SOH"), + transform_fn=lambda x: float(x) if x is not None else None, + icon="mdi:battery-heart-variant", + ), + LeafSpySensorDescription( + key="Hx", + translation_key="hx_value", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.get("Hx"), + icon="mdi:battery-heart-variant", + ), + LeafSpySensorDescription( + key="speed", + translation_key="vehicle_speed", + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, + device_class=SensorDeviceClass.SPEED, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.get("Speed"), + ), + LeafSpySensorDescription( + key="battery voltage", + translation_key="battery_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.get("BatVolts"), + ), + LeafSpySensorDescription( + key="battery current", + translation_key="battery_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.get("BatAmps"), + ), +] + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None +) -> bool: """Set up Leaf Spy sensors based on a config entry.""" - if 'sensors' not in hass.data[LS_DOMAIN]: - hass.data[LS_DOMAIN]['sensors'] = {} + if 'sensors' not in hass.data[DOMAIN]: + hass.data[DOMAIN]['sensors'] = {} async def _process_message(context, message): - """Process the message.""" + """Process incoming sensor messages.""" try: + _LOGGER.debug("Incoming message: %s", message) if 'VIN' not in message: return dev_id = slugify(f'leaf_{message["VIN"]}') - # Battery Health (SOH) Sensor - if 'SOH' in message: - soh_entity = hass.data[LS_DOMAIN]['sensors'].get(f"{dev_id}_soh") - if soh_entity is not None: - soh_entity.update_state(float(message['SOH'])) - else: - soh_entity = LeafSpyBatteryHealthSensor(dev_id, float(message['SOH'])) - hass.data[LS_DOMAIN]['sensors'][f"{dev_id}_soh"] = soh_entity - async_add_entities([soh_entity]) - - # Battery Level (SOC) Sensor - if 'SOC' in message: - soc_entity = hass.data[LS_DOMAIN]['sensors'].get(f"{dev_id}_soc") - if soc_entity is not None: - soc_entity.update_state(round(float(message['SOC']), 1)) - else: - soc_entity = LeafSpyBatteryLevelSensor(dev_id, round(float(message['SOC']), 1)) - hass.data[LS_DOMAIN]['sensors'][f"{dev_id}_soc"] = soc_entity - async_add_entities([soc_entity]) - - # Mileage Sensor - if 'Odo' in message: - mileage_entity = hass.data[LS_DOMAIN]['sensors'].get(f"{dev_id}_mileage") - if mileage_entity is not None: - mileage_entity.update_state(int(float(message['Odo']))) - else: - mileage_entity = LeafSpyMileageSensor(dev_id, int(float(message['Odo']))) - hass.data[LS_DOMAIN]['sensors'][f"{dev_id}_mileage"] = mileage_entity - async_add_entities([mileage_entity]) - - except Exception as err: - _LOGGER.error("Error processing message for Leaf Spy sensors: %s", err) - - async_dispatcher_connect(hass, LS_DOMAIN, _process_message) - return True - - -class LeafSpyBatteryHealthSensor(SensorEntity): - """Representation of the Battery Health (SOH) sensor.""" - - def __init__(self, device_id, soh): - """Initialize the sensor.""" - self._device_id = device_id - self._soh = soh - self._attr_icon = "mdi:battery-heart" - self._attr_state_class = SensorStateClass.MEASUREMENT - self._attr_native_unit_of_measurement = PERCENTAGE - - @property - def unique_id(self): - """Return a unique ID for the sensor.""" - return f"{self._device_id}_battery_health" + # Create and update sensors for each description + for description in SENSOR_TYPES: + sensor_id = f"{dev_id}_{description.key}" + value = description.value_fn(message) + _LOGGER.debug("Sensor '%s': Raw data=%s, Parsed value=%s", description.key, message, value) - @property - def name(self): - """Return the name of the sensor.""" - return "Leaf battery health (SOH)" + value = description.transform_fn(value) + _LOGGER.debug("Sensor '%s': Transformed value=%s", description.key, value) - @property - def native_value(self): - """Return the state of the sensor.""" - return self._soh + if value is not None: + sensor = hass.data[DOMAIN]['sensors'].get(sensor_id) + if sensor is not None: + sensor.update_state(value) + else: + sensor = LeafSpySensor(dev_id, description, value) + hass.data[DOMAIN]['sensors'][sensor_id] = sensor + async_add_entities([sensor]) - @property - def device_info(self): - """Return device information.""" - return { - "identifiers": {(LS_DOMAIN, self._device_id)}, - } + except Exception as err: + _LOGGER.error("Error processing Leaf Spy message: %s", err) - def update_state(self, new_soh): - """Update the sensor state.""" - self._soh = new_soh - self.async_write_ha_state() + async_dispatcher_connect(hass, DOMAIN, _process_message) + return True -class LeafSpyBatteryLevelSensor(SensorEntity): - """Representation of the Battery Level (SOC) sensor.""" +class LeafSpySensor(SensorEntity, RestoreEntity): + """Representation of a Leaf Spy sensor.""" - def __init__(self, device_id, soc): + def __init__(self, device_id: str, description: LeafSpySensorDescription, initial_value): """Initialize the sensor.""" self._device_id = device_id - self._soc = soc - self._attr_device_class = SensorDeviceClass.BATTERY - self._attr_state_class = SensorStateClass.MEASUREMENT - self._attr_native_unit_of_measurement = PERCENTAGE + self._value = initial_value + self.entity_description = description @property def unique_id(self): - """Return a unique ID for the sensor.""" - return f"{self._device_id}_battery_level" + """Return a unique ID.""" + return f"{self._device_id}_{self.entity_description.key}" @property def name(self): """Return the name of the sensor.""" - return "Leaf battery level (SOC)" + return f"Leaf {self.entity_description.key}" @property def native_value(self): - """Return the state of the sensor.""" - return self._soc + """Return the sensor's value.""" + return self._value @property def device_info(self): """Return device information.""" return { - "identifiers": {(LS_DOMAIN, self._device_id)}, + "identifiers": {(DOMAIN, self._device_id)}, } - def update_state(self, new_soc): + def update_state(self, new_value): """Update the sensor state.""" - self._soc = new_soc + self._value = new_value self.async_write_ha_state() - -class LeafSpyMileageSensor(SensorEntity): - """Representation of the Mileage sensor.""" - - def __init__(self, device_id, mileage): - """Initialize the sensor.""" - self._device_id = device_id - self._mileage = mileage - self._attr_device_class = SensorDeviceClass.DISTANCE - self._attr_state_class = SensorStateClass.TOTAL_INCREASING - self._attr_native_unit_of_measurement = UnitOfLength.KILOMETERS - self._attr_icon = "mdi:counter" - - @property - def unique_id(self): - """Return a unique ID for the sensor.""" - return f"{self._device_id}_mileage" - - @property - def name(self): - """Return the name of the sensor.""" - return "Leaf mileage" - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._mileage - - @property - def device_info(self): - """Return device information.""" - return { - "identifiers": {(LS_DOMAIN, self._device_id)}, - } - - def update_state(self, new_mileage): - """Update the sensor state.""" - self._mileage = new_mileage - self.async_write_ha_state() + async def async_added_to_hass(self): + """Restore last known state.""" + await super().async_added_to_hass() + + last_state = await self.async_get_last_state() + if last_state: + try: + transform_fn = self.entity_description.transform_fn + self._value = transform_fn(last_state.state) + except (ValueError, TypeError): + _LOGGER.warning(f"Could not restore state for {self.name}") From c35520704342fee9c930ba073ab80bcd6399df0b Mon Sep 17 00:00:00 2001 From: Chaoscontrol Date: Sat, 30 Nov 2024 20:24:17 +0000 Subject: [PATCH 06/14] Update README.md --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6351f2a..015ba60 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ _Component to integrate with [leafspy][leafspy]._ Platform | Description -- | -- -`device_tracker` | Track a Nissan Leaf using the Leaf Spy app. Data about the vehicle sent from Leaf Spy will be viewable under Attributes +`device_tracker` | Track a Nissan Leaf using the Leaf Spy app. +`sensor` | All data about the vehicle sent from Leaf Spy is displayed as independent sensors. ![leafspy][leafspyimg] @@ -40,6 +41,7 @@ custom_components/leafspy/__init__.py custom_components/leafspy/config_flow.py custom_components/leafspy/const.py custom_components/leafspy/device_tracker.py +custom_components/leafspy/sensor.py custom_components/leafspy/manifest.json custom_components/leafspy/strings.json ``` From 502edea41ed4407e7dcfff388be0eb98ccb1ca4c Mon Sep 17 00:00:00 2001 From: Chaoscontrol Date: Sat, 30 Nov 2024 20:25:03 +0000 Subject: [PATCH 07/14] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 015ba60..502385d 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ _Component to integrate with [leafspy][leafspy]._ Platform | Description -- | -- `device_tracker` | Track a Nissan Leaf using the Leaf Spy app. -`sensor` | All data about the vehicle sent from Leaf Spy is displayed as independent sensors. +`sensor` | All data about the vehicle sent from Leaf Spy is displayed as independent sensors. They won't appear in Home Assistant until data is received. ![leafspy][leafspyimg] From 2969f27fd308c405ac02fe6ddbd580a561af4341 Mon Sep 17 00:00:00 2001 From: Chaoscontrol Date: Sat, 30 Nov 2024 20:37:21 +0000 Subject: [PATCH 08/14] Adjusted precision for elevation and SOC sensors --- custom_components/leafspy/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/custom_components/leafspy/sensor.py b/custom_components/leafspy/sensor.py index a4e44eb..a095d8b 100644 --- a/custom_components/leafspy/sensor.py +++ b/custom_components/leafspy/sensor.py @@ -60,6 +60,7 @@ class LeafSpySensorDescription(SensorEntityDescription): device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.get("Elv"), + transform_fn=lambda x: int(round(float(x), 0)) if x is not None else None, icon="mdi:elevation-rise", ), LeafSpySensorDescription( @@ -91,6 +92,7 @@ class LeafSpySensorDescription(SensorEntityDescription): device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.get("SOC"), + transform_fn=lambda x: round(float(x), 2) if x is not None else None, transform_fn=lambda x: float(x) if x is not None else None, ), LeafSpySensorDescription( From cc60800bbc9de325f48e358549c44604c8b76dee Mon Sep 17 00:00:00 2001 From: Chaoscontrol Date: Sat, 30 Nov 2024 20:40:37 +0000 Subject: [PATCH 09/14] Fix double function on SOC precision --- custom_components/leafspy/sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/custom_components/leafspy/sensor.py b/custom_components/leafspy/sensor.py index a095d8b..c373be2 100644 --- a/custom_components/leafspy/sensor.py +++ b/custom_components/leafspy/sensor.py @@ -93,7 +93,6 @@ class LeafSpySensorDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.get("SOC"), transform_fn=lambda x: round(float(x), 2) if x is not None else None, - transform_fn=lambda x: float(x) if x is not None else None, ), LeafSpySensorDescription( key="capacity (AHr)", From 48fb620f46cc70ae56a6df582f275993317513df Mon Sep 17 00:00:00 2001 From: Chaoscontrol Date: Sun, 1 Dec 2024 10:29:11 +0000 Subject: [PATCH 10/14] Remove Power Switch as sensor, adjust temperature units based on Leafspy selected units --- custom_components/leafspy/sensor.py | 35 ++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/custom_components/leafspy/sensor.py b/custom_components/leafspy/sensor.py index c373be2..ed1b9f5 100644 --- a/custom_components/leafspy/sensor.py +++ b/custom_components/leafspy/sensor.py @@ -1,6 +1,6 @@ """Sensor platform that adds support for Leaf Spy.""" import logging -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from typing import Any, Callable from homeassistant.components.sensor import ( @@ -35,6 +35,14 @@ class LeafSpySensorDescription(SensorEntityDescription): """Describes Leaf Spy sensor.""" value_fn: Callable[[dict], Any] = field(default=lambda data: None) transform_fn: Callable[[Any], Any] = field(default=lambda x: x) + unit_fn: Callable[[dict], str] = field(default=lambda data: None) + +def _get_temperature_unit(data): + """Determine the temperature unit based on Tunits.""" + tunits = data.get("Tunits", "").lower() + if tunits == "f": + return UnitOfTemperature.FAHRENHEIT + return UnitOfTemperature.CELSIUS SENSOR_TYPES = [ LeafSpySensorDescription( @@ -92,7 +100,7 @@ class LeafSpySensorDescription(SensorEntityDescription): device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.get("SOC"), - transform_fn=lambda x: round(float(x), 2) if x is not None else None, + transform_fn=lambda x: int(round(float(x), 0)) if x is not None else None, ), LeafSpySensorDescription( key="capacity (AHr)", @@ -110,6 +118,7 @@ class LeafSpySensorDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.get("BatTemp"), transform_fn=lambda x: float(x) if x is not None else None, + unit_fn=_get_temperature_unit, icon="mdi:thermometer", ), LeafSpySensorDescription( @@ -120,6 +129,7 @@ class LeafSpySensorDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.get("Amb"), transform_fn=lambda x: float(x) if x is not None else None, + unit_fn=_get_temperature_unit, icon="mdi:sun-thermometer", ), LeafSpySensorDescription( @@ -194,12 +204,6 @@ class LeafSpySensorDescription(SensorEntityDescription): value_fn=lambda data: data.get("VIN"), icon="mdi:identifier", ), - LeafSpySensorDescription( - key="power switch", - translation_key="power_switch_state", - value_fn=lambda data: data.get("PwrSw"), - icon="mdi:power", - ), LeafSpySensorDescription( key="temperature units", translation_key="temperature_units", @@ -270,7 +274,6 @@ async def async_setup_entry( async def _process_message(context, message): """Process incoming sensor messages.""" try: - _LOGGER.debug("Incoming message: %s", message) if 'VIN' not in message: return @@ -287,15 +290,27 @@ async def _process_message(context, message): if value is not None: sensor = hass.data[DOMAIN]['sensors'].get(sensor_id) + + # Dynamically update temperature unit if applicable + sensor_description = description + if description.unit_fn: + unit = description.unit_fn(message) + if unit: + sensor_description = replace(description, + native_unit_of_measurement=unit) + if sensor is not None: + # Update with potentially new unit + sensor.entity_description = sensor_description sensor.update_state(value) else: - sensor = LeafSpySensor(dev_id, description, value) + sensor = LeafSpySensor(dev_id, sensor_description, value) hass.data[DOMAIN]['sensors'][sensor_id] = sensor async_add_entities([sensor]) except Exception as err: _LOGGER.error("Error processing Leaf Spy message: %s", err) + _LOGGER.exception("Full traceback") async_dispatcher_connect(hass, DOMAIN, _process_message) return True From df73107e954050196412d5be2e4f001fb69cb749 Mon Sep 17 00:00:00 2001 From: Chaoscontrol Date: Sun, 1 Dec 2024 10:29:38 +0000 Subject: [PATCH 11/14] Add support for binary_sensor --- custom_components/leafspy/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/custom_components/leafspy/__init__.py b/custom_components/leafspy/__init__.py index f514925..b76a732 100755 --- a/custom_components/leafspy/__init__.py +++ b/custom_components/leafspy/__init__.py @@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["device_tracker", "sensor"] +PLATFORMS = ["device_tracker", "sensor", "binary_sensor"] # Use empty_config_schema because the component does not have any config options CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -28,6 +28,7 @@ async def async_setup(hass, config): """Initialize Leaf Spy component.""" hass.data[DOMAIN] = { 'devices': {}, + 'sensors': {}, 'unsub': None, } return True @@ -44,7 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): hass.http.register_view(LeafSpyView()) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - + hass.data[DOMAIN]['unsub'] = \ async_dispatcher_connect(hass, DOMAIN, async_handle_message) From 0132047d4eaade11d21b9e9ec8d9f7d29ff0a88f Mon Sep 17 00:00:00 2001 From: Chaoscontrol Date: Sun, 1 Dec 2024 10:30:19 +0000 Subject: [PATCH 12/14] New binary_sensor for Power Switch --- custom_components/leafspy/binary_sensor.py | 129 +++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 custom_components/leafspy/binary_sensor.py diff --git a/custom_components/leafspy/binary_sensor.py b/custom_components/leafspy/binary_sensor.py new file mode 100644 index 0000000..9ef6de8 --- /dev/null +++ b/custom_components/leafspy/binary_sensor.py @@ -0,0 +1,129 @@ +"""Binary sensor platform that adds support for Leaf Spy.""" +import logging +from dataclasses import dataclass, field +from typing import Any, Callable + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import slugify + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +@dataclass(frozen=True) +class LeafSpyBinarySensorDescription(BinarySensorEntityDescription): + """Describes Leaf Spy binary sensor.""" + value_fn: Callable[[dict], Any] = field(default=lambda data: None) + transform_fn: Callable[[Any], bool] = field(default=lambda x: bool(x)) + +BINARY_SENSOR_TYPES = [ + LeafSpyBinarySensorDescription( + key="power switch", + translation_key="power_switch_state", + device_class=BinarySensorDeviceClass.POWER, + value_fn=lambda data: data.get("PwrSw"), + transform_fn=lambda x: x == 1, + icon="mdi:power", + ) +] + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None +) -> bool: + """Set up Leaf Spy binary sensors based on a config entry.""" + if 'binary_sensors' not in hass.data[DOMAIN]: + hass.data[DOMAIN]['binary_sensors'] = {} + + async def _process_message(context, message): + """Process incoming sensor messages.""" + try: + _LOGGER.debug("Incoming message: %s", message) + if 'VIN' not in message: + return + + dev_id = slugify(f'leaf_{message["VIN"]}') + + # Create and update binary sensors for each description + for description in BINARY_SENSOR_TYPES: + sensor_id = f"{dev_id}_{description.key}" + value = description.value_fn(message) + _LOGGER.debug("Binary Sensor '%s': Raw data=%s, Parsed value=%s", description.key, message, value) + + value = description.transform_fn(value) + _LOGGER.debug("Binary Sensor '%s': Transformed value=%s", description.key, value) + + if value is not None: + sensor = hass.data[DOMAIN]['binary_sensors'].get(sensor_id) + if sensor is not None: + sensor.update_state(value) + else: + sensor = LeafSpyBinarySensor(dev_id, description, value) + hass.data[DOMAIN]['binary_sensors'][sensor_id] = sensor + async_add_entities([sensor]) + + except Exception as err: + _LOGGER.error("Error processing Leaf Spy message: %s", err) + + async_dispatcher_connect(hass, DOMAIN, _process_message) + return True + + +class LeafSpyBinarySensor(BinarySensorEntity, RestoreEntity): + """Representation of a Leaf Spy binary sensor.""" + + def __init__(self, device_id: str, description: LeafSpyBinarySensorDescription, initial_value): + """Initialize the binary sensor.""" + self._device_id = device_id + self._value = initial_value + self.entity_description = description + + @property + def unique_id(self): + """Return a unique ID.""" + return f"{self._device_id}_{self.entity_description.key}" + + @property + def name(self): + """Return the name of the binary sensor.""" + return f"Leaf {self.entity_description.key}" + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._value + + @property + def device_info(self): + """Return device information.""" + return { + "identifiers": {(DOMAIN, self._device_id)}, + } + + def update_state(self, new_value): + """Update the binary sensor state.""" + self._value = new_value + self.async_write_ha_state() + + async def async_added_to_hass(self): + """Restore last known state.""" + await super().async_added_to_hass() + + last_state = await self.async_get_last_state() + if last_state: + try: + transform_fn = self.entity_description.transform_fn + self._value = transform_fn(last_state.state) + except (ValueError, TypeError): + _LOGGER.warning(f"Could not restore state for {self.name}") From 5c91da5666d4ed1c3d0ac0611f1822abc41e0529 Mon Sep 17 00:00:00 2001 From: Chaoscontrol Date: Sun, 1 Dec 2024 10:43:36 +0000 Subject: [PATCH 13/14] Binary sensor platform added --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 502385d..1f1b413 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Platform | Description -- | -- `device_tracker` | Track a Nissan Leaf using the Leaf Spy app. `sensor` | All data about the vehicle sent from Leaf Spy is displayed as independent sensors. They won't appear in Home Assistant until data is received. +`binary_sensor` | Power switch sensor. ![leafspy][leafspyimg] From 712dc67f72ad57435f9dc49d000a13e6f0eb7e85 Mon Sep 17 00:00:00 2001 From: Chaoscontrol Date: Sun, 1 Dec 2024 10:45:03 +0000 Subject: [PATCH 14/14] Also added the new binary sensor file to the folder structure --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1f1b413..f07b7cb 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ custom_components/leafspy/config_flow.py custom_components/leafspy/const.py custom_components/leafspy/device_tracker.py custom_components/leafspy/sensor.py +custom_components/leafspy/binary_sensor.py custom_components/leafspy/manifest.json custom_components/leafspy/strings.json ```