From 47b9feeaedac1047663b82c46882d3beac1a24f6 Mon Sep 17 00:00:00 2001 From: Joakim Olsson Date: Wed, 23 Jun 2021 21:23:22 +0200 Subject: [PATCH] feat: add sensor for last command and status --- custom_components/ferroamp/sensor.py | 168 +++++++++++++++++++++++++-- tests/test_sensor.py | 136 ++++++++++++++++++++++ 2 files changed, 292 insertions(+), 12 deletions(-) diff --git a/custom_components/ferroamp/sensor.py b/custom_components/ferroamp/sensor.py index 6f0bec6..9dda6a4 100644 --- a/custom_components/ferroamp/sensor.py +++ b/custom_components/ferroamp/sensor.py @@ -41,6 +41,9 @@ SSO_TOPIC = "data/sso" ESO_TOPIC = "data/eso" ESM_TOPIC = "data/esm" +CONTROL_REQUEST_TOPIC = "control/request" +CONTROL_RESPONSE_TOPIC = "control/response" +CONTROL_RESULT_TOPIC = "control/result" EHUB = "ehub" EHUB_NAME = "EnergyHub" @@ -100,6 +103,15 @@ async def async_setup_entry( eso_sensors = {} esm_sensors = {} sso_sensors = {} + cmd_sensor = {} + + def get_store(store_name): + store = config.get(store_name) + new = False + if store is None: + store = config[store_name] = {} + new = True + return store, new def update_sensor_from_event(event, sensors, store): for sensor in sensors: @@ -116,9 +128,7 @@ def update_sensor_from_event(event, sensors, store): @callback def ehub_event_received(msg): event = json.loads(msg.payload) - store = config.get(f"{slug}_{EHUB}") - if store is None: - store = config[f"{slug}_{EHUB}"] = {} + store, new = get_store(f"{slug}_{EHUB}") update_sensor_from_event(event, ehub, store) @callback @@ -127,10 +137,9 @@ def sso_event_received(msg): sso_id = event["id"]["val"] device_id = f"{slug}_sso_{sso_id}" device_name = f"{name} SSO {sso_id}" - store = config.get(device_id) + store, new = get_store(device_id) sensors = sso_sensors.get(sso_id) - if store is None: - store = config[device_id] = {} + if new: sensors = sso_sensors[sso_id] = [ VoltageFerroampSensor( f"{device_name} PV String Voltage", @@ -211,10 +220,9 @@ def eso_event_received(msg): return device_id = f"{slug}_eso_{eso_id}" device_name = f"{name} ESO {eso_id}" - store = config.get(device_id) + store, new = get_store(device_id) sensors = eso_sensors.get(eso_id) - if store is None: - store = config[device_id] = {} + if new: sensors = eso_sensors[eso_id] = [ VoltageFerroampSensor( f"{device_name} Battery Voltage", @@ -312,10 +320,9 @@ def esm_event_received(msg): esm_id = event["id"]["val"] device_id = f"{slug}_esm_{esm_id}" device_name = f"{name} ESM {esm_id}" - store = config.get(device_id) + store, new = get_store(device_id) sensors = esm_sensors.get(esm_id) - if store is None: - store = config[device_id] = {} + if new: sensors = esm_sensors[esm_id] = [ StringValFerroampSensor( f"{device_name} Status", @@ -359,6 +366,47 @@ def esm_event_received(msg): update_sensor_from_event(event, sensors, store) + def get_cmd_sensor(store): + sensor = cmd_sensor.get('sensor') + if sensor is None: + sensor = CommandFerroampSensor( + f"{name} Control Status", + f"{slug}_{EHUB}", + f"{name} {EHUB_NAME}", + config_id + ) + cmd_sensor['sensor'] = sensor + if sensor.unique_id not in store: + store[sensor.unique_id] = sensor + _LOGGER.debug( + "Registering new sensor %(unique_id)s", + dict(unique_id=sensor.unique_id), + ) + async_add_entities((sensor,), True) + sensor.hass = hass + return sensor + + @callback + def ehub_request_received(msg): + command = json.loads(msg.payload) + store, new = get_store(f"{slug}_{EHUB}") + sensor = get_cmd_sensor(store) + trans_id = command["transId"] + cmd = command["cmd"] + cmd_name = cmd["name"] + arg = cmd.get("arg") + sensor.add_request(trans_id, cmd_name, arg) + + @callback + def ehub_response_received(msg): + response = json.loads(msg.payload) + store, new = get_store(f"{slug}_{EHUB}") + sensor = get_cmd_sensor(store) + trans_id = response["transId"] + status = response["status"] + message = response["msg"] + sensor.add_response(trans_id, status, message) + listeners.append(await mqtt.async_subscribe( hass, f"{config_entry.data[CONF_PREFIX]}/{EHUB_TOPIC}", ehub_event_received, 0 )) @@ -371,6 +419,15 @@ def esm_event_received(msg): listeners.append(await mqtt.async_subscribe( hass, f"{config_entry.data[CONF_PREFIX]}/{ESM_TOPIC}", esm_event_received, 0 )) + listeners.append(await mqtt.async_subscribe( + hass, f"{config_entry.data[CONF_PREFIX]}/{CONTROL_REQUEST_TOPIC}", ehub_request_received, 0 + )) + listeners.append(await mqtt.async_subscribe( + hass, f"{config_entry.data[CONF_PREFIX]}/{CONTROL_RESPONSE_TOPIC}", ehub_response_received, 0 + )) + listeners.append(await mqtt.async_subscribe( + hass, f"{config_entry.data[CONF_PREFIX]}/{CONTROL_RESULT_TOPIC}", ehub_response_received, 0 + )) return True @@ -783,6 +840,93 @@ def __init__(self, name, key, icon, device_id, device_name, interval, config_id) super().__init__(name, key, POWER_WATT, icon, device_id, device_name, interval, 0, config_id) +class CommandFerroampSensor(RestoreEntity): + def __init__(self, name, device_id, device_name, config_id): + self._state = None + self._name = name + self._icon = "mdi:cog-transfer-outline" + self._device_id = device_id + self._device_name = device_name + self.config_id = config_id + self.updated = datetime.min + self.attrs = {} + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unique_id(self): + """Return unique ID of entity.""" + return f"{self.device_id}_last_cmd" + + @property + def icon(self): + return self._icon + + @property + def device_id(self): + return self._device_id + + @property + def device_info(self): + """Return the device_info of the device.""" + device_info = { + "identifiers": {(DOMAIN, self._device_id)}, + "name": self._device_name, + "manufacturer": MANUFACTURER, + } + return device_info + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return None + + @property + def should_poll(self) -> bool: + return False + + @property + def state_attributes(self): + return self.attrs + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + state = await self.async_get_last_state() + if not state: + return + self._state = state.state + self.hass.data[DOMAIN][DATA_DEVICES][self.config_id][self.device_id][self.unique_id] = self + + def add_request(self, trans_id, cmd, arg): + if arg is not None: + self._state = f"{cmd} ({arg})" + else: + self._state = cmd + self.attrs["transId"] = trans_id + self.attrs["status"] = None + self.attrs["message"] = None + self.updated = datetime.now() + if self.entity_id is not None: + self.async_write_ha_state() + + def add_response(self, trans_id, status, message): + if self.attrs["transId"] == trans_id: + self.attrs["status"] = status + self.attrs["message"] = message + self.updated = datetime.now() + if self.entity_id is not None: + self.async_write_ha_state() + + def ehub_sensors(slug, name, interval, precision_battery, precision_energy, precision_frequency, config_id): return [ FloatValFerroampSensor( diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 547b44d..dc7989e 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -932,3 +932,139 @@ async def test_base_class_update_state_from_events(): sensor = FerroampSensor("test", "key", "", "", "", "", 20, "a") with pytest.raises(Exception): sensor.update_state_from_events([{}]) + + +async def test_control_command(hass, mqtt_mock): + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_NAME: "Ferroamp", + CONF_PREFIX: "extapi" + }, + options={ + CONF_INTERVAL: 0 + }, + version=1, + unique_id="ferroamp", + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + msg = """{ + "transId": "abc-123", + "cmd": {"name": "charge", "arg": "5000"} + }""" + async_fire_mqtt_message(hass, "extapi/control/request", msg) + await hass.async_block_till_done() + + state = hass.states.get("sensor.ferroamp_control_status") + assert state.state == "charge (5000)" + assert state.attributes == { + 'friendly_name': 'Ferroamp Control Status', + 'icon': 'mdi:cog-transfer-outline', + 'transId': 'abc-123', + 'status': None, + 'message': None + } + + async_fire_mqtt_message(hass, "extapi/control/request", msg) + await hass.async_block_till_done() + + msg = """{ + "transId": "xxx-123", + "status": "ack", + "msg": "some message" + }""" + async_fire_mqtt_message(hass, "extapi/control/response", msg) + await hass.async_block_till_done() + + state = hass.states.get("sensor.ferroamp_control_status") + assert state.state == "charge (5000)" + assert state.attributes == { + 'friendly_name': 'Ferroamp Control Status', + 'icon': 'mdi:cog-transfer-outline', + 'transId': 'abc-123', + 'status': None, + 'message': None + } + + msg = """{ + "transId": "abc-123", + "status": "ack", + "msg": "some message" + }""" + async_fire_mqtt_message(hass, "extapi/control/response", msg) + await hass.async_block_till_done() + + state = hass.states.get("sensor.ferroamp_control_status") + assert state.state == "charge (5000)" + assert state.attributes == { + 'friendly_name': 'Ferroamp Control Status', + 'icon': 'mdi:cog-transfer-outline', + 'transId': 'abc-123', + 'status': "ack", + 'message': "some message" + } + + msg = """{ + "transId": "abc-123", + "status": "nack", + "msg": "other message" + }""" + async_fire_mqtt_message(hass, "extapi/control/result", msg) + await hass.async_block_till_done() + + state = hass.states.get("sensor.ferroamp_control_status") + assert state.state == "charge (5000)" + assert state.attributes == { + 'friendly_name': 'Ferroamp Control Status', + 'icon': 'mdi:cog-transfer-outline', + 'transId': 'abc-123', + 'status': "nack", + 'message': "other message" + } + + +async def test_control_command_restore_state(hass, mqtt_mock): + mock_restore_cache( + hass, + ( + State("sensor.ferroamp_control_status", "auto"), + ), + ) + + hass.state = CoreState.starting + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_NAME: "Ferroamp", + CONF_PREFIX: "extapi" + }, + options={ + CONF_INTERVAL: 0 + }, + version=1, + unique_id="ferroamp", + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + msg = """{ + "transId": "abc-123", + "cmd": {"name": "charge", "arg": "5000"} + }""" + async_fire_mqtt_message(hass, "extapi/control/request", msg) + await hass.async_block_till_done() + + state = hass.states.get("sensor.ferroamp_control_status") + assert state.state == "auto" + assert state.attributes == { + 'friendly_name': 'Ferroamp Control Status', + 'icon': 'mdi:cog-transfer-outline', + 'transId': 'abc-123', + 'status': None, + 'message': None + }