diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index 2b8bbc8d103..dbe27e1a003 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -3,7 +3,7 @@ "name": "Tasmota (beta)", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tasmota", - "requirements": ["hatasmota==0.0.27"], + "requirements": ["hatasmota==0.0.29"], "dependencies": ["mqtt"], "mqtt": ["tasmota/discovery/#"], "codeowners": ["@emontnemery"] diff --git a/homeassistant/components/tasmota/mixins.py b/homeassistant/components/tasmota/mixins.py index 5a1c7a9f3de..a860b06c574 100644 --- a/homeassistant/components/tasmota/mixins.py +++ b/homeassistant/components/tasmota/mixins.py @@ -95,8 +95,7 @@ class TasmotaAvailability(TasmotaEntity): @callback def availability_updated(self, available: bool) -> None: """Handle updated availability.""" - if available and not self._available: - self._tasmota_entity.poll_status() + self._tasmota_entity.poll_status() self._available = available self.async_write_ha_state() diff --git a/homeassistant/components/tasmota/sensor.py b/homeassistant/components/tasmota/sensor.py index 480e5d8d214..966bf8648d4 100644 --- a/homeassistant/components/tasmota/sensor.py +++ b/homeassistant/components/tasmota/sensor.py @@ -3,6 +3,19 @@ from typing import Optional from hatasmota import status_sensor from hatasmota.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER as TASMOTA_CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION as TASMOTA_CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION as TASMOTA_CONCENTRATION_PARTS_PER_MILLION, + ELECTRICAL_CURRENT_AMPERE as TASMOTA_ELECTRICAL_CURRENT_AMPERE, + ELECTRICAL_VOLT_AMPERE as TASMOTA_ELECTRICAL_VOLT_AMPERE, + ENERGY_KILO_WATT_HOUR as TASMOTA_ENERGY_KILO_WATT_HOUR, + FREQUENCY_HERTZ as TASMOTA_FREQUENCY_HERTZ, + LENGTH_CENTIMETERS as TASMOTA_LENGTH_CENTIMETERS, + LIGHT_LUX as TASMOTA_LIGHT_LUX, + MASS_KILOGRAMS as TASMOTA_MASS_KILOGRAMS, + PERCENTAGE as TASMOTA_PERCENTAGE, + POWER_WATT as TASMOTA_POWER_WATT, + PRESSURE_HPA as TASMOTA_PRESSURE_HPA, SENSOR_AMBIENT, SENSOR_APPARENT_POWERUSAGE, SENSOR_BATTERY, @@ -35,12 +48,13 @@ from hatasmota.const import ( SENSOR_PROXIMITY, SENSOR_REACTIVE_POWERUSAGE, SENSOR_STATUS_IP, + SENSOR_STATUS_LAST_RESTART_TIME, SENSOR_STATUS_LINK_COUNT, SENSOR_STATUS_MQTT_COUNT, - SENSOR_STATUS_RESTART, + SENSOR_STATUS_RESTART_REASON, SENSOR_STATUS_RSSI, SENSOR_STATUS_SIGNAL, - SENSOR_STATUS_UPTIME, + SENSOR_STATUS_SSID, SENSOR_TEMPERATURE, SENSOR_TODAY, SENSOR_TOTAL, @@ -49,10 +63,21 @@ from hatasmota.const import ( SENSOR_VOLTAGE, SENSOR_WEIGHT, SENSOR_YESTERDAY, + SIGNAL_STRENGTH_DECIBELS as TASMOTA_SIGNAL_STRENGTH_DECIBELS, + SPEED_KILOMETERS_PER_HOUR as TASMOTA_SPEED_KILOMETERS_PER_HOUR, + SPEED_METERS_PER_SECOND as TASMOTA_SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR as TASMOTA_SPEED_MILES_PER_HOUR, + TEMP_CELSIUS as TASMOTA_TEMP_CELSIUS, + TEMP_FAHRENHEIT as TASMOTA_TEMP_FAHRENHEIT, + TEMP_KELVIN as TASMOTA_TEMP_KELVIN, + VOLT as TASMOTA_VOLT, ) from homeassistant.components import sensor from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, @@ -60,6 +85,25 @@ from homeassistant.const import ( DEVICE_CLASS_PRESSURE, DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TIMESTAMP, + ELECTRICAL_CURRENT_AMPERE, + ELECTRICAL_VOLT_AMPERE, + ENERGY_KILO_WATT_HOUR, + FREQUENCY_HERTZ, + LENGTH_CENTIMETERS, + LIGHT_LUX, + MASS_KILOGRAMS, + PERCENTAGE, + POWER_WATT, + PRESSURE_HPA, + SIGNAL_STRENGTH_DECIBELS, + SPEED_KILOMETERS_PER_HOUR, + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + TEMP_KELVIN, + VOLT, ) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -108,10 +152,11 @@ SENSOR_DEVICE_CLASS_ICON_MAP = { SENSOR_PRESSUREATSEALEVEL: {DEVICE_CLASS: DEVICE_CLASS_PRESSURE}, SENSOR_PROXIMITY: {ICON: "mdi:ruler"}, SENSOR_REACTIVE_POWERUSAGE: {DEVICE_CLASS: DEVICE_CLASS_POWER}, - SENSOR_STATUS_RESTART: {ICON: "mdi:information-outline"}, + SENSOR_STATUS_LAST_RESTART_TIME: {DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP}, + SENSOR_STATUS_RESTART_REASON: {ICON: "mdi:information-outline"}, SENSOR_STATUS_SIGNAL: {DEVICE_CLASS: DEVICE_CLASS_SIGNAL_STRENGTH}, SENSOR_STATUS_RSSI: {ICON: "mdi:access-point"}, - SENSOR_STATUS_UPTIME: {ICON: "mdi:progress-clock"}, + SENSOR_STATUS_SSID: {ICON: "mdi:access-point-network"}, SENSOR_TEMPERATURE: {DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE}, SENSOR_TODAY: {DEVICE_CLASS: DEVICE_CLASS_POWER}, SENSOR_TOTAL: {DEVICE_CLASS: DEVICE_CLASS_POWER}, @@ -122,6 +167,30 @@ SENSOR_DEVICE_CLASS_ICON_MAP = { SENSOR_YESTERDAY: {DEVICE_CLASS: DEVICE_CLASS_POWER}, } +SENSOR_UNIT_MAP = { + TASMOTA_CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + TASMOTA_CONCENTRATION_PARTS_PER_BILLION: CONCENTRATION_PARTS_PER_BILLION, + TASMOTA_CONCENTRATION_PARTS_PER_MILLION: CONCENTRATION_PARTS_PER_MILLION, + TASMOTA_ELECTRICAL_CURRENT_AMPERE: ELECTRICAL_CURRENT_AMPERE, + TASMOTA_ELECTRICAL_VOLT_AMPERE: ELECTRICAL_VOLT_AMPERE, + TASMOTA_ENERGY_KILO_WATT_HOUR: ENERGY_KILO_WATT_HOUR, + TASMOTA_FREQUENCY_HERTZ: FREQUENCY_HERTZ, + TASMOTA_LENGTH_CENTIMETERS: LENGTH_CENTIMETERS, + TASMOTA_LIGHT_LUX: LIGHT_LUX, + TASMOTA_MASS_KILOGRAMS: MASS_KILOGRAMS, + TASMOTA_PERCENTAGE: PERCENTAGE, + TASMOTA_POWER_WATT: POWER_WATT, + TASMOTA_PRESSURE_HPA: PRESSURE_HPA, + TASMOTA_SIGNAL_STRENGTH_DECIBELS: SIGNAL_STRENGTH_DECIBELS, + TASMOTA_SPEED_KILOMETERS_PER_HOUR: SPEED_KILOMETERS_PER_HOUR, + TASMOTA_SPEED_METERS_PER_SECOND: SPEED_METERS_PER_SECOND, + TASMOTA_SPEED_MILES_PER_HOUR: SPEED_MILES_PER_HOUR, + TASMOTA_TEMP_CELSIUS: TEMP_CELSIUS, + TASMOTA_TEMP_FAHRENHEIT: TEMP_FAHRENHEIT, + TASMOTA_TEMP_KELVIN: TEMP_KELVIN, + TASMOTA_VOLT: VOLT, +} + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Tasmota sensor dynamically through discovery.""" @@ -190,9 +259,11 @@ class TasmotaSensor(TasmotaAvailability, TasmotaDiscoveryUpdate, Entity): @property def state(self): """Return the state of the entity.""" + if self._state and self.device_class == DEVICE_CLASS_TIMESTAMP: + return self._state.isoformat() return self._state @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return self._tasmota_entity.unit + return SENSOR_UNIT_MAP.get(self._tasmota_entity.unit, self._tasmota_entity.unit) diff --git a/requirements_all.txt b/requirements_all.txt index e66066e6f4e..0a000bff742 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -741,7 +741,7 @@ hass-nabucasa==0.37.1 hass_splunk==0.1.1 # homeassistant.components.tasmota -hatasmota==0.0.27 +hatasmota==0.0.29 # homeassistant.components.jewish_calendar hdate==0.9.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8b843b6a084..c2d8a789deb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -376,7 +376,7 @@ hangups==0.4.11 hass-nabucasa==0.37.1 # homeassistant.components.tasmota -hatasmota==0.0.27 +hatasmota==0.0.29 # homeassistant.components.jewish_calendar hdate==0.9.12 diff --git a/tests/components/tasmota/test_binary_sensor.py b/tests/components/tasmota/test_binary_sensor.py index c3982bf69fd..22bf533e18e 100644 --- a/tests/components/tasmota/test_binary_sensor.py +++ b/tests/components/tasmota/test_binary_sensor.py @@ -96,6 +96,54 @@ async def test_controlling_state_via_mqtt(hass, mqtt_mock, setup_tasmota): assert state.state == STATE_OFF +async def test_pushon_controlling_state_via_mqtt(hass, mqtt_mock, setup_tasmota): + """Test state update via MQTT.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["swc"][0] = 13 + mac = config["mac"] + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.test") + assert state.state == "unavailable" + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_OFF + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + # Test normal state update + async_fire_mqtt_message( + hass, "tasmota_49A3BC/stat/RESULT", '{"Switch1":{"Action":"ON"}}' + ) + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_ON + + async_fire_mqtt_message( + hass, "tasmota_49A3BC/stat/RESULT", '{"Switch1":{"Action":"OFF"}}' + ) + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_OFF + + # Test periodic state update is ignored + async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/SENSOR", '{"Switch1":"ON"}') + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_OFF + + # Test polled state update is ignored + async_fire_mqtt_message( + hass, "tasmota_49A3BC/stat/STATUS10", '{"StatusSNS":{"Switch1":"ON"}}' + ) + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_OFF + + async def test_friendly_names(hass, mqtt_mock, setup_tasmota): """Test state update via MQTT.""" config = copy.deepcopy(DEFAULT_CONFIG) diff --git a/tests/components/tasmota/test_sensor.py b/tests/components/tasmota/test_sensor.py index 8da08b16376..4c8de9e339d 100644 --- a/tests/components/tasmota/test_sensor.py +++ b/tests/components/tasmota/test_sensor.py @@ -1,8 +1,10 @@ """The tests for the Tasmota sensor platform.""" import copy +import datetime from datetime import timedelta import json +import hatasmota from hatasmota.utils import ( get_topic_stat_status, get_topic_tele_sensor, @@ -29,7 +31,7 @@ from .test_common import ( help_test_entity_id_update_subscriptions, ) -from tests.async_mock import patch +from tests.async_mock import Mock, patch from tests.common import async_fire_mqtt_message, async_fire_time_changed DEFAULT_SENSOR_CONFIG = { @@ -259,6 +261,7 @@ async def test_status_sensor_state_via_mqtt(hass, mqtt_mock, setup_tasmota): async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"Wifi":{"Signal":20.5}}' ) + await hass.async_block_till_done() state = hass.states.get("sensor.tasmota_status") assert state.state == "20.5" @@ -268,10 +271,142 @@ async def test_status_sensor_state_via_mqtt(hass, mqtt_mock, setup_tasmota): "tasmota_49A3BC/stat/STATUS11", '{"StatusSTS":{"Wifi":{"Signal":20.0}}}', ) + await hass.async_block_till_done() state = hass.states.get("sensor.tasmota_status") assert state.state == "20.0" +@pytest.mark.parametrize("status_sensor_disabled", [False]) +async def test_single_shot_status_sensor_state_via_mqtt(hass, mqtt_mock, setup_tasmota): + """Test state update via MQTT.""" + entity_reg = await hass.helpers.entity_registry.async_get_registry() + + # Pre-enable the status sensor + entity_reg.async_get_or_create( + sensor.DOMAIN, + "tasmota", + "00000049A3BC_status_sensor_status_sensor_status_restart_reason", + suggested_object_id="tasmota_status", + disabled_by=None, + ) + + config = copy.deepcopy(DEFAULT_CONFIG) + mac = config["mac"] + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get("sensor.tasmota_status") + assert state.state == "unavailable" + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + state = hass.states.get("sensor.tasmota_status") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + # Test polled state update + async_fire_mqtt_message( + hass, + "tasmota_49A3BC/stat/STATUS1", + '{"StatusPRM":{"RestartReason":"Some reason"}}', + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.tasmota_status") + assert state.state == "Some reason" + + # Test polled state update is ignored + async_fire_mqtt_message( + hass, + "tasmota_49A3BC/stat/STATUS1", + '{"StatusPRM":{"RestartReason":"Another reason"}}', + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.tasmota_status") + assert state.state == "Some reason" + + # Device signals online again + async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() + state = hass.states.get("sensor.tasmota_status") + assert state.state == "Some reason" + + # Test polled state update + async_fire_mqtt_message( + hass, + "tasmota_49A3BC/stat/STATUS1", + '{"StatusPRM":{"RestartReason":"Another reason"}}', + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.tasmota_status") + assert state.state == "Another reason" + + # Test polled state update is ignored + async_fire_mqtt_message( + hass, + "tasmota_49A3BC/stat/STATUS1", + '{"StatusPRM":{"RestartReason":"Third reason"}}', + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.tasmota_status") + assert state.state == "Another reason" + + +@pytest.mark.parametrize("status_sensor_disabled", [False]) +@patch.object(hatasmota.status_sensor, "datetime", Mock(wraps=datetime.datetime)) +async def test_restart_time_status_sensor_state_via_mqtt( + hass, mqtt_mock, setup_tasmota +): + """Test state update via MQTT.""" + entity_reg = await hass.helpers.entity_registry.async_get_registry() + + # Pre-enable the status sensor + entity_reg.async_get_or_create( + sensor.DOMAIN, + "tasmota", + "00000049A3BC_status_sensor_status_sensor_last_restart_time", + suggested_object_id="tasmota_status", + disabled_by=None, + ) + + config = copy.deepcopy(DEFAULT_CONFIG) + mac = config["mac"] + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get("sensor.tasmota_status") + assert state.state == "unavailable" + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + state = hass.states.get("sensor.tasmota_status") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + # Test polled state update + utc_now = datetime.datetime(2020, 11, 11, 8, 0, 0, tzinfo=dt.UTC) + hatasmota.status_sensor.datetime.now.return_value = utc_now + async_fire_mqtt_message( + hass, + "tasmota_49A3BC/stat/STATUS11", + '{"StatusSTS":{"UptimeSec":"3600"}}', + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.tasmota_status") + assert state.state == "2020-11-11T07:00:00+00:00" + + async def test_attributes(hass, mqtt_mock, setup_tasmota): """Test correct attributes for sensors.""" config = copy.deepcopy(DEFAULT_CONFIG) @@ -301,7 +436,7 @@ async def test_attributes(hass, mqtt_mock, setup_tasmota): assert state.attributes.get("device_class") == "temperature" assert state.attributes.get("friendly_name") == "Tasmota DHT11 Temperature" assert state.attributes.get("icon") is None - assert state.attributes.get("unit_of_measurement") == "C" + assert state.attributes.get("unit_of_measurement") == "°C" state = hass.states.get("sensor.tasmota_beer_CarbonDioxide") assert state.attributes.get("device_class") is None @@ -371,7 +506,7 @@ async def test_indexed_sensor_attributes(hass, mqtt_mock, setup_tasmota): assert state.attributes.get("device_class") == "temperature" assert state.attributes.get("friendly_name") == "Tasmota Dummy1 Temperature 0" assert state.attributes.get("icon") is None - assert state.attributes.get("unit_of_measurement") == "C" + assert state.attributes.get("unit_of_measurement") == "°C" state = hass.states.get("sensor.tasmota_dummy2_carbondioxide_1") assert state.attributes.get("device_class") is None