diff --git a/README.md b/README.md index 6351f2a..f07b7cb 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,9 @@ _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. They won't appear in Home Assistant until data is received. +`binary_sensor` | Power switch sensor. ![leafspy][leafspyimg] @@ -40,6 +42,8 @@ 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/binary_sensor.py custom_components/leafspy/manifest.json custom_components/leafspy/strings.json ``` diff --git a/custom_components/leafspy/__init__.py b/custom_components/leafspy/__init__.py index d60eb2e..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"] +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) 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}") diff --git a/custom_components/leafspy/device_tracker.py b/custom_components/leafspy/device_tracker.py index 2216578..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): @@ -90,11 +76,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 +128,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 +145,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 diff --git a/custom_components/leafspy/sensor.py b/custom_components/leafspy/sensor.py new file mode 100644 index 0000000..ed1b9f5 --- /dev/null +++ b/custom_components/leafspy/sensor.py @@ -0,0 +1,365 @@ +"""Sensor platform that adds support for Leaf Spy.""" +import logging +from dataclasses import dataclass, field, replace +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__) + +@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) + 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( + 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"), + transform_fn=lambda x: int(round(float(x), 0)) if x is not None else None, + 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: int(round(float(x), 0)) 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, + unit_fn=_get_temperature_unit, + 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, + unit_fn=_get_temperature_unit, + 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="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[DOMAIN]: + hass.data[DOMAIN]['sensors'] = {} + + async def _process_message(context, message): + """Process incoming sensor messages.""" + try: + if 'VIN' not in message: + return + + dev_id = slugify(f'leaf_{message["VIN"]}') + + # 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) + + value = description.transform_fn(value) + _LOGGER.debug("Sensor '%s': Transformed value=%s", description.key, value) + + 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, 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 + + +class LeafSpySensor(SensorEntity, RestoreEntity): + """Representation of a Leaf Spy sensor.""" + + def __init__(self, device_id: str, description: LeafSpySensorDescription, initial_value): + """Initialize the 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 sensor.""" + return f"Leaf {self.entity_description.key}" + + @property + def native_value(self): + """Return the sensor's value.""" + 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 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}")