pax_global_header00006660000000000000000000000064147576602270014532gustar00rootroot0000000000000052 comment=d87b6d542af57a51602f8c411b4bb4344c521fa6 hatasmota-0.10.0/000077500000000000000000000000001475766022700135715ustar00rootroot00000000000000hatasmota-0.10.0/.github/000077500000000000000000000000001475766022700151315ustar00rootroot00000000000000hatasmota-0.10.0/.github/dependabot.yml000066400000000000000000000001751475766022700177640ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: pip directory: "/" schedule: interval: daily open-pull-requests-limit: 10 hatasmota-0.10.0/.github/release-drafter.yml000066400000000000000000000000541475766022700207200ustar00rootroot00000000000000template: | ## What's Changed $CHANGES hatasmota-0.10.0/.github/workflows/000077500000000000000000000000001475766022700171665ustar00rootroot00000000000000hatasmota-0.10.0/.github/workflows/pythonpublish.yml000066400000000000000000000015151475766022700226230ustar00rootroot00000000000000# This workflows will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries name: Upload Python Package on: release: types: [published] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Python uses: actions/setup-python@v1 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip pip install setuptools wheel twine - name: Build and publish env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} run: | python setup.py sdist bdist_wheel twine upload dist/* hatasmota-0.10.0/.github/workflows/release-drafter.yml000066400000000000000000000010501475766022700227520ustar00rootroot00000000000000name: Release Drafter on: push: # branches to consider in the event; optional, defaults to all branches: - master jobs: update_release_draft: runs-on: ubuntu-latest steps: # Drafts your next Release notes as Pull Requests are merged into "master" - uses: release-drafter/release-drafter@v5 # with: # (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml # config-name: my-config.yml env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} hatasmota-0.10.0/.github/workflows/test.yml000066400000000000000000000021461475766022700206730ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint with a single version of Python # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: Run Tests on: push: branches: [master] pull_request: branches: [master] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Python 3.13 uses: actions/setup-python@v1 with: python-version: 3.13 - name: Install dependencies run: | pip install -r requirements.txt pip install -r requirements-test.txt - name: Lint with flake8 run: | flake8 hatasmota - name: Lint with pylint run: | pylint hatasmota - name: Lint with mypy run: | mypy hatasmota - name: Check formatting with black run: | black hatasmota --check --diff - name: Check imports with isort run: | isort hatasmota --check --diff --combine-as --force-sort-within-sections --profile black hatasmota-0.10.0/.gitignore000066400000000000000000000034071475766022700155650ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ hatasmota-0.10.0/LICENSE000066400000000000000000000020601475766022700145740ustar00rootroot00000000000000MIT License Copyright (c) 2020 Erik Montnemery Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. hatasmota-0.10.0/MANIFEST.in000066400000000000000000000001501475766022700153230ustar00rootroot00000000000000include README.md include LICENSE include requirements.txt graft hatasmota recursive-exclude * *.py[co] hatasmota-0.10.0/README.md000066400000000000000000000000141475766022700150430ustar00rootroot00000000000000# hatasmota hatasmota-0.10.0/hatasmota/000077500000000000000000000000001475766022700155525ustar00rootroot00000000000000hatasmota-0.10.0/hatasmota/__init__.py000066400000000000000000000000211475766022700176540ustar00rootroot00000000000000"""HATasmota.""" hatasmota-0.10.0/hatasmota/button.py000066400000000000000000000130151475766022700174370ustar00rootroot00000000000000"""Tasmota binary sensor.""" from __future__ import annotations from dataclasses import dataclass import logging from .const import ( CONF_BUTTON, CONF_MAC, CONF_OPTIONS, OPTION_BUTTON_SINGLE, OPTION_BUTTON_SWAP, OPTION_MQTT_BUTTONS, RSLT_ACTION, ) from .mqtt import ReceiveMessage from .trigger import TasmotaTrigger, TasmotaTriggerConfig from .utils import get_topic_stat_result, get_value_by_path _LOGGER = logging.getLogger(__name__) # Button matrix for triggers generation when SetOption73 is enabled: # N SetOption1 SetOption11 SetOption13 SINGLE PRESS DOUBLE PRESS MULTI PRESS HOLD # 1 0 0 0 SINGLE (10 - button_short_press) DOUBLE DOUBLE to PENTA YES (button_long_press) # 2 1 0 0 SINGLE (10 - button_short_press) DOUBLE DOUBLE to PENTA YES (button_long_press) # 3 0 1 0 DOUBLE (11 - button_short_press) SINGLE SINGLE then TRIPLE TO PENTA YES (button_long_press) # 4 1 1 0 DOUBLE (11 - button_short_press) SINGLE SINGLE then TRIPLE TO PENTA YES (button_long_press) # 5 0 0 1 SINGLE (10 - button_short_press) NONE NONE NONE # 6 1 0 1 SINGLE (10 - button_short_press) NONE NONE NONE # 7 0 1 1 SINGLE (10 - button_short_press) NONE NONE NONE # 8 1 1 1 SINGLE (10 - button_short_press) NONE NONE NONE # Trigger types: 10 = button_short_press | 11 = button_double_press | 12 = button_triple_press | 13 = button_quadruple_press | 14 = button_quintuple_press | 3 = button_long_press # SetOption11: Swap button single and double press functionality # SetOption13: Immediate action on button press, just SINGLE trigger BUTTONMODE_NONE = "none" BUTTONMODE_NORMAL = "normal" BUTTONMODE_SWAP = "swap" BUTTONMODE_SINGLE = "single" BTN_SINGLE = "SINGLE" BTN_DOUBLE = "DOUBLE" BTN_TRIPLE = "TRIPLE" BTN_QUAD = "QUAD" BTN_PENTA = "PENTA" BTN_HOLD = "HOLD" BTN_TRIG_NONE = "none" BTN_TRIG_SINGLE = "button_short_press" BTN_TRIG_DOUBLE = "button_double_press" BTN_TRIG_TRIPLE = "button_triple_press" BTN_TRIG_QUAD = "button_quadruple_press" BTN_TRIG_PENTA = "button_quintuple_press" BTN_TRIG_HOLD = "button_long_press" BUTTONMODE_MAP = { BUTTONMODE_NONE: { BTN_SINGLE: BTN_TRIG_NONE, BTN_DOUBLE: BTN_TRIG_NONE, BTN_TRIPLE: BTN_TRIG_NONE, BTN_QUAD: BTN_TRIG_NONE, BTN_PENTA: BTN_TRIG_NONE, BTN_HOLD: BTN_TRIG_NONE, }, BUTTONMODE_NORMAL: { BTN_SINGLE: BTN_TRIG_SINGLE, BTN_DOUBLE: BTN_TRIG_DOUBLE, BTN_TRIPLE: BTN_TRIG_TRIPLE, BTN_QUAD: BTN_TRIG_QUAD, BTN_PENTA: BTN_TRIG_PENTA, BTN_HOLD: BTN_TRIG_HOLD, }, BUTTONMODE_SWAP: { BTN_SINGLE: BTN_TRIG_DOUBLE, BTN_DOUBLE: BTN_TRIG_SINGLE, BTN_TRIPLE: BTN_TRIG_TRIPLE, BTN_QUAD: BTN_TRIG_QUAD, BTN_PENTA: BTN_TRIG_PENTA, BTN_HOLD: BTN_TRIG_HOLD, }, BUTTONMODE_SINGLE: { BTN_SINGLE: BTN_TRIG_SINGLE, BTN_DOUBLE: BTN_TRIG_NONE, BTN_TRIPLE: BTN_TRIG_NONE, BTN_QUAD: BTN_TRIG_NONE, BTN_PENTA: BTN_TRIG_NONE, BTN_HOLD: BTN_TRIG_NONE, }, } @dataclass(frozen=True, kw_only=True) class TasmotaButtonTriggerConfig(TasmotaTriggerConfig): """Tasmota switch configuation.""" @classmethod def from_discovery_message( cls, config: dict, idx: int ) -> list[TasmotaButtonTriggerConfig]: """Instantiate from discovery message.""" mqtt_buttons = config[CONF_OPTIONS][OPTION_MQTT_BUTTONS] single_buttons = config[CONF_OPTIONS][OPTION_BUTTON_SINGLE] swap_buttons = config[CONF_OPTIONS][OPTION_BUTTON_SWAP] buttonmode = BUTTONMODE_NONE if mqtt_buttons and config[CONF_BUTTON][idx]: if single_buttons: buttonmode = BUTTONMODE_SINGLE elif swap_buttons: buttonmode = BUTTONMODE_SWAP else: buttonmode = BUTTONMODE_NORMAL triggers = BUTTONMODE_MAP[buttonmode] configs = [] for event, trigger_type in triggers.items(): configs.append( cls( mac=config[CONF_MAC], event=event, idx=idx, source="button", subtype=f"button_{idx + 1}", trigger_topic=get_topic_stat_result(config), type=trigger_type, ) ) return configs @property def is_active(self) -> bool: """Return if the trigger is active.""" return self.type != BTN_TRIG_NONE @property def trigger_id(self) -> str: """Return trigger id.""" return f"{self.mac}_button_{self.idx + 1}_{self.event}" class TasmotaButtonTrigger(TasmotaTrigger): """Representation of a Tasmota button trigger.""" cfg: TasmotaButtonTriggerConfig def _trig_message_received(self, msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" event = get_value_by_path( msg.payload, [f"Button{self.cfg.idx + 1}", RSLT_ACTION] ) if event == self.cfg.event and self._on_trigger_callback: self._on_trigger_callback() hatasmota-0.10.0/hatasmota/camera.py000066400000000000000000000053011475766022700173530ustar00rootroot00000000000000"""Tasmota camera.""" from __future__ import annotations from collections.abc import Awaitable from dataclasses import dataclass import logging from typing import Any from aiohttp import ClientResponse, ClientSession from .const import CONF_DEEP_SLEEP, CONF_IP, CONF_MAC from .entity import ( TasmotaAvailability, TasmotaAvailabilityConfig, TasmotaEntity, TasmotaEntityConfig, ) from .utils import ( config_get_state_offline, config_get_state_online, get_topic_command_state, get_topic_tele_will, ) _LOGGER = logging.getLogger(__name__) @dataclass(frozen=True, kw_only=True) class TasmotaCameraConfig(TasmotaAvailabilityConfig, TasmotaEntityConfig): """Tasmota camera configuation.""" ip_address: str @classmethod def from_discovery_message(cls, config: dict, platform: str) -> TasmotaCameraConfig: """Instantiate from discovery message.""" return cls( endpoint="camera", idx=0, friendly_name=None, mac=config[CONF_MAC], platform=platform, poll_payload="", poll_topic=get_topic_command_state(config), availability_topic=get_topic_tele_will(config), availability_offline=config_get_state_offline(config), availability_online=config_get_state_online(config), deep_sleep_enabled=config[CONF_DEEP_SLEEP], ip_address=config[CONF_IP], ) class TasmotaCamera(TasmotaAvailability, TasmotaEntity): """Representation of a Tasmota camera.""" _cfg: TasmotaCameraConfig def __init__(self, **kwds: Any): """Initialize.""" self._sub_state: dict | None = None super().__init__(**kwds) async def subscribe_topics(self) -> None: """Subscribe to topics.""" availability_topics = self.get_availability_topics() topics = {**availability_topics} self._sub_state = await self._mqtt_client.subscribe( self._sub_state, topics, ) async def unsubscribe_topics(self) -> None: """Unsubscribe to all MQTT topics.""" self._sub_state = await self._mqtt_client.unsubscribe(self._sub_state) def get_still_image_stream( self, websession: ClientSession ) -> Awaitable[ClientResponse]: """Get the io stream to read the static image.""" still_image_url = f"http://{self._cfg.ip_address}/snapshot.jpg" return websession.get(still_image_url) def get_mjpeg_stream(self, websession: ClientSession) -> Awaitable[ClientResponse]: """Get the io stream to read the mjpeg stream.""" mjpeg_url = f"http://{self._cfg.ip_address}:81/cam.mjpeg" return websession.get(mjpeg_url) hatasmota-0.10.0/hatasmota/config_validation.py000066400000000000000000000020731475766022700216050ustar00rootroot00000000000000"""Tasmota config validation.""" from __future__ import annotations from typing import Any, TypeVar import voluptuous as vol # typing typevar T = TypeVar("T") # pylint: disable=invalid-name bit = vol.All(vol.Coerce(int), vol.Range(min=0, max=1)) positive_int = vol.All( # pylint: disable=invalid-name vol.Coerce(int), vol.Range(min=0) ) def ensure_list(value: T | list[T] | None) -> list[T]: """Wrap value in list if it is not one.""" if value is None: return [] return value if isinstance(value, list) else [value] def optional_string(value: Any) -> str | None: """Coerce value to string, except for None.""" if value is None: return None if isinstance(value, (list, dict)): raise vol.Invalid("value should be a string") return str(value) def string(value: Any) -> str: """Coerce value to string, except for None.""" if value is None: raise vol.Invalid("string value is None") if isinstance(value, (list, dict)): raise vol.Invalid("value should be a string") return str(value) hatasmota-0.10.0/hatasmota/const.py000066400000000000000000000215601475766022700172560ustar00rootroot00000000000000"""Tasmota constants.""" from typing import Final AUTOMATION_TYPE_TRIGGER: Final = "trigger" COMMAND_BACKLOG: Final = "Backlog" COMMAND_CHANNEL: Final = "Channel" COMMAND_COLOR: Final = "Color" COMMAND_CT: Final = "CT" COMMAND_DIMMER: Final = "Dimmer" COMMAND_FADE: Final = "Fade2" COMMAND_FANSPEED: Final = "FanSpeed" COMMAND_POWER: Final = "Power" COMMAND_SCHEME: Final = "Scheme" COMMAND_SHUTTER_CLOSE: Final = "ShutterClose" COMMAND_SHUTTER_OPEN: Final = "ShutterOpen" COMMAND_SHUTTER_POSITION: Final = "ShutterPosition" COMMAND_SHUTTER_STOP: Final = "ShutterStop" COMMAND_SHUTTER_TILT: Final = "ShutterTilt" COMMAND_SPEED: Final = "Speed2" COMMAND_WHITE: Final = "White" CONF_BUTTON: Final = "btn" CONF_DEEP_SLEEP: Final = "dslp" CONF_DEVICENAME: Final = "dn" CONF_FRIENDLYNAME: Final = "fn" CONF_FULLTOPIC: Final = "ft" CONF_IFAN: Final = "if" CONF_CAM: Final = "cam" CONF_IP: Final = "ip" CONF_HOSTNAME: Final = "hn" CONF_MAC: Final = "mac" CONF_LIGHT_SUBTYPE: Final = "lt_st" CONF_LINK_RGB_CT: Final = "lk" # RGB + white channels linked to a single light CONF_MODEL: Final = "md" CONF_OFFLINE: Final = "ofln" CONF_ONLINE: Final = "onln" CONF_OPTIONS: Final = "so" CONF_PREFIX: Final = "tp" CONF_SENSOR: Final = "sn" CONF_BATTERY: Final = "bat" CONF_SHUTTER_OPTIONS: Final = "sho" CONF_SHUTTER_TILT: Final = "sht" CONF_STATE: Final = "state" CONF_RELAY: Final = "rl" CONF_SW_VERSION: Final = "sw" CONF_SWITCH: Final = "swc" CONF_SWITCHNAME: Final = "swn" CONF_TOPIC: Final = "t" CONF_TUYA: Final = "ty" CONF_VERSION: Final = "ver" CONF_MANUFACTURER: Final = "manufacturer" CONF_NAME: Final = "name" FAN_SPEED_OFF: Final = 0 FAN_SPEED_LOW: Final = 1 FAN_SPEED_MEDIUM: Final = 2 FAN_SPEED_HIGH: Final = 3 LST_NONE: Final = 0 LST_SINGLE: Final = 1 LST_COLDWARM: Final = 2 LST_RGB: Final = 3 LST_RGBW: Final = 4 LST_RGBCW: Final = 5 # fmt: off OPTION_MQTT_RESPONSE: Final = "4" # Return MQTT response as RESULT or %COMMAND% OPTION_BUTTON_SWAP: Final = "11" # Swap button single and double press functionality OPTION_BUTTON_SINGLE: Final = "13" # Allow immediate action on single button press OPTION_DECIMAL_TEXT: Final = "17" # Show Color string as hex or comma-separated OPTION_NOT_POWER_LINKED: Final = "20" # Update of Dimmer/Color/CT without turning power on OPTION_HASS_LIGHT: Final = "30" # Enforce Home Assistant auto-discovery as light OPTION_PWM_MULTI_CHANNELS: Final = "68" # Multi-channel PWM instead of a single light OPTION_MQTT_BUTTONS: Final = "73" # Enable Buttons decoupling and send multi-press and hold MQTT messages OPTION_SHUTTER_MODE: Final = "80" # Blinds and shutters support; removed in Tasmota 9.0.0.4 OPTION_REDUCED_CT_RANGE: Final = "82" # Reduce the CT range from 153..500 to 200.380 OPTION_MQTT_SWITCHES: Final = "114" # Enable sending switch MQTT messages OPTION_FADE_FIXED_DURATION: Final = "117" # Run fading at fixed duration instead of fixed slew rate # fmt: on PREFIX_CMND: Final = 0 PREFIX_STAT: Final = 1 PREFIX_TELE: Final = 2 RL_NONE: Final = 0 RL_RELAY: Final = 1 RL_LIGHT: Final = 2 RL_SHUTTER: Final = 3 RSLT_ACTION: Final = "Action" RSLT_POWER: Final = "POWER" RSLT_SHUTTER: Final = "Shutter" RSLT_STATE: Final = "STATE" RSLT_TRIG: Final = "TRIG" SENSOR_ATTRIBUTE_RSSI: Final = "RSSI" SENSOR_ATTRIBUTE_UPTIME: Final = "Uptime" SENSOR_ATTRIBUTE_SIGNAL: Final = "Signal" SENSOR_ATTRIBUTE_WIFI_LINKCOUNT: Final = "LinkCount" SENSOR_ATTRIBUTE_WIFI_DOWNTIME: Final = "Downtime" SENSOR_ATTRIBUTE_MQTTCOUNT: Final = "MqttCount" SENSOR_AMBIENT: Final = "Ambient" SENSOR_BATTERY: Final = "Battery" SENSOR_CCT: Final = "CCT" SENSOR_CF1: Final = "CF1" SENSOR_CF10: Final = "CF10" SENSOR_CF2_5: Final = "CF2.5" SENSOR_CO2: Final = "CarbonDioxide" SENSOR_COLOR_BLUE: Final = "Blue" SENSOR_COLOR_GREEN: Final = "Green" SENSOR_COLOR_RED: Final = "Red" SENSOR_CURRENT_NEUTRAL: Final = "CurrentNeutral" SENSOR_CURRENT: Final = "Current" SENSOR_DEWPOINT: Final = "DewPoint" SENSOR_DISTANCE: Final = "Distance" SENSOR_ECO2: Final = "eCO2" SENSOR_ENERGY_EXPORT_ACTIVE: Final = "ExportActive" SENSOR_ENERGY_EXPORT_REACTIVE: Final = "ExportReactive" SENSOR_ENERGY_EXPORT_TARIFF: Final = "ExportTariff" SENSOR_ENERGY_IMPORT_ACTIVE: Final = "ImportActive" SENSOR_ENERGY_IMPORT_REACTIVE: Final = "ImportReactive" SENSOR_ENERGY_IMPORT_TODAY: Final = "Today" SENSOR_ENERGY_IMPORT_TOTAL_TARIFF: Final = "TotalTariff" SENSOR_ENERGY_IMPORT_TOTAL: Final = "Total" SENSOR_ENERGY_IMPORT_YESTERDAY: Final = "Yesterday" SENSOR_ENERGY_OTHER: Final = "Energy_other" SENSOR_ENERGY_TOTAL_START_TIME: Final = "TotalStartTime" SENSOR_ENERGY: Final = "Energy" SENSOR_FREQUENCY: Final = "Frequency" SENSOR_HUMIDITY: Final = "Humidity" SENSOR_ILLUMINANCE: Final = "Illuminance" SENSOR_MOISTURE: Final = "Moisture" SENSOR_PB0_3: Final = "PB0.3" SENSOR_PB0_5: Final = "PB0.5" SENSOR_PB1: Final = "PB1" SENSOR_PB10: Final = "PB10" SENSOR_PB2_5: Final = "PB2.5" SENSOR_PB5: Final = "PB5" SENSOR_PHASE_ANGLE: Final = "PhaseAngle" SENSOR_PM1: Final = "PM1" SENSOR_PM10: Final = "PM10" SENSOR_PM2_5: Final = "PM2.5" SENSOR_POWER_ACTIVE: Final = "ActivePower" SENSOR_POWER_APPARENT: Final = "ApparentPower" SENSOR_POWER_FACTOR: Final = "Factor" SENSOR_POWER: Final = "Power" SENSOR_PRESSURE_AT_SEA_LEVEL: Final = "SeaPressure" SENSOR_PRESSURE: Final = "Pressure" SENSOR_PROXIMITY: Final = "Proximity" SENSOR_POWER_REACTIVE: Final = "ReactivePower" SENSOR_SPEED: Final = "Speed" SENSOR_STATUS_IP: Final = "status_ip" SENSOR_STATUS_LAST_RESTART_TIME: Final = "last_restart_time" SENSOR_STATUS_LINK_COUNT: Final = "status_link_count" SENSOR_STATUS_MQTT_COUNT: Final = "status_mqtt_count" SENSOR_STATUS_RESTART_REASON: Final = "status_restart_reason" SENSOR_STATUS_RSSI: Final = "status_rssi" SENSOR_STATUS_SIGNAL: Final = "status_signal" SENSOR_STATUS_SSID: Final = "status_ssid" SENSOR_STATUS_VERSION: Final = "status_version" SENSOR_SWITCH: Final = "Switch" SENSOR_TEMPERATURE: Final = "Temperature" SENSOR_TVOC: Final = "TVOC" SENSOR_VOLTAGE: Final = "Voltage" SENSOR_WEIGHT: Final = "Weight" SENSOR_STATUS_BATTERY_PERCENTAGE: Final = "status_battery_percentage" SENSOR_UNIT_PRESSURE: Final = "PressureUnit" SENSOR_UNIT_SPEED: Final = "SpeedUnit" SENSOR_UNIT_TEMPERATURE: Final = "TempUnit" SHUTTER_DIRECTION: Final = "Direction" SHUTTER_DIRECTION_DOWN: Final = -1 SHUTTER_DIRECTION_STOP: Final = 0 SHUTTER_DIRECTION_UP: Final = 1 SHUTTER_POSITION: Final = "Position" SHUTTER_TILT: Final = "Tilt" SHUTTER_OPTION_INVERT: Final = 1 # #### UNITS OF MEASUREMENT #### # Power units POWER_WATT: Final = "W" REACTIVE_POWER = "VAr" # Voltage units VOLT: Final = "V" # Energy units ENERGY_WATT_HOUR: Final = f"{POWER_WATT}h" ENERGY_KILO_WATT_HOUR: Final = f"k{ENERGY_WATT_HOUR}" REACTIVE_ENERGY_VOLT_AMPERE_HOUR = f"{REACTIVE_POWER}h" REACTIVE_ENERGY_KILO_VOLT_AMPERE_HOUR = f"k{REACTIVE_ENERGY_VOLT_AMPERE_HOUR}" # Electrical units ELECTRICAL_CURRENT_AMPERE: Final = "A" ELECTRICAL_VOLT_AMPERE: Final = f"{VOLT}{ELECTRICAL_CURRENT_AMPERE}" # Temperature units TEMP_CELSIUS: Final = "C" TEMP_FAHRENHEIT: Final = "F" TEMP_KELVIN: Final = "K" # Degree units DEGREE: Final = "°" # Time units TIME_SECONDS: Final = "s" TIME_HOURS: Final = "h" # Length units LENGTH_CENTIMETERS: Final = "cm" LENGTH_METERS: Final = "m" LENGTH_KILOMETERS: Final = "km" # Frequency units FREQUENCY_HERTZ: Final = "Hz" # Pressure units PRESSURE_HPA: Final = "hPa" PRESSURE_MMHG: Final = "mmHg" # Volume units VOLUME_CUBIC_METERS: Final = f"{LENGTH_METERS}³" # Mass units MASS_KILOGRAMS: Final = "kg" MASS_MICROGRAMS: Final = "µg" # Light units LIGHT_LUX: Final = "lux" # Percentage units PERCENTAGE: Final = "%" # Concentration units CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = ( f"{MASS_MICROGRAMS}/{VOLUME_CUBIC_METERS}" ) CONCENTRATION_PARTS_PER_MILLION: Final = "ppm" CONCENTRATION_PARTS_PER_BILLION: Final = "ppb" # Speed units SPEED_METERS_PER_SECOND: Final = f"{LENGTH_METERS}/{TIME_SECONDS}" SPEED_KILOMETERS_PER_HOUR: Final = f"{LENGTH_KILOMETERS}/{TIME_HOURS}" SPEED_KNOT: Final = "kn" SPEED_MILES_PER_HOUR: Final = "mph" SPEED_FEET_PER_SECOND: Final = "ft/s" SPEED_YARDS_PER_SECOND: Final = "yd/s" # Signal_strength units SIGNAL_STRENGTH_DECIBELS: Final = "dB" SIGNAL_STRENGTH_DECIBELS_MILLIWATT: Final = "dBm" STATE_OFF: Final = 0 STATE_ON: Final = 1 STATE_TOGGLE: Final = 2 STATE_HOLD: Final = 3 STATUS_SENSOR: Final = "StatusSNS" SWITCHMODE_NONE: Final = -1 SWITCHMODE_TOGGLE: Final = 0 SWITCHMODE_FOLLOW: Final = 1 SWITCHMODE_FOLLOW_INV: Final = 2 SWITCHMODE_PUSHBUTTON: Final = 3 SWITCHMODE_PUSHBUTTON_INV: Final = 4 SWITCHMODE_PUSHBUTTONHOLD: Final = 5 SWITCHMODE_PUSHBUTTONHOLD_INV: Final = 6 SWITCHMODE_PUSHBUTTON_TOGGLE: Final = 7 SWITCHMODE_TOGGLEMULTI: Final = 8 SWITCHMODE_FOLLOWMULTI: Final = 9 SWITCHMODE_FOLLOWMULTI_INV: Final = 10 SWITCHMODE_PUSHHOLDMULTI: Final = 11 SWITCHMODE_PUSHHOLDMULTI_INV: Final = 12 SWITCHMODE_PUSHON: Final = 13 SWITCHMODE_PUSHON_INV: Final = 14 SWITCHMODE_PUSH_IGNORE: Final = 15 SWITCHMODE_PUSH_IGNORE_INV: Final = 16 hatasmota-0.10.0/hatasmota/device_status.py000066400000000000000000000137571475766022700210030ustar00rootroot00000000000000"""Tasmota status sensor.""" from __future__ import annotations from dataclasses import dataclass import json import logging from typing import Any from .const import ( CONF_DEEP_SLEEP, CONF_MAC, SENSOR_STATUS_RSSI, SENSOR_STATUS_SIGNAL, SENSOR_STATUS_SSID, ) from .entity import ( TasmotaAvailability, TasmotaAvailabilityConfig, TasmotaEntity, TasmotaEntityConfig, ) from .mqtt import ReceiveMessage from .utils import ( config_get_state_offline, config_get_state_online, get_topic_command_status, get_topic_stat_status, get_topic_tele_state, get_topic_tele_will, get_value_by_path, ) _LOGGER = logging.getLogger(__name__) # 14:45:37 MQT: tasmota_B94927/tele/HASS_STATE = { # "Version":"9.0.0.1(tasmota)", stat/STATUS2:"StatusFWR"."Version" # "BuildDateTime":"2020-10-08T21:38:21", stat/STATUS2:"StatusFWR"."BuildDateTime" # "Module or Template":"Generic", # "RestartReason":"Software/System restart", stat/STATUS1:"StatusPRM"."RestartReason" # "Uptime":"1T17:04:28", stat/STATUS11:"StatusSTS"."Uptime"; tele/STATE:"Uptime" # "BatteryPercentage":60, stat/STATUS11:"StatusSTS":"BatteryPercentage"; tele/STATE: "BatteryPercentage" # "Hostname":"tasmota_B94927", stat/STATUS5:"StatusNET":"Hostname" # "IPAddress":"192.168.0.114", stat/STATUS5:"StatusNET":"IPAddress" # "RSSI":"100", stat/STATUS11:"StatusSTS":"RSSI"; tele/STATE:"RSSI" # "Signal (dBm)":"-49", stat/STATUS11:"StatusSTS":"Signal"; tele/STATE:"Signal" # "WiFi LinkCount":1, stat/STATUS11:"StatusSTS":"LinkCount"; tele/STATE:"LinkCount" # "WiFi Downtime":"0T00:00:03", stat/STATUS11:"StatusSTS":"Downtime"; tele/STATE:"Downtime" # "MqttCount":1, stat/STATUS11:"StatusSTS":"MqttCount"; tele/STATE:"MqttCount" # "LoadAvg":19 stat/STATUS11:"StatusSTS":"LoadAvg"; tele/STATE:"LoadAvg" # } ATTRIBUTES = [ SENSOR_STATUS_RSSI, SENSOR_STATUS_SIGNAL, SENSOR_STATUS_SSID, ] STATE_PATHS: dict[str, list[str | int]] = { SENSOR_STATUS_RSSI: ["Wifi", "RSSI"], SENSOR_STATUS_SIGNAL: ["Wifi", "Signal"], } STATUS_PATHS: dict[str, list[str | int]] = { SENSOR_STATUS_RSSI: ["StatusSTS", "Wifi", "RSSI"], SENSOR_STATUS_SIGNAL: ["StatusSTS", "Wifi", "Signal"], SENSOR_STATUS_SSID: ["StatusSTS", "Wifi", "SSId"], } STATUS_TOPICS = { SENSOR_STATUS_RSSI: 11, SENSOR_STATUS_SIGNAL: 11, SENSOR_STATUS_SSID: 11, } @dataclass(frozen=True, kw_only=True) class TasmotaDeviceStatusConfig(TasmotaAvailabilityConfig, TasmotaEntityConfig): """Tasmota Status Sensor configuration.""" poll_topic: str state_topic: str status_topics: dict[int, str] @classmethod def from_discovery_message(cls, config: dict) -> TasmotaDeviceStatusConfig: """Instantiate from discovery message.""" status_topics = {} for sensor in ATTRIBUTES: if sensor not in STATUS_TOPICS: continue topic = STATUS_TOPICS[sensor] status_topics[topic] = get_topic_stat_status(config, topic) return cls( endpoint="device_status", idx=None, friendly_name=None, mac=config[CONF_MAC], platform="device_status", poll_payload=str(STATUS_TOPICS.get(sensor)), poll_topic=get_topic_command_status(config), availability_topic=get_topic_tele_will(config), availability_offline=config_get_state_offline(config), availability_online=config_get_state_online(config), deep_sleep_enabled=config[CONF_DEEP_SLEEP], state_topic=get_topic_tele_state(config), status_topics=status_topics, ) class TasmotaDeviceStatus(TasmotaAvailability, TasmotaEntity): """Tasmota device status.""" _cfg: TasmotaDeviceStatusConfig def __init__(self, **kwds: Any): """Initialize.""" self._sub_state: dict | None = None super().__init__(**kwds) async def subscribe_topics(self) -> None: """Subscribe to topics.""" def state_message_received(msg: ReceiveMessage) -> None: """Handle new MQTT state messages.""" if not self._on_state_callback: return try: payload = json.loads(msg.payload) except json.decoder.JSONDecodeError: return attributes = {} for attribute in ATTRIBUTES: state = None if msg.topic == self._cfg.state_topic and attribute in STATE_PATHS: state = get_value_by_path(payload, STATE_PATHS[attribute]) elif msg.topic != self._cfg.state_topic and attribute in STATUS_PATHS: state = get_value_by_path(payload, STATUS_PATHS[attribute]) if state: attributes[attribute] = state self._on_state_callback(attributes) availability_topics = self.get_availability_topics() topics = {} # Periodic state update (tele/STATE) topics["state_topic"] = { "event_loop_safe": True, "topic": self._cfg.state_topic, "msg_callback": state_message_received, } for suffix in self._cfg.status_topics: # Polled state update (stat/STATUS#) topics[f"status_topic_{suffix}"] = { "event_loop_safe": True, "topic": self._cfg.status_topics[suffix], "msg_callback": state_message_received, } topics = {**topics, **availability_topics} self._sub_state = await self._mqtt_client.subscribe( self._sub_state, topics, ) async def unsubscribe_topics(self) -> None: """Unsubscribe from all MQTT topics.""" self._sub_state = await self._mqtt_client.unsubscribe(self._sub_state) hatasmota-0.10.0/hatasmota/discovery.py000066400000000000000000000505431475766022700201420ustar00rootroot00000000000000"""Tasmota discovery.""" from __future__ import annotations from itertools import chain import json import logging import voluptuous as vol from . import config_validation as cv from .button import TasmotaButtonTrigger, TasmotaButtonTriggerConfig from .camera import TasmotaCamera, TasmotaCameraConfig from .const import ( CONF_BATTERY, CONF_BUTTON, CONF_CAM, CONF_DEEP_SLEEP, CONF_DEVICENAME, CONF_FRIENDLYNAME, CONF_FULLTOPIC, CONF_HOSTNAME, CONF_IFAN, CONF_IP, CONF_LIGHT_SUBTYPE, CONF_LINK_RGB_CT, CONF_MAC, CONF_MANUFACTURER, CONF_MODEL, CONF_NAME, CONF_OFFLINE, CONF_ONLINE, CONF_OPTIONS, CONF_PREFIX, CONF_RELAY, CONF_SENSOR, CONF_SHUTTER_OPTIONS, CONF_SHUTTER_TILT, CONF_STATE, CONF_SW_VERSION, CONF_SWITCH, CONF_SWITCHNAME, CONF_TOPIC, CONF_TUYA, CONF_VERSION, OPTION_BUTTON_SINGLE, OPTION_BUTTON_SWAP, OPTION_DECIMAL_TEXT, OPTION_FADE_FIXED_DURATION, OPTION_HASS_LIGHT, OPTION_MQTT_BUTTONS, OPTION_MQTT_RESPONSE, OPTION_MQTT_SWITCHES, OPTION_NOT_POWER_LINKED, OPTION_PWM_MULTI_CHANNELS, OPTION_REDUCED_CT_RANGE, OPTION_SHUTTER_MODE, RL_LIGHT, RL_RELAY, RL_SHUTTER, ) from .entity import TasmotaEntity, TasmotaEntityConfig from .fan import TasmotaFan, TasmotaFanConfig from .light import TasmotaLight, TasmotaLightConfig from .models import ( DeviceDiscoveredCallback, DiscoveryHashType, SensorsDiscoveredCallback, TasmotaDeviceConfig, ) from .mqtt import ReceiveMessage, TasmotaMQTTClient from .relay import TasmotaRelay, TasmotaRelayConfig from .sensor import TasmotaBaseSensorConfig, TasmotaSensor, get_sensor_entities from .shutter import TasmotaShutter, TasmotaShutterConfig from .status_sensor import TasmotaStatusSensor, TasmotaStatusSensorConfig from .switch import ( TasmotaSwitch, TasmotaSwitchConfig, TasmotaSwitchTrigger, TasmotaSwitchTriggerConfig, ) from .trigger import TasmotaTrigger, TasmotaTriggerConfig from .utils import discovery_topic_get_mac, discovery_topic_is_device_config TASMOTA_OPTIONS_SCHEMA = vol.Schema( { vol.Optional( OPTION_MQTT_RESPONSE, default=0 ): cv.bit, # Added in Tasmota 9.0.0.4 OPTION_BUTTON_SWAP: cv.bit, OPTION_BUTTON_SINGLE: cv.bit, OPTION_DECIMAL_TEXT: cv.bit, OPTION_NOT_POWER_LINKED: cv.bit, OPTION_HASS_LIGHT: cv.bit, OPTION_PWM_MULTI_CHANNELS: cv.bit, OPTION_MQTT_BUTTONS: cv.bit, vol.Optional( OPTION_SHUTTER_MODE, default=0 ): cv.bit, # Removed in Tasmota 9.0.0.3 OPTION_REDUCED_CT_RANGE: cv.bit, vol.Optional( OPTION_MQTT_SWITCHES, default=0 ): cv.bit, # Added in Tasmota 9.0.0.4 vol.Optional( OPTION_FADE_FIXED_DURATION, default=0 ): cv.bit, # Added in Tasmota 9.3.0 }, required=True, extra=vol.ALLOW_EXTRA, ) TASMOTA_DISCOVERY_SCHEMA = vol.Schema( { vol.Optional( CONF_BATTERY, default=0 ): cv.positive_int, # Added in Tasmota 13.0.0.3 vol.Optional( CONF_DEEP_SLEEP, default=0 ): cv.positive_int, # Added in Tasmota 13.1.0 CONF_BUTTON: vol.All(cv.ensure_list, [cv.positive_int]), CONF_DEVICENAME: cv.string, CONF_FRIENDLYNAME: vol.All(cv.ensure_list, [cv.optional_string]), CONF_FULLTOPIC: cv.string, CONF_HOSTNAME: cv.string, vol.Optional(CONF_IFAN, default=0): cv.bit, # Added in Tasmota 9.0.0.4 vol.Optional(CONF_CAM, default=0): cv.bit, CONF_IP: cv.string, CONF_LIGHT_SUBTYPE: cv.positive_int, CONF_LINK_RGB_CT: cv.bit, CONF_MAC: cv.string, CONF_MODEL: cv.string, CONF_OFFLINE: cv.string, CONF_ONLINE: cv.string, CONF_OPTIONS: TASMOTA_OPTIONS_SCHEMA, CONF_PREFIX: vol.All(cv.ensure_list, [cv.string]), CONF_RELAY: vol.All(cv.ensure_list, [cv.positive_int]), vol.Optional(CONF_SHUTTER_OPTIONS, default=[]): vol.All( cv.ensure_list, [cv.positive_int] ), # Added in Tasmota 9.2 CONF_STATE: vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_SHUTTER_TILT, default=[]): vol.All( cv.ensure_list, [[int]] ), # Added in Tasmota 11.x CONF_SW_VERSION: cv.string, CONF_SWITCH: vol.All(cv.ensure_list, [int]), vol.Optional(CONF_SWITCHNAME, default=[]): vol.All( cv.ensure_list, [cv.optional_string] ), # Added in Tasmota 9.0.0.4 CONF_TOPIC: cv.string, CONF_TUYA: cv.bit, CONF_VERSION: 1, }, required=True, extra=vol.ALLOW_EXTRA, ) TASMOTA_SENSOR_DISCOVERY_SCHEMA = vol.Schema( { CONF_SENSOR: dict, CONF_VERSION: 1, }, required=True, extra=vol.ALLOW_EXTRA, ) _LOGGER = logging.getLogger(__name__) class TasmotaDiscoveryMsg(dict): """Dummy class to allow adding attributes.""" def __init__(self, config: dict, validate: bool = True): """Validate config.""" if validate: config = TASMOTA_DISCOVERY_SCHEMA(config) super().__init__(config) class TasmotaDiscovery: """Help class to store discovery status.""" def __init__(self, discovery_topic: str, mqtt_client: TasmotaMQTTClient): """Initialize.""" self._devices: dict[str, dict] = {} self._sensors: dict[str, dict] = {} self._discovery_topic = discovery_topic self._mqtt_client = mqtt_client self._sub_state: dict | None = None async def start_discovery( self, device_discovered: DeviceDiscoveredCallback, sensors_discovered: SensorsDiscoveredCallback, ) -> None: """Start receiving discovery messages.""" await self._subscribe_discovery_topic(device_discovered, sensors_discovered) async def stop_discovery(self) -> None: """Stop receiving discovery messages.""" self._sub_state = await self._mqtt_client.subscribe(self._sub_state, {}) async def _subscribe_discovery_topic( self, device_discovered: DeviceDiscoveredCallback, sensors_discovered: SensorsDiscoveredCallback | None, ) -> None: """Subscribe to discovery messages.""" async def discovery_message_received(msg: ReceiveMessage) -> None: """Validate a received discovery message.""" _payload = msg.payload payload: dict topic = msg.topic if not (mac := discovery_topic_get_mac(topic, self._discovery_topic)): _LOGGER.warning("Invalid discovery topic %s:", topic) return if discovery_topic_is_device_config(topic): if _payload: try: payload = TasmotaDiscoveryMsg(json.loads(_payload)) except ValueError: _LOGGER.warning( "Invalid discovery message %s: '%s'", mac, _payload ) return if mac != payload[CONF_MAC]: _LOGGER.warning( "MAC mismatch between topic and payload, '%s' != '%s'", mac, payload[CONF_MAC], ) return self._devices[mac] = payload else: if mac not in self._devices: return self._devices.pop(mac, None) payload = {} await device_discovered(payload, mac) if mac in self._devices and mac in self._sensors: sensors: list[tuple[TasmotaBaseSensorConfig, DiscoveryHashType]] sensors = get_sensor_entities( self._sensors[mac], self._devices[mac] ) sensors.extend(get_status_sensor_entities(self._devices[mac])) if sensors_discovered: await sensors_discovered(sensors, mac) else: if _payload: try: payload = json.loads(_payload) except ValueError: _LOGGER.warning( "Invalid discovery message %s: '%s'", mac, _payload ) return self._sensors[mac] = payload else: self._sensors.pop(mac, None) payload = {} if mac not in self._devices: return sensors = [] if payload: sensors = get_sensor_entities(payload, self._devices[mac]) sensors.extend(get_status_sensor_entities(self._devices[mac])) if sensors_discovered: await sensors_discovered(sensors, mac) topics = { "discovery_topic": { "topic": f"{self._discovery_topic}/#", "msg_callback": discovery_message_received, } } self._sub_state = await self._mqtt_client.subscribe(self._sub_state, topics) async def clear_discovery_topic(self, mac: str, discovery_prefix: str) -> None: """Clear retained discovery topic.""" mac = mac.replace(":", "") mac = mac.upper() device_discovery_topic = None sensor_discovery_topic = None if mac in self._devices: device_discovery_topic = f"{discovery_prefix}/{mac}/config" self._devices.pop(mac) if mac in self._sensors: sensor_discovery_topic = f"{discovery_prefix}/{mac}/sensors" self._sensors.pop(mac) if device_discovery_topic: await self._mqtt_client.publish(device_discovery_topic, "", retain=True) if sensor_discovery_topic: await self._mqtt_client.publish(sensor_discovery_topic, "", retain=True) def get_device_config_helper(discovery_msg: dict) -> TasmotaDeviceConfig: """Generate device configuration.""" if not discovery_msg: return {} device_config: TasmotaDeviceConfig = { CONF_IP: discovery_msg[CONF_IP], CONF_MAC: discovery_msg[CONF_MAC], CONF_MANUFACTURER: "Tasmota", CONF_MODEL: discovery_msg[CONF_MODEL], CONF_NAME: discovery_msg[CONF_DEVICENAME], CONF_SW_VERSION: discovery_msg[CONF_SW_VERSION], } return device_config def get_device_config(discovery_msg: dict) -> TasmotaDeviceConfig: """Generate device configuration.""" return get_device_config_helper(discovery_msg) def get_binary_sensor_entities( discovery_msg: dict, ) -> list[tuple[TasmotaSwitchConfig | None, DiscoveryHashType]]: """Generate binary sensor configuration.""" entities: list[tuple[TasmotaSwitchConfig | None, DiscoveryHashType]] = [] for idx, value in enumerate(discovery_msg[CONF_SWITCH]): entity = None discovery_hash = (discovery_msg[CONF_MAC], "binary_sensor", "switch", idx) if value: entity = TasmotaSwitchConfig.from_discovery_message( discovery_msg, idx, "binary_sensor" ) entities.append((entity, discovery_hash)) return entities def get_camera_entities( discovery_msg: dict, ) -> list[tuple[TasmotaCameraConfig | None, DiscoveryHashType]]: """Generate camera configuration.""" camera_entities: list[tuple[TasmotaCameraConfig | None, DiscoveryHashType]] = [] entity = None discovery_hash = (discovery_msg[CONF_MAC], "cam", "cam", 0) if CONF_CAM in discovery_msg and discovery_msg[CONF_CAM]: entity = TasmotaCameraConfig.from_discovery_message(discovery_msg, "camera") camera_entities.append((entity, discovery_hash)) return camera_entities def get_cover_entities( discovery_msg: dict, ) -> list[tuple[TasmotaShutterConfig | None, DiscoveryHashType]]: """Generate cover configuration.""" relays = discovery_msg[CONF_RELAY] shutter_entities: list[tuple[TasmotaShutterConfig | None, DiscoveryHashType]] = [] shutter_indices = [] # Tasmota supports up to 16 shutters, each shutter is assigned two consecutive relays for idx, value in enumerate(chain(relays, [-1])): if idx - 1 in shutter_indices: # This is the 2nd half of a pair, skip continue if value == RL_SHUTTER: if relays[idx + 1] == RL_SHUTTER: shutter_indices.append(idx) _LOGGER.debug("Found shutter pair %s + %s", idx, idx + 1) else: # The 2nd half of the pair is missing, abort _LOGGER.error( "Invalid shutter configuration, relay %s is shutter but %s is not", idx + 1, idx + 2, ) shutter_indices = [] break # pad / truncate the shutter index list to 16 shutter_indices = shutter_indices[:16] + [-1] * (16 - len(shutter_indices)) for idx, relay_idx in enumerate(shutter_indices): entity = None discovery_hash = (discovery_msg[CONF_MAC], "cover", "shutter", idx) if relay_idx != -1: entity = TasmotaShutterConfig.from_discovery_message( discovery_msg, idx, "cover" ) shutter_entities.append((entity, discovery_hash)) return shutter_entities def get_fan_entities( discovery_msg: dict, ) -> list[tuple[TasmotaFanConfig | None, DiscoveryHashType]]: """Generate fan configuration.""" fan_entities: list[tuple[TasmotaFanConfig | None, DiscoveryHashType]] = [] entity = None discovery_hash = (discovery_msg[CONF_MAC], "fan", "fan", "ifan") if discovery_msg[CONF_IFAN]: entity = TasmotaFanConfig.from_discovery_message(discovery_msg, "fan") fan_entities.append((entity, discovery_hash)) return fan_entities def get_switch_entities( discovery_msg: dict, ) -> list[tuple[TasmotaRelayConfig | None, DiscoveryHashType]]: """Generate switch configuration.""" force_light = discovery_msg[CONF_OPTIONS][OPTION_HASS_LIGHT] == 1 switch_entities: list[tuple[TasmotaRelayConfig | None, DiscoveryHashType]] = [] for idx, value in enumerate(discovery_msg[CONF_RELAY]): entity = None discovery_hash = (discovery_msg[CONF_MAC], "switch", "relay", idx) if value == RL_RELAY and not force_light: entity = TasmotaRelayConfig.from_discovery_message( discovery_msg, idx, "switch" ) switch_entities.append((entity, discovery_hash)) return switch_entities def get_light_entities( discovery_msg: dict, ) -> list[tuple[TasmotaLightConfig | TasmotaRelayConfig | None, DiscoveryHashType]]: """Generate light configuration.""" entity: TasmotaLightConfig | TasmotaRelayConfig | None force_light = discovery_msg[CONF_OPTIONS][OPTION_HASS_LIGHT] == 1 light_entities: list[ tuple[TasmotaLightConfig | TasmotaRelayConfig | None, DiscoveryHashType] ] = [] relays = list(discovery_msg[CONF_RELAY]) if discovery_msg[CONF_IFAN] and relays[0] == RL_LIGHT: # Special case for iFan: Single, non dimmable light relays[0] = RL_RELAY for idx, value in enumerate(relays): entity = None discovery_hash = (discovery_msg[CONF_MAC], "light", "light", idx) if value == RL_LIGHT: entity = TasmotaLightConfig.from_discovery_message( discovery_msg, idx, "light" ) light_entities.append((entity, discovery_hash)) for idx, value in enumerate(relays): entity = None discovery_hash = (discovery_msg[CONF_MAC], "light", "relay", idx) if value == RL_RELAY: if force_light or (discovery_msg[CONF_IFAN] and idx == 0): entity = TasmotaRelayConfig.from_discovery_message( discovery_msg, idx, "light" ) light_entities.append((entity, discovery_hash)) return light_entities def get_status_sensor_entities( discovery_msg: dict, ) -> list[tuple[TasmotaStatusSensorConfig, DiscoveryHashType]]: """Generate Status sensors.""" status_sensor_entities: list[ tuple[TasmotaStatusSensorConfig, DiscoveryHashType] ] = [] entities = TasmotaStatusSensorConfig.from_discovery_message( discovery_msg, "status_sensor" ) for entity in entities: discovery_hash = ( discovery_msg[CONF_MAC], "status_sensor", "status_sensor", entity.sensor, ) status_sensor_entities.append((entity, discovery_hash)) return status_sensor_entities def get_entities_for_platform( discovery_msg: dict, platform: str ) -> list[tuple[TasmotaEntityConfig | None, DiscoveryHashType]]: """Generate configuration for the given platform.""" entities: list[tuple[TasmotaEntityConfig | None, DiscoveryHashType]] = [] if platform == "binary_sensor": entities.extend(get_binary_sensor_entities(discovery_msg)) elif platform == "camera": entities.extend(get_camera_entities(discovery_msg)) elif platform == "cover": entities.extend(get_cover_entities(discovery_msg)) elif platform == "fan": entities.extend(get_fan_entities(discovery_msg)) elif platform == "light": entities.extend(get_light_entities(discovery_msg)) elif platform == "sensor": entities.extend(get_status_sensor_entities(discovery_msg)) elif platform == "switch": entities.extend(get_switch_entities(discovery_msg)) return entities def has_entities_with_platform(discovery_msg: dict, platform: str) -> bool: """Return True if any entity for given platform is enabled.""" entities = get_entities_for_platform(discovery_msg, platform) return any(x is not None for (x, _) in entities) def get_entity( config: TasmotaEntityConfig, mqtt_client: TasmotaMQTTClient ) -> TasmotaEntity | None: """Create entity for the given platform.""" platform = config.platform if platform == "binary_sensor": return TasmotaSwitch(config=config, mqtt_client=mqtt_client) if platform == "camera": return TasmotaCamera(config=config, mqtt_client=mqtt_client) if platform == "cover": return TasmotaShutter(config=config, mqtt_client=mqtt_client) if platform == "fan": return TasmotaFan(config=config, mqtt_client=mqtt_client) if platform == "light": return TasmotaLight(config=config, mqtt_client=mqtt_client) if platform == "sensor": return TasmotaSensor(config=config, mqtt_client=mqtt_client) if platform == "status_sensor": return TasmotaStatusSensor(config=config, mqtt_client=mqtt_client) if platform == "switch": return TasmotaRelay(config=config, mqtt_client=mqtt_client) return None def get_button_triggers(discovery_msg: dict) -> list[TasmotaButtonTriggerConfig]: """Generate binary sensor configuration.""" triggers = [] for idx, _ in enumerate(discovery_msg[CONF_BUTTON]): trigger = TasmotaButtonTriggerConfig.from_discovery_message(discovery_msg, idx) triggers.extend(trigger) return triggers def get_switch_triggers(discovery_msg: dict) -> list[TasmotaSwitchTriggerConfig]: """Generate binary sensor configuration.""" triggers = [] for idx, _ in enumerate(discovery_msg[CONF_SWITCH]): trigger = TasmotaSwitchTriggerConfig.from_discovery_message(discovery_msg, idx) triggers.extend(trigger) return triggers def get_triggers(discovery_msg: dict) -> list[TasmotaTriggerConfig]: """Generate trigger configurations.""" triggers: list[TasmotaTriggerConfig] = [] if CONF_BUTTON in discovery_msg: triggers.extend(get_button_triggers(discovery_msg)) if CONF_SWITCH in discovery_msg: triggers.extend(get_switch_triggers(discovery_msg)) return triggers def get_trigger( config: TasmotaTriggerConfig, mqtt_client: TasmotaMQTTClient ) -> TasmotaTrigger | None: """Create entity for the given platform.""" if config.source == "button": return TasmotaButtonTrigger(config=config, mqtt_client=mqtt_client) if config.source == "switch": return TasmotaSwitchTrigger(config=config, mqtt_client=mqtt_client) return None def unique_id_from_hash(discovery_hash: DiscoveryHashType) -> str: """Generate unique_id from discovery_hash.""" return "_".join(discovery_hash[0:3] + (str(discovery_hash[3]),)) hatasmota-0.10.0/hatasmota/entity.py000066400000000000000000000101061475766022700174360ustar00rootroot00000000000000"""Tasmota discovery.""" from __future__ import annotations from collections.abc import Callable, Coroutine from dataclasses import dataclass import logging from typing import Any from .mqtt import ReceiveMessage, TasmotaMQTTClient _LOGGER = logging.getLogger(__name__) @dataclass(frozen=True, kw_only=True) class TasmotaEntityConfig: """Base class for Tasmota configuation.""" endpoint: str idx: int | str | None friendly_name: str | None mac: str platform: str poll_payload: str poll_topic: str @property def unique_id(self) -> str: """Return unique_id.""" return f"{self.mac}_{self.platform}_{self.endpoint}_{self.idx}" @dataclass(frozen=True, kw_only=True) class TasmotaAvailabilityConfig(TasmotaEntityConfig): """Tasmota availability configuation.""" availability_topic: str availability_offline: str availability_online: str deep_sleep_enabled: bool class TasmotaEntity: """Base class for Tasmota entities.""" def __init__(self, config: TasmotaEntityConfig, mqtt_client: TasmotaMQTTClient): """Initialize.""" self._cfg = config self._mqtt_client = mqtt_client self._on_state_callback: Callable | None = None super().__init__() def config_same(self, new_config: TasmotaEntityConfig) -> bool: """Return if updated config is same as current config.""" return self._cfg == new_config def config_update(self, new_config: TasmotaEntityConfig) -> None: """Update config.""" self._cfg = new_config async def poll_status(self) -> None: """Poll for status.""" await self._mqtt_client.publish_debounced( self._cfg.poll_topic, self._cfg.poll_payload ) def set_on_state_callback(self, on_state_callback: Callable) -> None: """Set callback for state change.""" self._on_state_callback = on_state_callback async def subscribe_topics(self) -> None: """Subscribe to topics.""" async def unsubscribe_topics(self) -> None: """Unsubscribe to all MQTT topics.""" @property def mac(self) -> str: """Return MAC.""" return self._cfg.mac @property def name(self) -> str | None: """Return friendly name.""" return self._cfg.friendly_name @property def unique_id(self) -> str: """Return unique_id.""" return self._cfg.unique_id class TasmotaAvailability(TasmotaEntity): """Availability mixin for Tasmota entities.""" _cfg: TasmotaAvailabilityConfig def __init__(self, **kwds: Any): """Initialize.""" self._on_availability_callback: ( Callable[[bool], Coroutine[Any, Any, None]] | None ) = None super().__init__(**kwds) def get_availability_topics(self) -> dict: """Return MQTT topics to subscribe to for availability state.""" if self.deep_sleep_enabled: return {} async def availability_message_received(msg: ReceiveMessage) -> None: """Handle a new received MQTT availability message.""" if msg.payload == self._cfg.availability_online: await self.poll_status() if not self._on_availability_callback: return if msg.payload == self._cfg.availability_online: await self._on_availability_callback(True) if msg.payload == self._cfg.availability_offline: await self._on_availability_callback(False) topics = { "availability_topic": { "event_loop_safe": True, "msg_callback": availability_message_received, "topic": self._cfg.availability_topic, } } return topics def set_on_availability_callback(self, on_availability_callback: Callable) -> None: """Set callback for availability state change.""" self._on_availability_callback = on_availability_callback @property def deep_sleep_enabled(self) -> bool: """Return if deep sleep is enabled.""" return self._cfg.deep_sleep_enabled hatasmota-0.10.0/hatasmota/fan.py000066400000000000000000000071041475766022700166720ustar00rootroot00000000000000"""Tasmota fan.""" from __future__ import annotations from dataclasses import dataclass import logging from typing import Any from .const import ( COMMAND_FANSPEED, CONF_DEEP_SLEEP, CONF_MAC, FAN_SPEED_HIGH, FAN_SPEED_LOW, FAN_SPEED_MEDIUM, FAN_SPEED_OFF, ) from .entity import ( TasmotaAvailability, TasmotaAvailabilityConfig, TasmotaEntity, TasmotaEntityConfig, ) from .mqtt import ReceiveMessage from .utils import ( config_get_state_offline, config_get_state_online, get_topic_command, get_topic_command_state, get_topic_stat_result, get_topic_tele_state, get_topic_tele_will, get_value_by_path, ) SUPPORTED_FAN_SPEEDS = [FAN_SPEED_OFF, FAN_SPEED_LOW, FAN_SPEED_MEDIUM, FAN_SPEED_HIGH] _LOGGER = logging.getLogger(__name__) @dataclass(frozen=True, kw_only=True) class TasmotaFanConfig(TasmotaAvailabilityConfig, TasmotaEntityConfig): """Tasmota fan configuation.""" command_topic: str result_topic: str state_topic: str @classmethod def from_discovery_message(cls, config: dict, platform: str) -> TasmotaFanConfig: """Instantiate from discovery message.""" return cls( endpoint="fan", idx="ifan", friendly_name=None, mac=config[CONF_MAC], platform=platform, poll_payload="", poll_topic=get_topic_command_state(config), availability_topic=get_topic_tele_will(config), availability_offline=config_get_state_offline(config), availability_online=config_get_state_online(config), deep_sleep_enabled=config[CONF_DEEP_SLEEP], command_topic=get_topic_command(config), result_topic=get_topic_stat_result(config), state_topic=get_topic_tele_state(config), ) class TasmotaFan(TasmotaAvailability, TasmotaEntity): """Representation of a Tasmota fan.""" _cfg: TasmotaFanConfig def __init__(self, **kwds: Any): """Initialize.""" self._sub_state: dict | None = None super().__init__(**kwds) async def subscribe_topics(self) -> None: """Subscribe to topics.""" def state_message_received(msg: ReceiveMessage) -> None: """Handle new MQTT state messages.""" if not self._on_state_callback: return fanspeed: int = get_value_by_path(msg.payload, [COMMAND_FANSPEED]) if fanspeed in SUPPORTED_FAN_SPEEDS: self._on_state_callback(fanspeed) availability_topics = self.get_availability_topics() topics = { "result_topic": { "event_loop_safe": True, "topic": self._cfg.result_topic, "msg_callback": state_message_received, }, "state_topic": { "event_loop_safe": True, "topic": self._cfg.state_topic, "msg_callback": state_message_received, }, } topics = {**topics, **availability_topics} self._sub_state = await self._mqtt_client.subscribe( self._sub_state, topics, ) async def unsubscribe_topics(self) -> None: """Unsubscribe to all MQTT topics.""" self._sub_state = await self._mqtt_client.unsubscribe(self._sub_state) async def set_speed(self, fanspeed: int) -> None: """Set the fan's speed.""" payload = fanspeed command = COMMAND_FANSPEED await self._mqtt_client.publish( self._cfg.command_topic + command, payload, ) hatasmota-0.10.0/hatasmota/light.py000066400000000000000000000416011475766022700172350ustar00rootroot00000000000000"""Tasmota light.""" from __future__ import annotations import colorsys from dataclasses import dataclass import logging from typing import Any, cast from .const import ( COMMAND_CHANNEL, COMMAND_COLOR, COMMAND_CT, COMMAND_DIMMER, COMMAND_FADE, COMMAND_POWER, COMMAND_SCHEME, COMMAND_SPEED, COMMAND_WHITE, CONF_DEEP_SLEEP, CONF_LIGHT_SUBTYPE, CONF_LINK_RGB_CT, CONF_MAC, CONF_OPTIONS, CONF_RELAY, CONF_TUYA, LST_COLDWARM, LST_NONE, LST_RGB, LST_RGBCW, LST_RGBW, LST_SINGLE, OPTION_FADE_FIXED_DURATION, OPTION_NOT_POWER_LINKED, OPTION_PWM_MULTI_CHANNELS, OPTION_REDUCED_CT_RANGE, RL_LIGHT, ) from .entity import ( TasmotaAvailability, TasmotaAvailabilityConfig, TasmotaEntity, TasmotaEntityConfig, ) from .mqtt import ReceiveMessage, send_commands from .utils import ( config_get_friendlyname, config_get_state_offline, config_get_state_online, config_get_state_power_off, config_get_state_power_on, get_state_power, get_topic_command, get_topic_command_state, get_topic_stat_result, get_topic_tele_state, get_topic_tele_will, get_value_by_path, ) LIGHT_TYPE_NONE = 0 LIGHT_TYPE_DIMMER = 1 LIGHT_TYPE_COLDWARM = 2 LIGHT_TYPE_RGB = 3 LIGHT_TYPE_RGBW = 4 LIGHT_TYPE_RGBCW = 5 LIGHT_TYPE_MAP = { LIGHT_TYPE_NONE: LST_NONE, LIGHT_TYPE_DIMMER: LST_SINGLE, LIGHT_TYPE_COLDWARM: LST_COLDWARM, LIGHT_TYPE_RGB: LST_RGB, LIGHT_TYPE_RGBW: LST_RGBW, LIGHT_TYPE_RGBCW: LST_RGBCW, } DEFAULT_MIN_MIREDS = 153 DEFAULT_MAX_MIREDS = 500 REDUCED_MIN_MIREDS = 200 REDUCED_MAX_MIREDS = 380 _LOGGER = logging.getLogger(__name__) @dataclass(frozen=True, kw_only=True) class TasmotaLightConfig(TasmotaAvailabilityConfig, TasmotaEntityConfig): """Tasmota light configuation.""" idx: int dimmer_cmd: str dimmer_state: str color_suffix: str command_topic: str control_by_channel: bool fade_fixed_duration: bool light_type: int max_mireds: int min_mireds: int not_power_linked: bool poll_topic: str result_topic: str state_power_off: str state_power_on: str state_topic: str tuya: bool @classmethod def from_discovery_message( cls, config: dict, idx: int, platform: str ) -> TasmotaLightConfig: """Instantiate from discovery message.""" color_suffix = "" dimmer_cmd = COMMAND_DIMMER dimmer_state = COMMAND_DIMMER control_by_channel = False # Use Channel command to control the light if config[CONF_TUYA]: dimmer_idx = 3 # Brightness controlled by DIMMER3 dimmer_cmd = f"{COMMAND_DIMMER}{dimmer_idx}" tasmota_light_sub_type = config[CONF_LIGHT_SUBTYPE] light_type = LIGHT_TYPE_MAP[tasmota_light_sub_type] if config[CONF_OPTIONS][OPTION_PWM_MULTI_CHANNELS]: # Multi-channel PWM instead of a single light, each light controlled by CHANNEL dimmer_state = f"{COMMAND_CHANNEL}{idx + 1}" control_by_channel = True light_type = LIGHT_TYPE_DIMMER elif not config[CONF_LINK_RGB_CT] and tasmota_light_sub_type >= LST_RGBW: # Split light in RGB (idx==0) + White/CT (idx==1) first_light = config[CONF_RELAY].index(RL_LIGHT) if idx - first_light == 0: dimmer_idx = 1 # Brightness controlled by DIMMER1 light_type = LIGHT_TYPE_RGB color_suffix = "=" if idx - first_light == 1: dimmer_idx = 2 # Brightness controlled by DIMMER2 if tasmota_light_sub_type == LST_RGBW: light_type = LIGHT_TYPE_DIMMER else: light_type = LIGHT_TYPE_COLDWARM dimmer_cmd = f"{COMMAND_DIMMER}{dimmer_idx}" dimmer_state = f"{COMMAND_DIMMER}{dimmer_idx}" min_mireds = DEFAULT_MIN_MIREDS max_mireds = DEFAULT_MAX_MIREDS if config[CONF_OPTIONS][OPTION_REDUCED_CT_RANGE]: min_mireds = REDUCED_MIN_MIREDS max_mireds = REDUCED_MAX_MIREDS return cls( endpoint="light", idx=idx, friendly_name=config_get_friendlyname(config, platform, idx), mac=config[CONF_MAC], platform=platform, poll_payload="", poll_topic=get_topic_command_state(config), availability_topic=get_topic_tele_will(config), availability_offline=config_get_state_offline(config), availability_online=config_get_state_online(config), deep_sleep_enabled=config[CONF_DEEP_SLEEP], dimmer_cmd=dimmer_cmd, dimmer_state=dimmer_state, color_suffix=color_suffix, command_topic=get_topic_command(config), control_by_channel=control_by_channel, fade_fixed_duration=config[CONF_OPTIONS][OPTION_FADE_FIXED_DURATION], light_type=light_type, max_mireds=max_mireds, min_mireds=min_mireds, not_power_linked=config[CONF_OPTIONS][OPTION_NOT_POWER_LINKED], result_topic=get_topic_stat_result(config), state_power_off=config_get_state_power_off(config), state_power_on=config_get_state_power_on(config), state_topic=get_topic_tele_state(config), tuya=config[CONF_TUYA], ) class TasmotaLight(TasmotaAvailability, TasmotaEntity): """Representation of a Tasmota light.""" _cfg: TasmotaLightConfig def __init__(self, **kwds: Any): """Initialize.""" self._brightness = None self._color: list[float] | None = None self._color_temp: int | None = None self._state: bool | None = None self._sub_state: dict | None = None super().__init__(**kwds) async def subscribe_topics(self) -> None: """Subscribe to topics.""" def state_message_received(msg: ReceiveMessage) -> None: """Handle new MQTT state messages.""" if not self._on_state_callback: return attributes = {} idx = self._cfg.idx if self._cfg.endpoint == "light": if self._cfg.light_type != LIGHT_TYPE_NONE: brightness = get_value_by_path( msg.payload, [self._cfg.dimmer_state] ) if brightness is not None: self._brightness = brightness attributes["brightness"] = brightness if ( color := get_value_by_path(msg.payload, [COMMAND_COLOR]) ) is not None: if color.find(",") != -1: color = color.split(",", 3) else: color = [ int(color[i : i + 2], 16) for i in range(0, len(color), 2) ] if len(color) >= 3: color = [float(color[0]), float(color[1]), float(color[2])] self._color = color attributes["color"] = color if ( color_hsb := get_value_by_path(msg.payload, ["HSBColor"]) ) is not None: color_hsb = color_hsb.split(",", 3) if len(color_hsb) == 3: color_hs = [float(color_hsb[0]), float(color_hsb[1])] attributes["color_hs"] = color_hs if ( color_temp := get_value_by_path(msg.payload, [COMMAND_CT]) ) is not None: self._color_temp = color_temp attributes["color_temp"] = color_temp scheme = get_value_by_path(msg.payload, [COMMAND_SCHEME]) if scheme is not None and self.effect_list: try: attributes["effect"] = self.effect_list[scheme] except IndexError: attributes["effect"] = f"Scheme {scheme}" _LOGGER.debug("Unknown scheme %s", scheme) if ( white_value := get_value_by_path(msg.payload, [COMMAND_WHITE]) ) is not None: attributes["white_value"] = white_value state = get_state_power(cast(str, msg.payload), idx) if state == self._cfg.state_power_on: self._state = True self._on_state_callback(True, attributes=attributes) elif state == self._cfg.state_power_off: self._state = False self._on_state_callback(False, attributes=attributes) availability_topics = self.get_availability_topics() topics = { "result_topic": { "event_loop_safe": True, "topic": self._cfg.result_topic, "msg_callback": state_message_received, }, "state_topic": { "event_loop_safe": True, "topic": self._cfg.state_topic, "msg_callback": state_message_received, }, } topics = {**topics, **availability_topics} self._sub_state = await self._mqtt_client.subscribe( self._sub_state, topics, ) async def unsubscribe_topics(self) -> None: """Unsubscribe to all MQTT topics.""" self._sub_state = await self._mqtt_client.unsubscribe(self._sub_state) @property def effect_list(self) -> list[str] | None: """Return effect list.""" if self._cfg.endpoint == "light": return ["Solid", "Wake up", "Cycle up", "Cycle down", "Random"] return None @property def light_type(self) -> int: """Return light type.""" if self._cfg.endpoint == "light": return self._cfg.light_type return LIGHT_TYPE_NONE @property def min_mireds(self) -> int: """Return the coldest color_temp that this light supports.""" return self._cfg.min_mireds @property def max_mireds(self) -> int: """Return the warmest color_temp that this light supports.""" return self._cfg.max_mireds async def set_state(self, state: bool, attributes: dict[str, Any]) -> None: """Turn the light on or off.""" if self._cfg.endpoint == "relay": await self._set_state_relay(state) else: await self._set_state_light(state, attributes) @property def supports_transition(self) -> bool: """Return if the light supports transitions.""" return self.light_type != LIGHT_TYPE_NONE and not self._cfg.tuya async def _set_state_relay(self, state: bool) -> None: """Turn the relay on or off.""" payload = self._cfg.state_power_on if state else self._cfg.state_power_off command = f"{COMMAND_POWER}{self._cfg.idx + 1}" await self._mqtt_client.publish( self._cfg.command_topic + command, payload, ) async def _set_state_light(self, state: bool, attributes: dict[str, Any]) -> None: idx = self._cfg.idx commands: list[tuple[str, str | float]] = [] transition = attributes.get("transition", 0) do_transition = transition > 0 argument: str | float # Set fade if self.supports_transition and "transition" in attributes: command = COMMAND_FADE argument = 1 if do_transition else 0 commands.append((command, argument)) if do_transition: speed = self._calculate_speed(state, attributes) # Clamp speed to the range 1..40 speed = min(max(speed, 1), 40) command = COMMAND_SPEED commands.append((command, speed)) argument = self._cfg.state_power_on if state else self._cfg.state_power_off command = f"{COMMAND_POWER}{idx + 1}" if "brightness" in attributes: argument = attributes["brightness"] if self._cfg.control_by_channel: command = f"{COMMAND_CHANNEL}{idx + 1}" else: command = self._cfg.dimmer_cmd commands.append((command, argument)) if "color" in attributes: color = attributes["color"] argument = f"{color[0]},{color[1]},{color[2]}{self._cfg.color_suffix}" command = f"{COMMAND_COLOR}2" commands.append((command, argument)) if "color_hs" in attributes: argument = round(attributes["color_hs"][0]) command = "HsbColor1" # Hue commands.append((command, argument)) argument = round(attributes["color_hs"][1]) command = "HsbColor2" # Saturation commands.append((command, argument)) if "color_temp" in attributes: argument = attributes["color_temp"] command = COMMAND_CT commands.append((command, argument)) if "effect" in attributes and self.effect_list: try: effect = attributes["effect"] argument = self.effect_list.index(effect) command = COMMAND_SCHEME commands.append((command, argument)) except ValueError: _LOGGER.debug("Unknown effect %s", effect) if "white_value" in attributes: argument = attributes["white_value"] command = COMMAND_WHITE commands.append((command, argument)) if self._cfg.not_power_linked and "brightness" in attributes: # Always send power argument = self._cfg.state_power_on if state else self._cfg.state_power_off command = f"{COMMAND_POWER}{idx + 1}" commands.append((command, argument)) await send_commands(self._mqtt_client, self._cfg.command_topic, commands) def _calculate_speed(self, state: bool, attributes: dict[str, Any]) -> float: # Calculate speed: # Home Assistant's transition is the transition time in seconds. # Tasmota's speed command is the number of half-seconds, scaled for a 100% fade # if fade_fixed_duration (SetOption117) is not set transition: int = attributes.get("transition", 0) if self._cfg.fade_fixed_duration: # Fading at fixed duration return round(transition * 2) old_brightness = self._brightness if self._brightness is not None else 100 now_brightness = old_brightness if self._state else 0 new_brightness = attributes.get("brightness", old_brightness if state else 0) now_channels = [] new_channels = [] # Calculate normalized brightness for all channels if self.light_type >= LIGHT_TYPE_COLDWARM: if self.light_type >= LIGHT_TYPE_RGB and self._color: if "color" in attributes: new_color = attributes["color"] elif "color_hs" in attributes: # Convert hs_color to color color_hs = attributes["color_hs"] rgb = colorsys.hsv_to_rgb(color_hs[0] / 360, color_hs[1] / 100, 1) rgb = (int(rgb[0] * 255), int(rgb[1] * 255), int(rgb[2] * 255)) new_color = rgb else: new_color = self._color now_color = [x / 255 for x in self._color] new_color = [x / 255 for x in new_color] now_channels.extend(now_color) new_channels.extend(new_color) if self.light_type >= LIGHT_TYPE_COLDWARM and self._color_temp: now_color_temp = self._color_temp new_color_temp = attributes.get("color_temp", self._color_temp) mired_range = self.max_mireds - self.min_mireds now_ct_ratio = (now_color_temp - self.min_mireds) / mired_range new_ct_ratio = (new_color_temp - self.min_mireds) / mired_range now_channels.append(now_ct_ratio) new_channels.append(new_ct_ratio) now_channels = [x * now_brightness / 100 for x in now_channels] new_channels = [x * new_brightness / 100 for x in new_channels] # 1-channel dimmer, or color / color_temp unknown if not new_channels: new_channels = [new_brightness / 100] now_channels = [now_brightness / 100] # Scale transition to the channel with the largest brightness change abs_changes = map( abs, [x1 - x2 for (x1, x2) in zip(now_channels, new_channels)] ) # Mypy is confused about the map, override the inferred typing if (delta_ratio := max(abs_changes)) == 0: speed = 0 else: speed = round(transition * 2 / delta_ratio) return speed hatasmota-0.10.0/hatasmota/models.py000066400000000000000000000014531475766022700174120ustar00rootroot00000000000000"""Tasmota types.""" from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any, TypedDict from .entity import TasmotaAvailabilityConfig, TasmotaEntityConfig @dataclass(frozen=True, kw_only=True) class TasmotaBaseSensorConfig(TasmotaAvailabilityConfig, TasmotaEntityConfig): """Tasmota Base Sensor configuration.""" DiscoveryHashType = tuple[str, str, str, str | int] DeviceDiscoveredCallback = Callable[[dict, str], Coroutine[Any, Any, None]] SensorsDiscoveredCallback = Callable[ [list[tuple[TasmotaBaseSensorConfig, DiscoveryHashType]], str], Coroutine[Any, Any, None], ] class TasmotaDeviceConfig(TypedDict, total=False): """Tasmota device config.""" ip: str mac: str manufacturer: str md: str name: str sw: str hatasmota-0.10.0/hatasmota/mqtt.py000066400000000000000000000066601475766022700171210ustar00rootroot00000000000000"""Tasmota MQTT.""" from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine from dataclasses import dataclass import logging from typing import Any from .const import COMMAND_BACKLOG DEBOUNCE_TIMEOUT = 1 _LOGGER = logging.getLogger(__name__) class Timer: """Simple timer.""" def __init__( self, timeout: float, callback: Callable[[], Coroutine[Any, Any, None]] ): self._timeout = timeout self._callback = callback self._task = asyncio.ensure_future(self._job()) async def _job(self) -> None: await asyncio.sleep(self._timeout) await self._callback() def cancel(self) -> None: """Cancel the timer.""" self._task.cancel() PublishPayloadType = str | bytes | int | float | None ReceivePayloadType = str | bytes @dataclass(frozen=True) class PublishMessage: """MQTT Message.""" topic: str payload: PublishPayloadType qos: int | None retain: bool | None @dataclass(frozen=True) class ReceiveMessage: """MQTT Message.""" topic: str payload: ReceivePayloadType qos: int retain: bool class TasmotaMQTTClient: """Helper class to sue an external MQTT client.""" def __init__( self, publish: Callable[ [str, PublishPayloadType, int | None, bool | None], Coroutine[Any, Any, None], ], subscribe: Callable[[dict | None, dict], Coroutine[Any, Any, dict]], unsubscribe: Callable[[dict | None], Coroutine[Any, Any, dict]], ): """Initialize.""" self._pending_messages: dict[PublishMessage, Timer] = {} self._publish = publish self._subscribe = subscribe self._unsubscribe = unsubscribe async def publish( self, topic: str, payload: PublishPayloadType, qos: int | None = 0, retain: bool | None = False, ) -> None: """Publish a message.""" return await self._publish(topic, payload, qos, retain) async def publish_debounced( self, topic: str, payload: PublishPayloadType, qos: int | None = 0, retain: bool | None = False, ) -> None: """Publish a message, with debounce.""" msg = PublishMessage(topic, payload, qos, retain) async def publish_callback() -> None: _LOGGER.debug("publish_debounced: publishing %s", msg) self._pending_messages.pop(msg) await self.publish(msg.topic, msg.payload, qos=msg.qos, retain=msg.retain) if msg in self._pending_messages: timer = self._pending_messages.pop(msg) timer.cancel() timer = Timer(DEBOUNCE_TIMEOUT, publish_callback) self._pending_messages[msg] = timer async def subscribe(self, sub_state: dict | None, topics: dict) -> dict: """Subscribe to topics.""" return await self._subscribe(sub_state, topics) async def unsubscribe(self, sub_state: dict | None) -> dict: """Unsubscribe from topics.""" return await self._unsubscribe(sub_state) async def send_commands( mqtt_client: TasmotaMQTTClient, command_topic: str, commands: list[tuple[str, str | float]], ) -> None: """Send a sequence of commands.""" backlog_topic = command_topic + COMMAND_BACKLOG backlog = ";".join([f"NoDelay;{command[0]} {command[1]}" for command in commands]) await mqtt_client.publish(backlog_topic, backlog) hatasmota-0.10.0/hatasmota/relay.py000066400000000000000000000077011475766022700172450ustar00rootroot00000000000000"""Tasmota switch.""" from __future__ import annotations from dataclasses import dataclass import logging from typing import Any, cast from .const import COMMAND_POWER, CONF_DEEP_SLEEP, CONF_MAC from .entity import ( TasmotaAvailability, TasmotaAvailabilityConfig, TasmotaEntity, TasmotaEntityConfig, ) from .mqtt import ReceiveMessage from .utils import ( config_get_friendlyname, config_get_state_offline, config_get_state_online, config_get_state_power_off, config_get_state_power_on, get_state_power, get_topic_command, get_topic_command_state, get_topic_stat_result, get_topic_tele_state, get_topic_tele_will, ) _LOGGER = logging.getLogger(__name__) @dataclass(frozen=True, kw_only=True) class TasmotaRelayConfig(TasmotaAvailabilityConfig, TasmotaEntityConfig): """Tasmota relay configuation.""" idx: int command_topic: str result_topic: str state_power_off: str state_power_on: str state_topic: str @classmethod def from_discovery_message( cls, config: dict, idx: int, platform: str ) -> TasmotaRelayConfig: """Instantiate from discovery message.""" return cls( endpoint="relay", idx=idx, friendly_name=config_get_friendlyname(config, platform, idx), mac=config[CONF_MAC], platform=platform, poll_payload="", poll_topic=get_topic_command_state(config), availability_topic=get_topic_tele_will(config), availability_offline=config_get_state_offline(config), availability_online=config_get_state_online(config), deep_sleep_enabled=config[CONF_DEEP_SLEEP], command_topic=get_topic_command(config), result_topic=get_topic_stat_result(config), state_power_off=config_get_state_power_off(config), state_power_on=config_get_state_power_on(config), state_topic=get_topic_tele_state(config), ) class TasmotaRelay(TasmotaAvailability, TasmotaEntity): """Representation of a Tasmota relay.""" _cfg: TasmotaRelayConfig def __init__(self, **kwds: Any): """Initialize.""" self._sub_state: dict | None = None self.light_type = None super().__init__(**kwds) async def subscribe_topics(self) -> None: """Subscribe to topics.""" def state_message_received(msg: ReceiveMessage) -> None: """Handle new MQTT state messages.""" if not self._on_state_callback: return state = get_state_power(cast(str, msg.payload), self._cfg.idx) if state == self._cfg.state_power_on: self._on_state_callback(True) elif state == self._cfg.state_power_off: self._on_state_callback(False) availability_topics = self.get_availability_topics() topics = { "result_topic": { "event_loop_safe": True, "topic": self._cfg.result_topic, "msg_callback": state_message_received, }, "state_topic": { "event_loop_safe": True, "topic": self._cfg.state_topic, "msg_callback": state_message_received, }, } topics = {**topics, **availability_topics} self._sub_state = await self._mqtt_client.subscribe( self._sub_state, topics, ) async def unsubscribe_topics(self) -> None: """Unsubscribe to all MQTT topics.""" self._sub_state = await self._mqtt_client.unsubscribe(self._sub_state) async def set_state(self, state: bool) -> None: """Turn the relay on or off.""" payload = self._cfg.state_power_on if state else self._cfg.state_power_off command = f"{COMMAND_POWER}{self._cfg.idx + 1}" await self._mqtt_client.publish( self._cfg.command_topic + command, payload, ) hatasmota-0.10.0/hatasmota/sensor.py000066400000000000000000000456251475766022700174510ustar00rootroot00000000000000"""Tasmota sensor.""" from __future__ import annotations from dataclasses import dataclass import logging import string from typing import Any from .const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, CONF_DEEP_SLEEP, CONF_MAC, CONF_SENSOR, DEGREE, ELECTRICAL_CURRENT_AMPERE, ELECTRICAL_VOLT_AMPERE, ENERGY_KILO_WATT_HOUR, FREQUENCY_HERTZ, LENGTH_CENTIMETERS, LIGHT_LUX, MASS_KILOGRAMS, PERCENTAGE, POWER_WATT, PRESSURE_HPA, PRESSURE_MMHG, REACTIVE_ENERGY_KILO_VOLT_AMPERE_HOUR, REACTIVE_POWER, SENSOR_AMBIENT, SENSOR_BATTERY, SENSOR_CCT, SENSOR_CF1, SENSOR_CF2_5, SENSOR_CF10, SENSOR_CO2, SENSOR_COLOR_BLUE, SENSOR_COLOR_GREEN, SENSOR_COLOR_RED, SENSOR_CURRENT, SENSOR_CURRENT_NEUTRAL, SENSOR_DEWPOINT, SENSOR_DISTANCE, SENSOR_ECO2, SENSOR_ENERGY, SENSOR_ENERGY_EXPORT_ACTIVE, SENSOR_ENERGY_EXPORT_REACTIVE, SENSOR_ENERGY_EXPORT_TARIFF, SENSOR_ENERGY_IMPORT_ACTIVE, SENSOR_ENERGY_IMPORT_REACTIVE, SENSOR_ENERGY_IMPORT_TODAY, SENSOR_ENERGY_IMPORT_TOTAL, SENSOR_ENERGY_IMPORT_TOTAL_TARIFF, SENSOR_ENERGY_IMPORT_YESTERDAY, SENSOR_ENERGY_OTHER, SENSOR_ENERGY_TOTAL_START_TIME, SENSOR_FREQUENCY, SENSOR_HUMIDITY, SENSOR_ILLUMINANCE, SENSOR_MOISTURE, SENSOR_PB0_3, SENSOR_PB0_5, SENSOR_PB1, SENSOR_PB2_5, SENSOR_PB5, SENSOR_PB10, SENSOR_PHASE_ANGLE, SENSOR_PM1, SENSOR_PM2_5, SENSOR_PM10, SENSOR_POWER, SENSOR_POWER_ACTIVE, SENSOR_POWER_APPARENT, SENSOR_POWER_FACTOR, SENSOR_POWER_REACTIVE, SENSOR_PRESSURE, SENSOR_PRESSURE_AT_SEA_LEVEL, SENSOR_PROXIMITY, SENSOR_SPEED, SENSOR_TEMPERATURE, SENSOR_TVOC, SENSOR_UNIT_PRESSURE, SENSOR_UNIT_SPEED, SENSOR_UNIT_TEMPERATURE, SENSOR_VOLTAGE, SENSOR_WEIGHT, SPEED_FEET_PER_SECOND, SPEED_KILOMETERS_PER_HOUR, SPEED_KNOT, SPEED_METERS_PER_SECOND, SPEED_MILES_PER_HOUR, SPEED_YARDS_PER_SECOND, TEMP_CELSIUS, TEMP_FAHRENHEIT, TEMP_KELVIN, VOLT, ) from .entity import TasmotaAvailability, TasmotaEntity from .models import DiscoveryHashType, TasmotaBaseSensorConfig from .mqtt import ReceiveMessage from .utils import ( config_get_state_offline, config_get_state_online, get_topic_command_status, get_topic_stat_status, get_topic_tele_sensor, get_topic_tele_will, get_value_by_path, ) IGNORED_SENSORS = ["Time", "PN532", "RDM6300"] # QUANTITY UNIT CLASS/ICON # SENSOR_AMBIENT LX "dev_cla":"illuminance" # SENSOR_BATTERY % "dev_cla":"battery" # SENSOR_CCT K "ic":"mdi:temperature-kelvin" # SENSOR_CO2 ppm "ic":"mdi:molecule-co2" # SENSOR_COLOR_BLUE B "ic":"mdi:palette" # SENSOR_COLOR_GREEN G "ic":"mdi:palette" # SENSOR_COLOR_RED R "ic":"mdi:palette" # SENSOR_CURRENT A "ic":"mdi:alpha-a-circle-outline" # SENSOR_DEWPOINT "ic":"mdi:weather-rainy" # SENSOR_DISTANCE Cm "ic":"mdi:leak" # SENSOR_ECO2 ppm "ic":"mdi:molecule-co2" # SENSOR_ENERGY_IMPORT_TODAY kWh "dev_cla":"power" # SENSOR_ENERGY_IMPORT_TOTAL kWh "dev_cla":"power" # SENSOR_ENERGY_IMPORT_YESTERDAY kWh "dev_cla":"power" # SENSOR_ENERGY_TOTAL_START_TIME "ic":"mdi:progress-clock" # SENSOR_FREQUENCY Hz "ic":"mdi:current-ac" # SENSOR_HUMIDITY % "dev_cla":"humidity" # SENSOR_ILLUMINANCE LX "dev_cla":"illuminance" # SENSOR_MOISTURE % "ic":"mdi:cup-water" # SENSOR_PB0_3 ppd "ic":"mdi:flask" # SENSOR_PB0_5 ppd "ic":"mdi:flask" # SENSOR_PB1 ppd "ic":"mdi:flask" # SENSOR_PB10 ppd "ic":"mdi:flask" # SENSOR_PB2_5 ppd "ic":"mdi:flask" # SENSOR_PB5 ppd "ic":"mdi:flask" # SENSOR_PM1 µg/m³ "ic":"mdi:air-filter" # SENSOR_PM10 µg/m³ "ic":"mdi:air-filter" # SENSOR_PM2_5 µg/m³ "ic":"mdi:air-filter" # SENSOR_POWER W "dev_cla":"power" # SENSOR_POWER_APPARENT VA "dev_cla":"power" # SENSOR_POWER_FACTOR Cos φ "ic":"mdi:alpha-f-circle-outline" # SENSOR_POWER_REACTIVE VAr "dev_cla":"power" # SENSOR_PRESSURE "dev_cla":"pressure" # SENSOR_PRESSURE_AT_SEA_LEVEL "dev_cla":"pressure" # SENSOR_PROXIMITY "ic":"mdi:ruler" # SENSOR_TEMPERATURE "dev_cla":"temperature" # SENSOR_TVOC ppb "ic":"mdi:air-filter" # SENSOR_VOLTAGE V "ic":"mdi:alpha-v-circle-outline" # SENSOR_WEIGHT Kg "ic":"mdi:scale" SENSOR_UNIT_MAP = { SENSOR_AMBIENT: LIGHT_LUX, SENSOR_BATTERY: PERCENTAGE, SENSOR_CCT: TEMP_KELVIN, SENSOR_CF1: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, SENSOR_CF10: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, SENSOR_CF2_5: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, SENSOR_CO2: CONCENTRATION_PARTS_PER_MILLION, SENSOR_COLOR_BLUE: "B", SENSOR_COLOR_GREEN: "G", SENSOR_COLOR_RED: "R", SENSOR_CURRENT_NEUTRAL: ELECTRICAL_CURRENT_AMPERE, SENSOR_CURRENT: ELECTRICAL_CURRENT_AMPERE, SENSOR_DISTANCE: LENGTH_CENTIMETERS, SENSOR_ECO2: CONCENTRATION_PARTS_PER_MILLION, SENSOR_ENERGY: ENERGY_KILO_WATT_HOUR, SENSOR_ENERGY_EXPORT_ACTIVE: ENERGY_KILO_WATT_HOUR, SENSOR_ENERGY_EXPORT_REACTIVE: REACTIVE_ENERGY_KILO_VOLT_AMPERE_HOUR, SENSOR_ENERGY_EXPORT_TARIFF: ENERGY_KILO_WATT_HOUR, SENSOR_ENERGY_IMPORT_ACTIVE: ENERGY_KILO_WATT_HOUR, SENSOR_ENERGY_IMPORT_REACTIVE: REACTIVE_ENERGY_KILO_VOLT_AMPERE_HOUR, SENSOR_ENERGY_IMPORT_TODAY: ENERGY_KILO_WATT_HOUR, SENSOR_ENERGY_IMPORT_TOTAL_TARIFF: ENERGY_KILO_WATT_HOUR, SENSOR_ENERGY_IMPORT_TOTAL: ENERGY_KILO_WATT_HOUR, SENSOR_ENERGY_IMPORT_YESTERDAY: ENERGY_KILO_WATT_HOUR, SENSOR_ENERGY_TOTAL_START_TIME: None, SENSOR_FREQUENCY: FREQUENCY_HERTZ, SENSOR_HUMIDITY: PERCENTAGE, SENSOR_ILLUMINANCE: LIGHT_LUX, SENSOR_MOISTURE: PERCENTAGE, SENSOR_PB0_3: "ppd", SENSOR_PB0_5: "ppd", SENSOR_PB1: "ppd", SENSOR_PB10: "ppd", SENSOR_PB2_5: "ppd", SENSOR_PB5: "ppd", SENSOR_PHASE_ANGLE: DEGREE, SENSOR_PM1: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, SENSOR_PM10: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, SENSOR_PM2_5: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, SENSOR_POWER: POWER_WATT, SENSOR_POWER_ACTIVE: POWER_WATT, SENSOR_POWER_APPARENT: ELECTRICAL_VOLT_AMPERE, SENSOR_POWER_FACTOR: None, SENSOR_POWER_REACTIVE: REACTIVE_POWER, SENSOR_PROXIMITY: " ", SENSOR_TVOC: CONCENTRATION_PARTS_PER_BILLION, SENSOR_VOLTAGE: VOLT, SENSOR_WEIGHT: MASS_KILOGRAMS, } SUPPORTED_PRESSURE_UNITS = [PRESSURE_HPA, PRESSURE_MMHG] SUPPORTED_SPEED_UNITS = [ SPEED_METERS_PER_SECOND, SPEED_KILOMETERS_PER_HOUR, SPEED_KNOT, SPEED_MILES_PER_HOUR, SPEED_FEET_PER_SECOND, SPEED_YARDS_PER_SECOND, ] SUPPORTED_TEMPERATURE_UNITS = [TEMP_CELSIUS, TEMP_FAHRENHEIT] SENSOR_DYNAMIC_UNIT_MAP = { SENSOR_DEWPOINT: (SENSOR_UNIT_TEMPERATURE, SUPPORTED_TEMPERATURE_UNITS), SENSOR_PRESSURE: (SENSOR_UNIT_PRESSURE, SUPPORTED_PRESSURE_UNITS), SENSOR_PRESSURE_AT_SEA_LEVEL: (SENSOR_UNIT_PRESSURE, SUPPORTED_PRESSURE_UNITS), SENSOR_SPEED: (SENSOR_UNIT_SPEED, SUPPORTED_SPEED_UNITS), SENSOR_TEMPERATURE: (SENSOR_UNIT_TEMPERATURE, SUPPORTED_TEMPERATURE_UNITS), } LAST_RESET_SENSOR_MAP = {SENSOR_ENERGY_IMPORT_TOTAL: SENSOR_ENERGY_TOTAL_START_TIME} _LOGGER = logging.getLogger(__name__) @dataclass(frozen=True, kw_only=True) class TasmotaSensorConfig(TasmotaBaseSensorConfig): """Tasmota Status Sensor configuration.""" discovered_value: Any last_reset_path: list[str | int] | None poll_topic: str quantity: str unit: str | None state_topic1: str state_topic2: str value_path: list[str | int] @classmethod def from_discovery_message( cls, device_config: dict, sensor_config: dict, platform: str, sensor_name: str, value_path: list[str | int], parent_path: list[str | int], quantity: str, discovered_value: Any, ) -> TasmotaSensorConfig: """Instantiate from discovery message.""" unit = SENSOR_UNIT_MAP.get(quantity) if quantity in SENSOR_DYNAMIC_UNIT_MAP: key, supported_units = SENSOR_DYNAMIC_UNIT_MAP[quantity] if (unit := sensor_config[CONF_SENSOR].get(key)) not in supported_units: _LOGGER.warning("Unknown unit %s for %s", unit, quantity) if last_reset_key := LAST_RESET_SENSOR_MAP.get(quantity): last_reset_path = list(parent_path) last_reset_path.append(last_reset_key) else: last_reset_path = None return cls( endpoint="sensor", idx=None, friendly_name=sensor_name, last_reset_path=last_reset_path, mac=device_config[CONF_MAC], platform=platform, poll_payload="10", poll_topic=get_topic_command_status(device_config), availability_topic=get_topic_tele_will(device_config), availability_offline=config_get_state_offline(device_config), availability_online=config_get_state_online(device_config), deep_sleep_enabled=device_config[CONF_DEEP_SLEEP], discovered_value=discovered_value, quantity=quantity, state_topic1=get_topic_tele_sensor(device_config), state_topic2=get_topic_stat_status(device_config, 10), unit=unit, value_path=value_path, ) @property def unique_id(self) -> str: """Return unique_id.""" sensor_id = "_".join([str(i) for i in self.value_path]) return f"{self.mac}_{self.platform}_{self.endpoint}_{sensor_id}" class TasmotaSensor(TasmotaAvailability, TasmotaEntity): """Representation of Tasmota Status Sensors.""" _cfg: TasmotaSensorConfig def __init__(self, **kwds: Any): """Initialize.""" self._sub_state: dict | None = None super().__init__(**kwds) async def subscribe_topics(self) -> None: """Subscribe to topics.""" def state_message_received(msg: ReceiveMessage) -> None: """Handle new MQTT state messages.""" if not self._on_state_callback: return last_reset_path = self._cfg.last_reset_path if msg.topic == self._cfg.state_topic1: state = get_value_by_path(msg.payload, self._cfg.value_path[:-1]) last_node = self._cfg.value_path[-1] elif msg.topic == self._cfg.state_topic2: prefix: list[str | int] = ["StatusSNS"] value_path = prefix + self._cfg.value_path state = get_value_by_path(msg.payload, value_path[:-1]) last_node = value_path[-1] if self._cfg.last_reset_path: last_reset_path = prefix + self._cfg.last_reset_path else: raise ValueError if state is not None: # Indexed sensors may be announced with more indices than present in # the status. Handle this gracefully wihtout throwing. This is a # workaround for energy sensors which are announced with multiple phases # but where the actual sensor sends updates with fewer phases. kwargs = {} try: if hasattr(state, "__getitem__"): state = state[last_node] elif last_node != 0: return except (IndexError, KeyError): return if last_reset_path: if last_reset := get_value_by_path(msg.payload, last_reset_path): kwargs["last_reset"] = last_reset self._on_state_callback(state, **kwargs) availability_topics = self.get_availability_topics() topics = { # Periodic state update (tele/Sensor) "state_topic1": { "event_loop_safe": True, "topic": self._cfg.state_topic1, "msg_callback": state_message_received, }, # Polled state update (stat/STATUS10) "state_topic2": { "event_loop_safe": True, "topic": self._cfg.state_topic2, "msg_callback": state_message_received, }, } topics = {**topics, **availability_topics} self._sub_state = await self._mqtt_client.subscribe( self._sub_state, topics, ) async def unsubscribe_topics(self) -> None: """Unsubscribe to all MQTT topics.""" self._sub_state = await self._mqtt_client.unsubscribe(self._sub_state) @property def discovered_as_numeric(self) -> bool: """Return if the sensor was discovered with a numeric value.""" return isinstance(self._cfg.discovered_value, (float, int)) @property def quantity(self) -> str: """Return the sensor's quantity (speed, mass, etc.).""" return self._cfg.quantity @property def unit(self) -> str | None: """Return the unit this state is expressed in.""" return self._cfg.unit # Simple sensor: # {"INA219":{"Voltage":4.494,"Current":0.020,"Power":0.089}} # Array sensor: # {"ENERGY": # { # "TotalStartTime":"2018-11-23T15:33:47", # "Total":0.017, # "TotalTariff":[0.000,0.017], # "Yesterday":0.000, # "Today":0.002, # "ExportActive":0.000, # "ExportTariff":[0.000,0.000], # "Period":0.00, # "Power":0.00, # "ApparentPower":7.84, # "ReactivePower":-7.21, # "Factor":0.39, # "Frequency":50.0, # "Voltage":234.31, # "Current":0.039, # "ImportActive":12.580, # "ImportReactive":0.002, # "ExportReactive":39.131, # "PhaseAngle":290.45}} # Nested sensor: # { # "Time":"2020-03-03T00:00:00+00:00", # "TX23":{ # "Speed":{"Act":14.8,"Avg":8.5,"Min":12.2,"Max":14.8}, # "Dir":{"Card":"WSW","Deg":247.5,"Avg":266.1,"AvgCard":"W","Min":247.5,"Max":247.5,"Range":0} # }, # "SpeedUnit":"km/h" # } def _get_sensor_entity( sensor_discovery_message: dict, device_discovery_msg: dict, sensor_path: list[str | int], parent_path: list[str | int], quantity: str, discovered_value: Any, ) -> tuple[TasmotaSensorConfig, DiscoveryHashType]: sensorname = " ".join([str(i) for i in sensor_path]) discovery_hash = ( device_discovery_msg[CONF_MAC], "sensor", "sensor", sensorname, ) sensor_config = TasmotaSensorConfig.from_discovery_message( device_discovery_msg, sensor_discovery_message, "sensor", sensorname, sensor_path, parent_path, quantity, discovered_value, ) return (sensor_config, discovery_hash) def _get_quantity( sensorkey: str, subsensorkey: str, subsubsensorkey: str | None, ) -> str: """Get quantity, for example temperature, of a sensor.""" if sensorkey in ["AS3935", "LD2410"] and subsensorkey == SENSOR_ENERGY: # The AS3935 and LD2410 sensor have energy readings which are not in kWh # LD2410: Energy in a range 0..100 # AS3935: Lightning energy in no specified unit return SENSOR_ENERGY_OTHER if subsubsensorkey in SENSOR_UNIT_MAP: # Handle cases where the types of the inner sensors differ # {"ANALOG": {"CTEnergy1": {"Power":2300,"Voltage":230,"Current":10}}} return subsubsensorkey if sensorkey == "ANALOG": # Sensors under ANALOG are suffixed by ADC pin number on the ESP32 if subsensorkey[-1] in string.digits: return subsensorkey[0:-1] return subsensorkey def get_sensor_entities( sensor_discovery_message: dict, device_discovery_msg: dict ) -> list[tuple[TasmotaBaseSensorConfig, DiscoveryHashType]]: """Generate sensor configuration.""" sensor_configs: list[tuple[TasmotaBaseSensorConfig, DiscoveryHashType]] = [] for sensorkey, sensor in sensor_discovery_message[CONF_SENSOR].items(): sensorpath = [sensorkey] if sensorkey in IGNORED_SENSORS or not isinstance(sensor, dict): continue for subsensorkey, subsensor in sensor.items(): subsensorpath = list(sensorpath) subsensorpath.append(subsensorkey) if isinstance(subsensor, dict): # Nested sensor for subsubsensorkey, value in subsensor.items(): subsubsensorpath = list(subsensorpath) subsubsensorpath.append(subsubsensorkey) sensor_configs.append( _get_sensor_entity( sensor_discovery_message, device_discovery_msg, subsubsensorpath, subsubsensorpath[:-1], _get_quantity(sensorkey, subsensorkey, subsubsensorkey), value, ) ) elif isinstance(subsensor, list): # Array sensor for idx, value in enumerate(subsensor): subsubsensorpath = list(subsensorpath) subsubsensorpath.append(idx) sensor_configs.append( _get_sensor_entity( sensor_discovery_message, device_discovery_msg, subsubsensorpath, subsensorpath[:-1], _get_quantity(sensorkey, subsensorkey, None), value, ) ) else: # Simple sensor value = subsensor sensor_configs.append( _get_sensor_entity( sensor_discovery_message, device_discovery_msg, subsensorpath, subsensorpath[:-1], _get_quantity(sensorkey, subsensorkey, None), value, ) ) return sensor_configs hatasmota-0.10.0/hatasmota/shutter.py000066400000000000000000000176721475766022700176370ustar00rootroot00000000000000"""Tasmota shutter.""" from __future__ import annotations from dataclasses import dataclass import logging from typing import Any from .const import ( COMMAND_SHUTTER_CLOSE, COMMAND_SHUTTER_OPEN, COMMAND_SHUTTER_POSITION, COMMAND_SHUTTER_STOP, COMMAND_SHUTTER_TILT, CONF_DEEP_SLEEP, CONF_MAC, CONF_SHUTTER_OPTIONS, CONF_SHUTTER_TILT, RSLT_SHUTTER, SHUTTER_DIRECTION, SHUTTER_OPTION_INVERT, SHUTTER_POSITION, SHUTTER_TILT, STATUS_SENSOR, ) from .entity import ( TasmotaAvailability, TasmotaAvailabilityConfig, TasmotaEntity, TasmotaEntityConfig, ) from .mqtt import ReceiveMessage from .utils import ( config_get_state_offline, config_get_state_online, get_topic_command, get_topic_command_status, get_topic_stat_result, get_topic_stat_status, get_topic_tele_sensor, get_topic_tele_will, get_value_by_path, ) _LOGGER = logging.getLogger(__name__) @dataclass(frozen=True, kw_only=True) class TasmotaShutterConfig(TasmotaAvailabilityConfig, TasmotaEntityConfig): """Tasmota shutter configuation.""" idx: int command_topic: str inverted_shutter: bool state_topic1: str state_topic2: str state_topic3: str tilt_min: int tilt_max: int tilt_dur: int @classmethod def from_discovery_message( cls, config: dict, idx: int, platform: str ) -> TasmotaShutterConfig: """Instantiate from discovery message.""" shutter_options = config[CONF_SHUTTER_OPTIONS] shutter_options = shutter_options[idx] if idx < len(shutter_options) else 0 shutter_tilt = config[CONF_SHUTTER_TILT] shutter_tilt = shutter_tilt[idx] if idx < len(shutter_tilt) else [0, 0, 0] return cls( endpoint="shutter", idx=idx, friendly_name=f"{platform} {idx + 1}", mac=config[CONF_MAC], platform=platform, poll_payload="10", poll_topic=get_topic_command_status(config), availability_topic=get_topic_tele_will(config), availability_offline=config_get_state_offline(config), availability_online=config_get_state_online(config), deep_sleep_enabled=config[CONF_DEEP_SLEEP], command_topic=get_topic_command(config), inverted_shutter=shutter_options & SHUTTER_OPTION_INVERT, state_topic1=get_topic_stat_result(config), state_topic2=get_topic_tele_sensor(config), state_topic3=get_topic_stat_status(config, 10), tilt_min=shutter_tilt[0], tilt_max=shutter_tilt[1], tilt_dur=shutter_tilt[2], ) class TasmotaShutter(TasmotaAvailability, TasmotaEntity): """Representation of a Tasmota shutter.""" _cfg: TasmotaShutterConfig def __init__(self, **kwds: Any): """Initialize.""" self._sub_state: dict | None = None super().__init__(**kwds) async def subscribe_topics(self) -> None: """Subscribe to topics.""" def state_message_received(msg: ReceiveMessage) -> None: """Handle new MQTT state messages.""" if not self._on_state_callback: return shutter = f"{RSLT_SHUTTER}{self._cfg.idx + 1}" prefix: list[str | int] = [] if msg.topic == self._cfg.state_topic3: prefix = [STATUS_SENSOR] direction = get_value_by_path( msg.payload, prefix + [shutter, SHUTTER_DIRECTION] ) if direction is not None and self._cfg.inverted_shutter: direction = direction * -1 position = get_value_by_path( msg.payload, prefix + [shutter, SHUTTER_POSITION] ) if position is not None and self._cfg.inverted_shutter: position = 100 - position tilt = get_value_by_path(msg.payload, prefix + [shutter, SHUTTER_TILT]) ha_tilt = None if tilt is not None: ha_tilt_range = 100 if tasmota_tilt_range := self._cfg.tilt_max - self._cfg.tilt_min: ha_tilt = ( (tilt - self._cfg.tilt_min) * ha_tilt_range / tasmota_tilt_range ) if direction is not None or position is not None or ha_tilt is not None: self._on_state_callback( None, direction=direction, position=position, tilt=ha_tilt ) availability_topics = self.get_availability_topics() topics = { "state_topic1": { "event_loop_safe": True, "topic": self._cfg.state_topic1, "msg_callback": state_message_received, }, "state_topic2": { "event_loop_safe": True, "topic": self._cfg.state_topic2, "msg_callback": state_message_received, }, "state_topic3": { "event_loop_safe": True, "topic": self._cfg.state_topic3, "msg_callback": state_message_received, }, } topics = {**topics, **availability_topics} self._sub_state = await self._mqtt_client.subscribe( self._sub_state, topics, ) async def unsubscribe_topics(self) -> None: """Unsubscribe to all MQTT topics.""" self._sub_state = await self._mqtt_client.unsubscribe(self._sub_state) async def open(self) -> None: """Open the shutter.""" payload = "" command = f"{COMMAND_SHUTTER_OPEN}{self._cfg.idx + 1}" await self._mqtt_client.publish( self._cfg.command_topic + command, payload, ) async def close(self) -> None: """Close the shutter.""" payload = "" command = f"{COMMAND_SHUTTER_CLOSE}{self._cfg.idx + 1}" await self._mqtt_client.publish( self._cfg.command_topic + command, payload, ) async def set_position(self, position: int) -> None: """Set the shutter's position. 0 is closed, 100 is fully open. """ if self._cfg.inverted_shutter: position = 100 - position payload = position command = f"{COMMAND_SHUTTER_POSITION}{self._cfg.idx + 1}" await self._mqtt_client.publish( self._cfg.command_topic + command, payload, ) async def stop(self) -> None: """Stop the shutter.""" payload = "" command = f"{COMMAND_SHUTTER_STOP}{self._cfg.idx + 1}" await self._mqtt_client.publish( self._cfg.command_topic + command, payload, ) @property def supports_tilt(self) -> bool: """Return if the shutter supports tilt.""" return self._cfg.tilt_dur != 0 and (self._cfg.tilt_min != self._cfg.tilt_max) async def open_tilt(self) -> None: """Open the shutter tilt.""" payload = "OPEN" command = f"{COMMAND_SHUTTER_TILT}{self._cfg.idx + 1}" await self._mqtt_client.publish( self._cfg.command_topic + command, payload, ) async def close_tilt(self) -> None: """Close the shutter tilt.""" payload = "CLOSE" command = f"{COMMAND_SHUTTER_TILT}{self._cfg.idx + 1}" await self._mqtt_client.publish( self._cfg.command_topic + command, payload, ) async def set_tilt_position(self, tilt: int) -> None: """Set the shutter's tilt position. 0 is closed, 100 is fully open. """ ha_tilt_range = 100 tasmota_tilt_range = self._cfg.tilt_max - self._cfg.tilt_min tasmota_tilt = self._cfg.tilt_min + (tilt * tasmota_tilt_range / ha_tilt_range) payload = round(tasmota_tilt) command = f"{COMMAND_SHUTTER_TILT}{self._cfg.idx + 1}" await self._mqtt_client.publish( self._cfg.command_topic + command, payload, ) hatasmota-0.10.0/hatasmota/status_sensor.py000066400000000000000000000252321475766022700210440ustar00rootroot00000000000000"""Tasmota status sensor.""" from __future__ import annotations import asyncio from dataclasses import dataclass from datetime import datetime, timedelta, timezone import json import logging from typing import Any from .const import ( CONF_BATTERY, CONF_DEEP_SLEEP, CONF_IP, CONF_MAC, PERCENTAGE, SENSOR_BATTERY, SENSOR_STATUS_BATTERY_PERCENTAGE, SENSOR_STATUS_IP, SENSOR_STATUS_LAST_RESTART_TIME, SENSOR_STATUS_LINK_COUNT, SENSOR_STATUS_MQTT_COUNT, SENSOR_STATUS_RESTART_REASON, SENSOR_STATUS_RSSI, SENSOR_STATUS_SIGNAL, SENSOR_STATUS_SSID, SENSOR_STATUS_VERSION, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, ) from .entity import TasmotaAvailability, TasmotaEntity from .mqtt import ReceiveMessage from .sensor import TasmotaBaseSensorConfig from .utils import ( config_get_state_offline, config_get_state_online, get_topic_command_status, get_topic_stat_status, get_topic_tele_state, get_topic_tele_will, get_value_by_path, ) _LOGGER = logging.getLogger(__name__) # 14:45:37 MQT: tasmota_B94927/tele/HASS_STATE = { # "Version":"9.0.0.1(tasmota)", stat/STATUS2:"StatusFWR"."Version" # "BuildDateTime":"2020-10-08T21:38:21", stat/STATUS2:"StatusFWR"."BuildDateTime" # "Module or Template":"Generic", # "RestartReason":"Software/System restart", stat/STATUS1:"StatusPRM"."RestartReason" # "Uptime":"1T17:04:28", stat/STATUS11:"StatusSTS"."Uptime"; tele/STATE:"Uptime" # "BatteryPercentage":60, stat/STATUS11:"StatusSTS":"BatteryPercentage"; tele/STATE: "BatteryPercentage" # "Hostname":"tasmota_B94927", stat/STATUS5:"StatusNET":"Hostname" # "IPAddress":"192.168.0.114", stat/STATUS5:"StatusNET":"IPAddress" # "RSSI":"100", stat/STATUS11:"StatusSTS":"RSSI"; tele/STATE:"RSSI" # "Signal (dBm)":"-49", stat/STATUS11:"StatusSTS":"Signal"; tele/STATE:"Signal" # "WiFi LinkCount":1, stat/STATUS11:"StatusSTS":"LinkCount"; tele/STATE:"LinkCount" # "WiFi Downtime":"0T00:00:03", stat/STATUS11:"StatusSTS":"Downtime"; tele/STATE:"Downtime" # "MqttCount":1, stat/STATUS11:"StatusSTS":"MqttCount"; tele/STATE:"MqttCount" # "LoadAvg":19 stat/STATUS11:"StatusSTS":"LoadAvg"; tele/STATE:"LoadAvg" # } SENSORS = [ SENSOR_STATUS_IP, SENSOR_STATUS_LAST_RESTART_TIME, SENSOR_STATUS_LINK_COUNT, SENSOR_STATUS_MQTT_COUNT, SENSOR_STATUS_RESTART_REASON, SENSOR_STATUS_RSSI, SENSOR_STATUS_SIGNAL, SENSOR_STATUS_SSID, SENSOR_STATUS_VERSION, ] NAMES = { SENSOR_STATUS_IP: "IP", SENSOR_STATUS_LAST_RESTART_TIME: "Last Restart Time", SENSOR_STATUS_LINK_COUNT: "WiFi Connect Count", SENSOR_STATUS_MQTT_COUNT: "MQTT Connect Count", SENSOR_STATUS_RESTART_REASON: "Restart Reason", SENSOR_STATUS_BATTERY_PERCENTAGE: "Battery Level", SENSOR_STATUS_RSSI: "RSSI", SENSOR_STATUS_SIGNAL: "Signal", SENSOR_STATUS_SSID: "SSID", SENSOR_STATUS_VERSION: "Firmware Version", } SINGLE_SHOT = [ SENSOR_STATUS_LAST_RESTART_TIME, SENSOR_STATUS_RESTART_REASON, SENSOR_STATUS_VERSION, ] STATE_PATHS: dict[str, list[str | int]] = { SENSOR_STATUS_LINK_COUNT: ["Wifi", "LinkCount"], SENSOR_STATUS_MQTT_COUNT: ["MqttCount"], SENSOR_STATUS_BATTERY_PERCENTAGE: ["BatteryPercentage"], SENSOR_STATUS_RSSI: ["Wifi", "RSSI"], SENSOR_STATUS_SIGNAL: ["Wifi", "Signal"], } STATUS_PATHS: dict[str, list[str | int]] = { SENSOR_STATUS_LAST_RESTART_TIME: ["StatusSTS", "UptimeSec"], SENSOR_STATUS_LINK_COUNT: ["StatusSTS", "Wifi", "LinkCount"], SENSOR_STATUS_MQTT_COUNT: ["StatusSTS", "MqttCount"], SENSOR_STATUS_RESTART_REASON: ["StatusPRM", "RestartReason"], SENSOR_STATUS_RSSI: ["StatusSTS", "Wifi", "RSSI"], SENSOR_STATUS_SIGNAL: ["StatusSTS", "Wifi", "Signal"], SENSOR_STATUS_SSID: ["StatusSTS", "Wifi", "SSId"], SENSOR_STATUS_VERSION: ["StatusFWR", "Version"], SENSOR_STATUS_BATTERY_PERCENTAGE: ["StatusSTS", "BatteryPercentage"], } STATUS_TOPICS = { SENSOR_STATUS_LAST_RESTART_TIME: 11, SENSOR_STATUS_LINK_COUNT: 11, SENSOR_STATUS_MQTT_COUNT: 11, SENSOR_STATUS_RESTART_REASON: 1, SENSOR_STATUS_RSSI: 11, SENSOR_STATUS_SIGNAL: 11, SENSOR_STATUS_SSID: 11, SENSOR_STATUS_VERSION: 2, SENSOR_STATUS_BATTERY_PERCENTAGE: 11, } QUANTITY = { SENSOR_STATUS_IP: SENSOR_STATUS_IP, SENSOR_STATUS_LAST_RESTART_TIME: SENSOR_STATUS_LAST_RESTART_TIME, SENSOR_STATUS_LINK_COUNT: SENSOR_STATUS_LINK_COUNT, SENSOR_STATUS_MQTT_COUNT: SENSOR_STATUS_MQTT_COUNT, SENSOR_STATUS_RESTART_REASON: SENSOR_STATUS_RESTART_REASON, SENSOR_STATUS_BATTERY_PERCENTAGE: SENSOR_BATTERY, SENSOR_STATUS_RSSI: SENSOR_STATUS_RSSI, SENSOR_STATUS_SIGNAL: SENSOR_STATUS_SIGNAL, SENSOR_STATUS_SSID: SENSOR_STATUS_SSID, SENSOR_STATUS_VERSION: SENSOR_STATUS_VERSION, } UNITS = { SENSOR_STATUS_IP: None, SENSOR_STATUS_LAST_RESTART_TIME: None, SENSOR_STATUS_LINK_COUNT: None, SENSOR_STATUS_MQTT_COUNT: None, SENSOR_STATUS_RESTART_REASON: None, SENSOR_STATUS_BATTERY_PERCENTAGE: PERCENTAGE, SENSOR_STATUS_RSSI: PERCENTAGE, SENSOR_STATUS_SIGNAL: SIGNAL_STRENGTH_DECIBELS_MILLIWATT, SENSOR_STATUS_SSID: None, SENSOR_STATUS_VERSION: None, } @dataclass(frozen=True, kw_only=True) class TasmotaStatusSensorConfig(TasmotaBaseSensorConfig): """Tasmota Status Sensor configuration.""" poll_topic: str sensor: str state: str | None state_topic: str status_topic: str @classmethod def from_discovery_message( cls, config: dict, platform: str ) -> list[TasmotaStatusSensorConfig]: """Instantiate from discovery message.""" sensor_types = list(SENSORS) if config[CONF_BATTERY] == 1: sensor_types.append(SENSOR_STATUS_BATTERY_PERCENTAGE) sensors = [ cls( endpoint="status_sensor", idx=None, friendly_name=NAMES[sensor], mac=config[CONF_MAC], platform=platform, poll_payload=str(STATUS_TOPICS.get(sensor)), poll_topic=get_topic_command_status(config), availability_topic=get_topic_tele_will(config), availability_offline=config_get_state_offline(config), availability_online=config_get_state_online(config), deep_sleep_enabled=config[CONF_DEEP_SLEEP], sensor=sensor, state=config[CONF_IP] if sensor == SENSOR_STATUS_IP else None, state_topic=get_topic_tele_state(config), status_topic=get_topic_stat_status(config, STATUS_TOPICS.get(sensor)), ) for sensor in sensor_types ] return sensors @property def unique_id(self) -> str: """Return unique_id.""" return f"{self.mac}_{self.platform}_{self.endpoint}_{self.sensor}" class TasmotaStatusSensor(TasmotaAvailability, TasmotaEntity): """Tasmota Status sensors.""" _cfg: TasmotaStatusSensorConfig def __init__(self, **kwds: Any): """Initialize.""" self._sub_state: dict | None = None self._sub_state_lock = asyncio.Lock() super().__init__(**kwds) async def _poll_status(self) -> None: """Poll for status.""" await self.subscribe_topics() await self._mqtt_client.publish_debounced( self._cfg.poll_topic, self._cfg.poll_payload ) async def poll_status(self) -> None: """Poll for status.""" await self._poll_status() async def subscribe_topics(self) -> None: """Subscribe to topics.""" def state_message_received(msg: ReceiveMessage) -> None: """Handle new MQTT state messages.""" if not self._on_state_callback: return try: payload = json.loads(msg.payload) except json.decoder.JSONDecodeError: return state = None if msg.topic == self._cfg.state_topic: state = get_value_by_path(payload, STATE_PATHS[self._cfg.sensor]) else: state = get_value_by_path(payload, STATUS_PATHS[self._cfg.sensor]) if state is not None: if self._cfg.sensor in SINGLE_SHOT: asyncio.create_task(self._unsubscribe_state_topics()) if self._cfg.sensor == SENSOR_STATUS_LAST_RESTART_TIME: state = datetime.now(timezone.utc) - timedelta(seconds=int(state)) self._on_state_callback(state) availability_topics = self.get_availability_topics() topics = {} if self._cfg.sensor in STATE_PATHS: # Periodic state update (tele/STATE) topics["state_topic"] = { "event_loop_safe": True, "topic": self._cfg.state_topic, "msg_callback": state_message_received, } if self._cfg.sensor in STATUS_PATHS: # Polled state update (stat/STATUS#) topics["status_topic"] = { "event_loop_safe": True, "topic": self._cfg.status_topic, "msg_callback": state_message_received, } topics = {**topics, **availability_topics} async with self._sub_state_lock: self._sub_state = await self._mqtt_client.subscribe( self._sub_state, topics, ) if self._cfg.state and self._on_state_callback: self._on_state_callback(self._cfg.state) async def _unsubscribe_state_topics(self) -> None: """Unsubscribe from state topics.""" availability_topics = self.get_availability_topics() async with self._sub_state_lock: self._sub_state = await self._mqtt_client.subscribe( self._sub_state, availability_topics, ) async def unsubscribe_topics(self) -> None: """Unsubscribe from all MQTT topics.""" async with self._sub_state_lock: self._sub_state = await self._mqtt_client.unsubscribe(self._sub_state) @property def discovered_as_numeric(self) -> bool: """Return if the sensor was discovered with a numeric value. Not needed for status sensors. """ return False @property def quantity(self) -> str: """Return the sensor's quantity (speed, mass, etc.).""" return QUANTITY[self._cfg.sensor] @property def unit(self) -> str | None: """Return the unit this state is expressed in.""" return UNITS[self._cfg.sensor] hatasmota-0.10.0/hatasmota/switch.py000066400000000000000000000327721475766022700174400ustar00rootroot00000000000000"""Tasmota binary sensor.""" from __future__ import annotations from dataclasses import dataclass import logging from typing import Any from .const import ( CONF_DEEP_SLEEP, CONF_MAC, CONF_STATE, CONF_SWITCH, RSLT_ACTION, STATE_HOLD, STATE_TOGGLE, STATUS_SENSOR, SWITCHMODE_FOLLOW, SWITCHMODE_FOLLOW_INV, SWITCHMODE_FOLLOWMULTI, SWITCHMODE_FOLLOWMULTI_INV, SWITCHMODE_NONE, SWITCHMODE_PUSH_IGNORE, SWITCHMODE_PUSH_IGNORE_INV, SWITCHMODE_PUSHBUTTON, SWITCHMODE_PUSHBUTTON_INV, SWITCHMODE_PUSHBUTTON_TOGGLE, SWITCHMODE_PUSHBUTTONHOLD, SWITCHMODE_PUSHBUTTONHOLD_INV, SWITCHMODE_PUSHHOLDMULTI, SWITCHMODE_PUSHHOLDMULTI_INV, SWITCHMODE_PUSHON, SWITCHMODE_PUSHON_INV, SWITCHMODE_TOGGLE, SWITCHMODE_TOGGLEMULTI, ) from .entity import ( TasmotaAvailability, TasmotaAvailabilityConfig, TasmotaEntity, TasmotaEntityConfig, ) from .mqtt import ReceiveMessage from .trigger import TasmotaTrigger, TasmotaTriggerConfig from .utils import ( config_get_state_offline, config_get_state_online, config_get_state_power_off, config_get_state_power_on, config_get_switchfriendlyname, config_get_switchname, get_topic_command_status, get_topic_stat_result, get_topic_stat_status, get_topic_tele_sensor, get_topic_tele_will, get_value_by_path, ) _LOGGER = logging.getLogger(__name__) # switch matrix for triggers and binary sensor generation when switchtopic is set as custom (default index is 0,0 - TOGGLE, TOGGLE): # SWITCHMODE INTERNAL BINARY STATE -> PRESS STATE -> DOUBLE PRESS STATE -> LONG_PRESS T,H # 0 TOGGLE NO TOGGLE (button_short_press) NONE NONE 1,0 # 1 FOLLOW YES NONE NONE NONE 0,0 # 2 FOLLOW_INV YES NONE NONE NONE 0,0 # 3 PUSHBUTTON YES TOGGLE (button_short_press) NONE NONE 1,0 # 4 PUSHBUTTON_INV YES TOGGLE (button_short_press) NONE NONE 1,0 # 5 PUSHBUTTONHOLD YES TOGGLE (button_short_press) NONE HOLD (button_long_press) 1,2 # 6 PUSHBUTTONHOLD_INV YES TOGGLE (button_short_press) NONE HOLD (button_long_press) 1,2 # 7 PUSHBUTTON_TOGGLE NO TOGGLE (button_short_press) NONE NONE 1,0 # 8 TOGGLEMULTI NO TOGGLE (button_short_press) HOLD (button_double_press) NONE 1,3 # 9 FOLLOWMULTI YES NONE HOLD (button_double_press) NONE 0,3 # 10 FOLLOWMULTI_INV YES NONE HOLD (button_double_press) NONE 0,3 # 11 PUSHHOLDMULTI NO TOGGLE (button_short_press) NONE INC_DEC (button_long_press) 1,0 # INV (not available) CLEAR (not available) # 12 PUSHHOLDMULTI_INV NO TOGGLE (button_short_press) NONE CLEAR (button_long_press) 1,0 # INV (not available) INC_DEC (not available) # 13 PUSHON YES (PIR) NONE NONE NONE 0,0 # 14 PUSHON_INV YES (PIR) NONE NONE NONE 0,0 # 15 PUSH_IGNORE YES NONE NONE NONE 0,0 # 16 PUSH_IGNORE_INV YES NONE NONE NONE 0,0 # Please note: SwitchMode11 and 12 will register just TOGGLE (button_short_press) # Trigger types: "0 = none | 1 = button_short_press | 2 = button_long_press | 3 = button_double_press"; # PIR: automatic off after 1 second SW_TRIG_DOUBLE = "button_double_press" SW_TRIG_LONG = "button_long_press" SW_TRIG_NONE = "none" SW_TRIG_SHORT = "button_short_press" SWITCHMODE_MAP = { SWITCHMODE_NONE: ( False, # binary sensor None, # off delay {STATE_TOGGLE: SW_TRIG_NONE, STATE_HOLD: SW_TRIG_NONE}, ), SWITCHMODE_TOGGLE: ( False, None, {STATE_TOGGLE: SW_TRIG_SHORT, STATE_HOLD: SW_TRIG_NONE}, ), SWITCHMODE_FOLLOW: ( True, None, {STATE_TOGGLE: SW_TRIG_NONE, STATE_HOLD: SW_TRIG_NONE}, ), SWITCHMODE_FOLLOW_INV: ( True, None, {STATE_TOGGLE: SW_TRIG_NONE, STATE_HOLD: SW_TRIG_NONE}, ), SWITCHMODE_PUSHBUTTON: ( True, None, {STATE_TOGGLE: SW_TRIG_SHORT, STATE_HOLD: SW_TRIG_NONE}, ), SWITCHMODE_PUSHBUTTON_INV: ( True, None, {STATE_TOGGLE: SW_TRIG_SHORT, STATE_HOLD: SW_TRIG_NONE}, ), SWITCHMODE_PUSHBUTTONHOLD: ( True, None, {STATE_TOGGLE: SW_TRIG_SHORT, STATE_HOLD: SW_TRIG_LONG}, ), SWITCHMODE_PUSHBUTTONHOLD_INV: ( True, None, {STATE_TOGGLE: SW_TRIG_SHORT, STATE_HOLD: SW_TRIG_LONG}, ), SWITCHMODE_PUSHBUTTON_TOGGLE: ( False, None, {STATE_TOGGLE: SW_TRIG_SHORT, STATE_HOLD: SW_TRIG_NONE}, ), SWITCHMODE_TOGGLEMULTI: ( False, None, {STATE_TOGGLE: SW_TRIG_SHORT, STATE_HOLD: SW_TRIG_DOUBLE}, ), SWITCHMODE_FOLLOWMULTI: ( True, None, {STATE_TOGGLE: SW_TRIG_NONE, STATE_HOLD: SW_TRIG_DOUBLE}, ), SWITCHMODE_FOLLOWMULTI_INV: ( True, None, {STATE_TOGGLE: SW_TRIG_NONE, STATE_HOLD: SW_TRIG_DOUBLE}, ), SWITCHMODE_PUSHHOLDMULTI: ( False, None, {STATE_TOGGLE: SW_TRIG_SHORT, STATE_HOLD: SW_TRIG_NONE}, ), SWITCHMODE_PUSHHOLDMULTI_INV: ( False, None, {STATE_TOGGLE: SW_TRIG_SHORT, STATE_HOLD: SW_TRIG_NONE}, ), SWITCHMODE_PUSHON: ( True, 1, {STATE_TOGGLE: SW_TRIG_NONE, STATE_HOLD: SW_TRIG_NONE}, ), SWITCHMODE_PUSHON_INV: ( True, 1, {STATE_TOGGLE: SW_TRIG_NONE, STATE_HOLD: SW_TRIG_NONE}, ), SWITCHMODE_PUSH_IGNORE: ( True, None, {STATE_TOGGLE: SW_TRIG_NONE, STATE_HOLD: SW_TRIG_NONE}, ), SWITCHMODE_PUSH_IGNORE_INV: ( True, None, {STATE_TOGGLE: SW_TRIG_NONE, STATE_HOLD: SW_TRIG_NONE}, ), } NO_POLL_SWITCHMODES = [SWITCHMODE_PUSHON, SWITCHMODE_PUSHON_INV] @dataclass(frozen=True, kw_only=True) class TasmotaSwitchTriggerConfig(TasmotaTriggerConfig): """Tasmota switch configuation.""" switchname: str @classmethod def from_discovery_message( cls, config: dict, idx: int ) -> list[TasmotaSwitchTriggerConfig]: """Instantiate from discovery message.""" switchmode = config[CONF_SWITCH][idx] _, _, triggers = SWITCHMODE_MAP[switchmode] configs = [] for event, trigger_type in triggers.items(): configs.append( cls( mac=config[CONF_MAC], event=config[CONF_STATE][event], idx=idx, source="switch", subtype=f"switch_{idx + 1}", switchname=config_get_switchname(config, idx), trigger_topic=get_topic_stat_result(config), type=trigger_type, ) ) return configs @property def is_active(self) -> bool: """Return if the trigger is active.""" return self.type != SW_TRIG_NONE @property def trigger_id(self) -> str: """Return trigger id.""" return f"{self.mac}_switch_{self.idx + 1}_{self.event}" class TasmotaSwitchTrigger(TasmotaTrigger): """Representation of a Tasmota switch trigger.""" cfg: TasmotaSwitchTriggerConfig def _trig_message_received(self, msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" event = get_value_by_path(msg.payload, [self.cfg.switchname, RSLT_ACTION]) if event == self.cfg.event and self._on_trigger_callback: self._on_trigger_callback() @dataclass(frozen=True, kw_only=True) class TasmotaSwitchConfig(TasmotaAvailabilityConfig, TasmotaEntityConfig): """Tasmota switch configuation.""" off_delay: int | None poll_topic: str state_power_off: str state_power_on: str state_topic1: str state_topic2: str | None state_topic3: str | None switchname: str @classmethod def from_discovery_message( cls, config: dict, idx: int, platform: str ) -> TasmotaSwitchConfig | None: """Instantiate from discovery message.""" switchmode = config[CONF_SWITCH][idx] state_topic1 = get_topic_stat_result(config) state_topic2 = None state_topic3 = None if switchmode not in NO_POLL_SWITCHMODES: state_topic2 = get_topic_tele_sensor(config) state_topic3 = get_topic_stat_status(config, 10) binary_sensor, off_delay, _ = SWITCHMODE_MAP[switchmode] if not binary_sensor: return None return cls( endpoint="switch", idx=idx, friendly_name=config_get_switchfriendlyname(config, platform, idx), mac=config[CONF_MAC], platform=platform, poll_payload="10", poll_topic=get_topic_command_status(config), availability_topic=get_topic_tele_will(config), availability_offline=config_get_state_offline(config), availability_online=config_get_state_online(config), deep_sleep_enabled=config[CONF_DEEP_SLEEP], off_delay=off_delay, state_power_off=config_get_state_power_off(config), state_power_on=config_get_state_power_on(config), state_topic1=state_topic1, state_topic2=state_topic2, state_topic3=state_topic3, switchname=config_get_switchname(config, idx), ) class TasmotaSwitch(TasmotaAvailability, TasmotaEntity): """Representation of a Tasmota switch.""" _cfg: TasmotaSwitchConfig def __init__(self, **kwds: Any): """Initialize.""" self._sub_state: dict | None = None super().__init__(**kwds) async def subscribe_topics(self) -> None: """Subscribe to topics.""" def state_message_received(msg: ReceiveMessage) -> None: """Handle new MQTT state messages.""" if not self._on_state_callback: return state = None # tasmota_0848A2/stat/RESULT / {"Switch1":{"Action":"ON"}} if msg.topic == self._cfg.state_topic1: state = get_value_by_path( msg.payload, [self._cfg.switchname, RSLT_ACTION] ) # tasmota_0848A2/tele/SENSOR / {"Time":"2020-09-20T09:41:28","Switch1":"ON"} if msg.topic == self._cfg.state_topic2: state = get_value_by_path(msg.payload, [self._cfg.switchname]) # tasmota_0848A2/stat/STATUS10 / {"StatusSNS":{"Time":"2020-09-20T09:41:00","Switch1":"ON"}} if msg.topic == self._cfg.state_topic3: state = get_value_by_path( msg.payload, [STATUS_SENSOR, self._cfg.switchname] ) if state == self._cfg.state_power_on: self._on_state_callback(True) elif state == self._cfg.state_power_off: self._on_state_callback(False) availability_topics = self.get_availability_topics() # tasmota_0848A2/stat/RESULT / {"Switch1":{"Action":"ON"}} # tasmota_0848A2/tele/SENSOR / {"Time":"2020-09-20T09:41:28","Switch1":"ON"} # tasmota_0848A2/stat/STATUS10 / {"StatusSNS":{"Time":"2020-09-20T09:41:00","Switch1":"ON"}} topics = { "state_topic1": { "event_loop_safe": True, "topic": self._cfg.state_topic1, "msg_callback": state_message_received, }, } if self._cfg.state_topic2: topics["state_topic2"] = { "event_loop_safe": True, "topic": self._cfg.state_topic2, "msg_callback": state_message_received, } if self._cfg.state_topic3: topics["state_topic3"] = { "event_loop_safe": True, "topic": self._cfg.state_topic3, "msg_callback": state_message_received, } topics = {**topics, **availability_topics} self._sub_state = await self._mqtt_client.subscribe( self._sub_state, topics, ) async def unsubscribe_topics(self) -> None: """Unsubscribe to all MQTT topics.""" self._sub_state = await self._mqtt_client.unsubscribe(self._sub_state) @property def off_delay(self) -> int | None: """Return off delay.""" return self._cfg.off_delay hatasmota-0.10.0/hatasmota/trigger.py000066400000000000000000000050311475766022700175660ustar00rootroot00000000000000"""Tasmota binary sensor.""" from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass import logging from typing import Any from .const import AUTOMATION_TYPE_TRIGGER from .mqtt import ReceiveMessage, TasmotaMQTTClient _LOGGER = logging.getLogger(__name__) @dataclass(frozen=True, kw_only=True) class TasmotaTriggerConfig(ABC): """Tasmota trigger configuation.""" event: str idx: int mac: str subtype: str source: str trigger_topic: str type: str @property @abstractmethod def is_active(self) -> int: """Return if the trigger is active.""" @property @abstractmethod def trigger_id(self) -> str: """Return trigger id.""" class TasmotaTrigger: """Representation of a Tasmota trigger.""" def __init__( self, config: TasmotaTriggerConfig, mqtt_client: TasmotaMQTTClient, **_kwds: Any ): """Initialize.""" self._sub_state: dict | None = None self.cfg = config self._mqtt_client = mqtt_client self._on_trigger_callback: Callable | None = None def config_same(self, new_config: TasmotaTriggerConfig) -> bool: """Return if updated config is same as current config.""" return self.cfg == new_config def config_update(self, new_config: TasmotaTriggerConfig) -> None: """Update config.""" self.cfg = new_config def set_on_trigger_callback(self, on_trigger_callback: Callable) -> None: """Set callback for triggere.""" self._on_trigger_callback = on_trigger_callback def _trig_message_received(self, msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" async def subscribe_topics(self) -> None: """Subscribe to topics.""" topics = { "trigger_topic": { "event_loop_safe": True, "topic": self.cfg.trigger_topic, "msg_callback": lambda msg: self._trig_message_received( # pylint: disable=unnecessary-lambda msg ), } } self._sub_state = await self._mqtt_client.subscribe( self._sub_state, topics, ) async def unsubscribe_topics(self) -> None: """Unsubscribe to all MQTT topics.""" self._sub_state = await self._mqtt_client.unsubscribe(self._sub_state) @property def automation_type(self) -> str: """Return the automation type.""" return AUTOMATION_TYPE_TRIGGER hatasmota-0.10.0/hatasmota/utils.py000066400000000000000000000157041475766022700172730ustar00rootroot00000000000000"""Tasmota utility functions.""" from __future__ import annotations from collections.abc import Mapping from functools import reduce import json import logging import operator import re from typing import Any, cast from .const import ( CONF_DEVICENAME, CONF_FRIENDLYNAME, CONF_FULLTOPIC, CONF_HOSTNAME, CONF_MAC, CONF_OFFLINE, CONF_ONLINE, CONF_PREFIX, CONF_STATE, CONF_SWITCHNAME, CONF_TOPIC, PREFIX_CMND, PREFIX_STAT, PREFIX_TELE, RSLT_ACTION, RSLT_POWER, RSLT_STATE, STATE_OFF, STATE_ON, ) from .mqtt import ReceivePayloadType _LOGGER = logging.getLogger(__name__) ConfigType = dict[str, str] def get_by_path(root: dict, items: list[str | int]) -> dict: """Access a nested object in root by item sequence.""" return reduce(operator.getitem, items, root) def set_by_path(root: dict, items: list[str | int], value: Any) -> None: """Set a value in a nested object in root by item sequence.""" get_by_path(root, items[:-1])[items[-1]] = value def del_by_path(root: dict, items: list[str | int]) -> None: """Delete a key-value in a nested object in root by item sequence.""" del get_by_path(root, items[:-1])[items[-1]] def _get_topic(config: ConfigType, prefix: str) -> str: topic = config[CONF_FULLTOPIC] topic = topic.replace("%hostname%", config[CONF_HOSTNAME]) topic = topic.replace("%id%", config[CONF_MAC][-6:]) topic = topic.replace("%prefix%", prefix) topic = topic.replace("%topic%", config[CONF_TOPIC]) return topic def _get_topic_cmnd(config: ConfigType) -> str: return _get_topic(config, config[CONF_PREFIX][PREFIX_CMND]) def _get_topic_stat(config: ConfigType) -> str: return _get_topic(config, config[CONF_PREFIX][PREFIX_STAT]) def _get_topic_tele(config: ConfigType) -> str: return _get_topic(config, config[CONF_PREFIX][PREFIX_TELE]) def get_topic_command(config: ConfigType) -> str: """Get command topic.""" return _get_topic_cmnd(config) def get_topic_command_state(config: ConfigType) -> str: """Get topic for command power.""" return _get_topic_cmnd(config) + "STATE" def get_topic_command_status(config: ConfigType) -> str: """Get topic for command power.""" return _get_topic_cmnd(config) + "STATUS" def get_topic_stat(config: ConfigType) -> str: """Get stat topic.""" return _get_topic_stat(config) def get_topic_stat_button_trigger(config: ConfigType, idx: int) -> str: """Get topic for tele state.""" return _get_topic_stat(config) + f"BUTTON{idx + 1}" def get_topic_stat_result(config: ConfigType) -> str: """Get topic for tele state.""" return _get_topic_stat(config) + "RESULT" def get_topic_stat_status(config: ConfigType, idx: int | None = None) -> str: """Get topic for tele state.""" if idx is None: return _get_topic_stat(config) + "STATUS" return _get_topic_stat(config) + f"STATUS{idx}" def get_topic_stat_switch(config: ConfigType, idx: int) -> str: """Get topic for tele state.""" return _get_topic_stat(config) + f"SWITCH{idx + 1}" def get_topic_stat_switch_trigger(config: ConfigType, idx: int) -> str: """Get topic for tele state.""" return _get_topic_stat(config) + f"SWITCH{idx + 1}" def get_topic_tele(config: ConfigType) -> str: """Get tele topic.""" return _get_topic_tele(config) def get_topic_tele_sensor(config: ConfigType) -> str: """Get topic for tele state.""" return _get_topic_tele(config) + "SENSOR" def get_topic_tele_state(config: ConfigType) -> str: """Get topic for tele state.""" return _get_topic_tele(config) + "STATE" def get_topic_tele_will(config: ConfigType) -> str: """Get topic for tele will.""" return _get_topic_tele(config) + "LWT" def config_get_state_power_on(config: ConfigType) -> str: """Get command/result on.""" return config[CONF_STATE][STATE_ON] def config_get_state_power_off(config: ConfigType) -> str: """Get command/result off.""" return config[CONF_STATE][STATE_OFF] def config_get_state_offline(config: ConfigType) -> str: """Get state offline.""" return config[CONF_OFFLINE] def config_get_state_online(config: ConfigType) -> str: """Get state online.""" return config[CONF_ONLINE] def get_value( _status: str, key: str, idx: int | None = None, idx_optional: bool = False ) -> Any: """Get status from JSON formatted status or result.""" try: status = json.loads(_status) except json.decoder.JSONDecodeError: _LOGGER.debug("Invalid JSON '%s'", _status) return None if idx is None: return status.get(key) if key in status and idx_optional and idx == 0: return status[key] key = f"{key}{idx + 1}" return status[key] if key in status else None def get_value_by_path(status: dict | ReceivePayloadType, path: list[str | int]) -> Any: """Get status from JSON formatted status or result by path.""" try: if not isinstance(status, Mapping): status = json.loads(status) return get_by_path(cast(dict, status), path) except (json.decoder.JSONDecodeError, KeyError): return None def get_state_power(status: str, idx: int) -> Any: """Get state power.""" return get_value(status, RSLT_POWER, idx=idx, idx_optional=True) def get_state_state(status: str) -> Any: """Get state of switch.""" return get_value(status, RSLT_STATE) def get_state_button_trigger(status: str) -> Any: """Get state of button.""" return get_value(status, RSLT_ACTION) def config_get_friendlyname(config: ConfigType, platform: str, idx: int) -> str | None: """Get config friendly name.""" friendly_names = config[CONF_FRIENDLYNAME] if idx >= len(friendly_names) or friendly_names[idx] is None: return f"{platform} {idx + 1}" if idx == 0 and friendly_names[idx] == config[CONF_DEVICENAME]: return None return friendly_names[idx] def config_get_switchfriendlyname(config: ConfigType, platform: str, idx: int) -> str: """Get config friendly name.""" switch_names = config[CONF_SWITCHNAME] if idx >= len(switch_names) or switch_names[idx] is None: return f"{platform} {idx + 1}" return switch_names[idx] def config_get_switchname(config: ConfigType, idx: int) -> str: """Get switch name.""" switch_names = config[CONF_SWITCHNAME] if idx >= len(switch_names) or switch_names[idx] is None: return f"Switch{idx + 1}" return switch_names[idx] TOPIC_MATCHER = re.compile(r"^(?P[A-Z0-9_-]+)\/(?:config|sensors)$") def discovery_topic_get_mac(topic: str, discovery_topic: str) -> str | None: """Get MAC from discovery topic.""" topic_trimmed = topic.replace(f"{discovery_topic}/", "", 1) if not (match := TOPIC_MATCHER.match(topic_trimmed)): return None (mac,) = match.groups() return mac def discovery_topic_is_device_config(topic: str) -> bool: """Return True if the discovery topic is device configuration.""" return topic.endswith("config") hatasmota-0.10.0/mypy.ini000066400000000000000000000014611475766022700152720ustar00rootroot00000000000000[mypy] python_version = 3.13 plugins = pydantic.mypy show_error_codes = true local_partial_types = true strict_equality = true no_implicit_optional = true warn_incomplete_stub = true warn_redundant_casts = true warn_unused_configs = true warn_unused_ignores = true enable_error_code = ignore-without-code, redundant-self, truthy-iterable disable_error_code = annotation-unchecked extra_checks = false check_untyped_defs = true disallow_incomplete_defs = true disallow_subclassing_any = true disallow_untyped_calls = true disallow_untyped_decorators = true disallow_untyped_defs = true warn_return_any = true warn_unreachable = true # no_implicit_reexport = true # disallow_any_generics = true [pydantic-mypy] init_forbid_extra = true init_typed = true warn_required_dynamic_aliases = true warn_untyped_fields = true hatasmota-0.10.0/pylintrc000066400000000000000000000011041475766022700153540ustar00rootroot00000000000000[MASTER] load-plugins = pylint.extensions.code_style, pylint.extensions.typing, [BASIC] good-names=id,i,j,k,x,ex,Run,_,fp,T,ev,bit [MESSAGES CONTROL] # Reasons disabled: # format - handled by black disable= consider-using-namedtuple-or-dataclass, duplicate-code, format, too-few-public-methods, too-many-arguments, too-many-branches, too-many-instance-attributes, too-many-locals, too-many-positional-arguments, too-many-return-statements, too-many-statements [REPORTS] score=no [TYPECHECK] [FORMAT] expected-line-ending-format=LF [EXCEPTIONS] hatasmota-0.10.0/requirements-test.txt000066400000000000000000000001251475766022700200300ustar00rootroot00000000000000flake8==7.1.2 pylint==3.3.4 black==25.1.0 isort==6.0.0 mypy==1.15.0 pydantic==2.10.6 hatasmota-0.10.0/requirements.txt000066400000000000000000000000441475766022700170530ustar00rootroot00000000000000voluptuous>=0.12.0 aiohttp>=3.11.12 hatasmota-0.10.0/setup.cfg000066400000000000000000000004331475766022700154120ustar00rootroot00000000000000[wheel] universal = 1 [flake8] # To work with Black max-line-length = 88 # E501: line too long # W503: Line break occurred before a binary operator # E203: Whitespace before ':' # D202 No blank lines allowed after function docstring ignore = E501, W503, E203, D202 hatasmota-0.10.0/setup.py000066400000000000000000000016761475766022700153150ustar00rootroot00000000000000from setuptools import setup, find_packages long_description = open("README.md").read() setup( name="HATasmota", version="0.10.0", license="MIT", url="https://github.com/emontnemery/hatasmota", author="", author_email="", description="Python module to help parse and construct Tasmota MQTT messages.", long_description=long_description, long_description_content_type="text/markdown", packages=find_packages(), zip_safe=False, include_package_data=True, platforms="any", install_requires=list(val.strip() for val in open("requirements.txt")), classifiers=[ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Topic :: Software Development :: Libraries :: Python Modules", ], python_requires='>=3.13', )