├── .github └── workflows │ ├── docker-publish.yaml │ └── tag-release.yaml ├── .gitignore ├── .pylintrc ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── requirements.txt ├── setup.py └── tinytuya2mqtt ├── __init__.py └── main.py /.github/workflows/docker-publish.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: docker-publish 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | permissions: 9 | packages: write 10 | 11 | jobs: 12 | docker-publish: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v2 18 | 19 | # https://github.com/docker/setup-qemu-action 20 | - name: Set up QEMU 21 | uses: docker/setup-qemu-action@v2 22 | 23 | # https://github.com/docker/setup-buildx-action 24 | - name: Set up Docker Buildx 25 | uses: docker/setup-buildx-action@v2 26 | 27 | - name: Login to GHCR 28 | uses: docker/login-action@v2 29 | with: 30 | registry: ghcr.io 31 | username: ${{ github.repository_owner }} 32 | password: ${{ secrets.GITHUB_TOKEN }} 33 | 34 | # https://github.com/docker/metadata-action 35 | - name: Docker meta 36 | id: meta 37 | uses: docker/metadata-action@v4 38 | with: 39 | # List of Docker images to use as base name for tags 40 | images: | 41 | ghcr.io/mafrosis/tinytuya2mqtt 42 | # Docker tags based on the following events/attributes 43 | tags: | 44 | type=schedule 45 | type=ref,event=branch 46 | type=ref,event=pr 47 | type=pep440,pattern={{version}},value=${{ steps.version.outputs.version }} 48 | type=sha 49 | 50 | # https://github.com/docker/build-push-action 51 | - name: Build and push 52 | uses: docker/build-push-action@v3 53 | with: 54 | context: . 55 | push: true 56 | platforms: linux/arm64,linux/amd64,linux/arm/v7 57 | tags: ${{ steps.meta.outputs.tags }} 58 | labels: ${{ steps.meta.outputs.labels }} 59 | -------------------------------------------------------------------------------- /.github/workflows/tag-release.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: tag-release 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | 8 | jobs: 9 | tag-release: 10 | runs-on: ubuntu-latest 11 | 12 | outputs: 13 | version: ${{ steps.version.outputs.version }} 14 | needs_release: ${{ steps.tags.outputs.needs_release }} 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | with: 20 | # Fetch all history & tags 21 | fetch-depth: 0 22 | # Checkout with PAT so that git push triggers further Actions 23 | token: ${{ secrets.PAT }} 24 | 25 | - name: Extract version 26 | id: version 27 | run: | 28 | python -m pip install --no-deps . 29 | echo "::set-output name=version::$(python -c 'import tinytuya2mqtt; print(tinytuya2mqtt.__version__)')" 30 | 31 | - name: Check tags 32 | id: tags 33 | run: | 34 | echo "::set-output name=needs_release::$(git describe --match v$VERSION &>/dev/null; echo $?)" 35 | env: 36 | VERSION: ${{ steps.version.outputs.version }} 37 | 38 | - name: Log variables 39 | run: | 40 | echo version=${{ steps.version.outputs.version }} 41 | echo needs_release=${{ steps.tags.outputs.needs_release }} 42 | 43 | - name: Create tag 44 | # Git describe exits with 128 when a tag is not found 45 | if: steps.tags.outputs.needs_release > 0 46 | run: | 47 | git config user.email "github@mafro.net" 48 | git config user.name "GHA Bot" 49 | git tag -a -m v$VERSION v$VERSION 50 | git push --tags 51 | env: 52 | VERSION: ${{ steps.version.outputs.version }} 53 | 54 | 55 | gh-release: 56 | runs-on: ubuntu-latest 57 | needs: [tag-release] 58 | 59 | steps: 60 | - name: Checkout 61 | uses: actions/checkout@v3 62 | if: needs.tag-release.outputs.needs_release > 0 63 | 64 | - name: Build wheel 65 | if: needs.tag-release.outputs.needs_release > 0 66 | run: python -m pip wheel --no-deps --wheel-dir dist . 67 | 68 | - name: Create GH release 69 | uses: softprops/action-gh-release@v1 70 | if: needs.tag-release.outputs.needs_release > 0 71 | with: 72 | tag_name: v${{ needs.tag-release.outputs.version }} 73 | token: ${{ secrets.PAT }} 74 | files: | 75 | dist/*.whl 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .pyc 2 | __pycache__ 3 | *.egg-info 4 | venv 5 | 6 | snapshot.json 7 | tinytuya2mqtt.ini 8 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | disable=bad-whitespace, consider-iterating-dictionary, import-error, invalid-name, missing-docstring, no-else-return, no-member, line-too-long, too-many-ancestors, too-many-arguments, too-many-branches, too-many-locals, too-many-return-statements, too-many-statements, too-few-public-methods, ungrouped-imports, inconsistent-return-statements, wrong-import-order, wrong-import-position, raise-missing-from, use-dict-literal 3 | 4 | [SIMILARITIES] 5 | min-similarity-lines=8 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-slim AS builder 2 | 3 | RUN apt-get update && apt-get install -y build-essential 4 | 5 | WORKDIR /src 6 | 7 | COPY requirements.txt /src/ 8 | RUN python -m pip wheel -r requirements.txt --wheel-dir /dist 9 | 10 | 11 | FROM python:3.9-slim 12 | 13 | WORKDIR /src 14 | 15 | COPY --from=builder /dist /dist 16 | COPY setup.py requirements.txt README.md /src/ 17 | COPY tinytuya2mqtt /src/tinytuya2mqtt 18 | 19 | RUN python -m pip wheel --no-deps --wheel-dir /dist . 20 | RUN python -m pip install --no-index --find-links=/dist tinytuya2mqtt 21 | 22 | CMD ["tinytuya2mqtt"] 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Matt Black 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | tinytuya2mqtt 2 | ========== 3 | 4 | A bridge between [jasonacox/tinytuya](https://github.com/jasonacox/tinytuya) and Home Assistant via 5 | MQTT. 6 | 7 | Leveraging Home Assistant's autodiscovery means there is no configuration required on the HA side. 8 | Once this is setup, your devices will just appear, and will be controllable from HA. 9 | 10 | ``` 11 | ┌───────────┐ ┌──────┐ ┌───────────────┐ 12 | │ Home │ │ MQTT │ │ tinytuya2mqtt │ 13 | │ Assistant │◀────▶│ │◀────▶│ │ 14 | └───────────┘ └──────┘ └──┬────────┬───┘ 15 | ┌┘ └──┐ 16 | ▼ ▼ 17 | ┌───────┐ ┌───────┐ 18 | │ Tuya │ │ Tuya │ 19 | │ Fan │ │ Light │ 20 | └───────┘ └───────┘ 21 | ``` 22 | 23 | Running 24 | ---------- 25 | 26 | Ensure `tinytuya2mqtt.ini` and `snapshot.json` are in the current directory. Ensure your broker IP 27 | has been set in `docker-compose.yml`: 28 | 29 | ``` 30 | docker compose up 31 | ``` 32 | 33 | Or, without `docker`: 34 | ``` 35 | tinytuya2mqtt 36 | ``` 37 | 38 | Setup 39 | ---------- 40 | 41 | ``` 42 | docker compose build 43 | ``` 44 | 45 | Or, without `docker`: 46 | ``` 47 | python3 -m venv venv && source venv/bin/activate 48 | pip install -e . 49 | ``` 50 | 51 | Config 52 | ---------- 53 | 54 | Two things are required: 55 | 56 | 1. `snapshot.json` 57 | 2. `tinytuya2mqtt.ini` 58 | 59 | #### snapshot.json 60 | 61 | Follow the setup process for 62 | [tinytuya](https://github.com/jasonacox/tinytuya#setup-wizard---getting-local-keys) using the 63 | `wizard` which is part of that project. This will create a file `snapshot.json` which is used by 64 | `tinytuya2mqtt`. 65 | 66 | ``` 67 | python -m tinytuya wizard 68 | ``` 69 | 70 | #### tinytuya2mqtt.ini 71 | 72 | Create a `tinytuya2mqtt.ini` config file, mapping your device pins to the named capabilities. Also 73 | ensure to include your MQTT broker hostname. For example: 74 | 75 | ```ini 76 | [broker] 77 | hostname = 192.168.1.198 78 | 79 | [device bf7bf4939779bbd9afllck] 80 | fan_state = 1 81 | fan_speed = 3 82 | fan_speed_steps = 1,2,3,4,5,6 83 | light_state = 15 84 | light_brightness = 16 85 | light_brightness_steps = 25,125,275,425,575,725,900,1000 86 | light_temp = 17 87 | 88 | [device bf66790922f582082fao6p] 89 | fan_state = 1 90 | fan_speed = 3 91 | fan_speed_steps = 1,2,3,4,5,6 92 | ``` 93 | 94 | Devices 95 | ---------- 96 | 97 | Device types and capabilities supported by `tinytuya2mqtt`: 98 | 99 | |Type|Subtype|Name|Example| 100 | |---|---|---|---| 101 | |Fan|Fan|`fan_state`|`1`| 102 | |Fan|Fan|`fan_speed`|`3`| 103 | |Fan|Fan|`fan_speed_steps`|`1,2,3,4,5,6`| 104 | |Fan|Light|`light_state`|`15`| 105 | |Fan|Light|`light_brightness`|`16`| 106 | |Fan|Light|`light_brightness_steps`|`25,125,275,425,575,725,900,1000`| 107 | |Fan|Light|`light_temp`|`17`| 108 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: '3' 3 | 4 | services: 5 | tinytuya2mqtt: 6 | image: ghcr.io/mafrosis/tinytuya2mqtt 7 | build: 8 | context: . 9 | volumes: 10 | - ./snapshot.json:/snapshot.json:ro 11 | - ./tinytuya2mqtt.ini:/tinytuya2mqtt.ini:ro 12 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | paho-mqtt 2 | tinytuya 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | from setuptools import setup, find_packages 4 | 5 | with open('README.md', encoding='utf8') as f: 6 | long_description = f.read() 7 | 8 | with open('requirements.txt', encoding='utf8') as f: 9 | install_requires = f.readlines() 10 | 11 | setup( 12 | name='tinytuya2mqtt', 13 | version='0.1', 14 | description='A bridge between jasonacox/tinytuya and Home Assistant via MQTT', 15 | long_description_content_type='text/markdown', 16 | long_description=long_description, 17 | author='Matt Black', 18 | author_email='dev@mafro.net', 19 | url='https://github.com/mafrosis/tinytuya2mqtt', 20 | packages=find_packages(exclude=['test']), 21 | package_data={'': ['LICENSE']}, 22 | package_dir={'': '.'}, 23 | include_package_data=True, 24 | install_requires=install_requires, 25 | license='MIT License', 26 | entry_points={ 27 | 'console_scripts': [ 28 | 'tinytuya2mqtt=tinytuya2mqtt.main:main' 29 | ] 30 | }, 31 | classifiers=[ 32 | 'Development Status :: 4 - Beta', 33 | 'Environment :: Console', 34 | 'Intended Audience :: Developers', 35 | 'Intended Audience :: System Administrators', 36 | 'Natural Language :: English', 37 | 'License :: OSI Approved :: MIT License', 38 | 'Programming Language :: Python', 39 | 'Programming Language :: Python :: 3.9', 40 | ], 41 | ) 42 | -------------------------------------------------------------------------------- /tinytuya2mqtt/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # tinytuya2mqtt 3 | # 4 | 5 | __title__ = 'tinytuya2mqtt' 6 | __version__ = '0.1.0' 7 | __author__ = 'mafrosis' 8 | __license__ = 'Simplified MIT License' 9 | __copyright__ = 'Copyright 2022 Matt Black' 10 | -------------------------------------------------------------------------------- /tinytuya2mqtt/main.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import dataclasses 3 | import json 4 | import logging 5 | import os 6 | import sys 7 | import threading 8 | import time 9 | from typing import List 10 | 11 | from paho.mqtt import publish 12 | import paho.mqtt.client as mqtt 13 | import tinytuya 14 | 15 | 16 | logger = logging.getLogger(__name__) 17 | sh = logging.StreamHandler() 18 | logger.addHandler(sh) 19 | logger.setLevel(logging.INFO) 20 | 21 | if os.environ.get('DEBUG'): 22 | logger.setLevel(logging.DEBUG) 23 | 24 | if os.environ.get('TINYTUYA_DEBUG'): 25 | tinytuya.set_debug() 26 | 27 | 28 | MQTT_BROKER = None 29 | TIME_SLEEP = 5 30 | 31 | 32 | @dataclasses.dataclass 33 | class Device: 34 | name: str 35 | id: str 36 | key: str 37 | mac: str 38 | ip: str 39 | dps: dict = dataclasses.field(default=None) 40 | tuya: tinytuya.OutletDevice = dataclasses.field(default=None) 41 | 42 | 43 | def autoconfigure_ha_fan(device: Device): 44 | ''' 45 | Send discovery messages to auto configure the fans in HA 46 | 47 | Params: 48 | device: An instance of Device dataclass 49 | ''' 50 | data = { 51 | 'name': device.name, 52 | 'unique_id': device.id, 53 | 'availability_topic': f'home/{device.id}/online', 54 | 'state_topic': f'home/{device.id}/fan/state', # fan ON/OFF 55 | 'command_topic': f'home/{device.id}/fan/command', 56 | 'percentage_state_topic': f'home/{device.id}/fan/speed/state', 57 | 'percentage_command_topic': f'home/{device.id}/fan/speed/command', 58 | 'device': { 59 | 'identifiers': [device.id, device.mac], 60 | 'name': device.name, 61 | 'manufacturer': 'Fanco', 62 | 'model': 'Infinity iD DC', 63 | 'sw_version': f'tinytuya {tinytuya.version}', 64 | } 65 | } 66 | publish.single( 67 | f'homeassistant/fan/{device.id}/config', json.dumps(data), hostname=MQTT_BROKER, retain=True, 68 | ) 69 | 70 | # Publish fan light discovery topic, if the fan has a light 71 | if device.dps.get('light_state'): 72 | data = { 73 | 'name': f'{device.name} Light', 74 | 'unique_id': device.id,#f'{device.id}_light', 75 | 'availability_topic': f'home/{device.id}/online', 76 | 'state_topic': f'home/{device.id}/light/state', # light ON/OFF 77 | 'command_topic': f'home/{device.id}/light/command', 78 | 'brightness_scale': 100, 79 | 'brightness_state_topic': f'home/{device.id}/light/brightness/state', 80 | 'brightness_command_topic': f'home/{device.id}/light/brightness/command', 81 | 'device': { 82 | 'identifiers': [device.id, device.mac], 83 | 'name': device.name, 84 | 'manufacturer': 'Fanco', 85 | 'model': 'Infinity iD DC', 86 | 'sw_version': f'tinytuya {tinytuya.version}', 87 | } 88 | } 89 | publish.single( 90 | f'homeassistant/light/{device.id}/config', json.dumps(data), hostname=MQTT_BROKER, retain=True, 91 | ) 92 | 93 | logger.info('Autodiscovery topic published for %s at %s', device.name, device.id) 94 | 95 | 96 | def read_config() -> List[Device]: 97 | ''' 98 | Read & parse tinytuya2mqtt.ini and snapshot.json 99 | ''' 100 | # Validate files are present 101 | snapshot_conf_path = tinytuya2mqtt_conf_path = None 102 | 103 | for fn in ('snapshot.json', '/snapshot.json'): 104 | if os.path.exists(fn): 105 | snapshot_conf_path = fn 106 | break 107 | 108 | if snapshot_conf_path is None: 109 | logger.error('Missing snapshot.json') 110 | sys.exit(2) 111 | 112 | for fn in ('tinytuya2mqtt.ini', '/tinytuya2mqtt.ini'): 113 | if os.path.exists(fn): 114 | tinytuya2mqtt_conf_path = fn 115 | break 116 | 117 | if tinytuya2mqtt_conf_path is None: 118 | logger.error('Missing tinytuya2mqtt.ini') 119 | sys.exit(2) 120 | 121 | try: 122 | # Read snapshop.json 123 | with open(snapshot_conf_path, encoding='utf8') as f: 124 | snapshot = json.load(f) 125 | except json.decoder.JSONDecodeError: 126 | logger.error('Invalid snapshot.json!') 127 | sys.exit(3) 128 | 129 | # Create a dict of Device objects from snapshot.json 130 | devices = { 131 | d['id']: Device(d['name'], d['id'], d['key'], d['mac'], d['ip']) 132 | for d in snapshot['devices'] 133 | } 134 | 135 | # Read tinytuya2mqtt.ini 136 | cfg = configparser.ConfigParser(inline_comment_prefixes='#') 137 | 138 | with open(tinytuya2mqtt_conf_path, encoding='utf8') as f: 139 | cfg.read_string(f.read()) 140 | 141 | try: 142 | # Map the device pin configurations into the Device class 143 | for section in cfg.sections(): 144 | parts = section.split(' ') 145 | 146 | if parts[0] == 'device': 147 | device_id = parts[1] 148 | devices[device_id].dps = dict(cfg.items(section)) 149 | 150 | elif parts[0] == 'broker': 151 | global MQTT_BROKER # pylint: disable=global-statement 152 | MQTT_BROKER = dict(cfg.items(section))['hostname'] 153 | 154 | except KeyError: 155 | logger.error('Malformed broker section in tinytuya2mqtt.ini') 156 | sys.exit(3) 157 | except IndexError: 158 | logger.error('Malformed section name in tinytuya2mqtt.ini') 159 | sys.exit(3) 160 | 161 | return devices.values() 162 | 163 | 164 | def main(): 165 | ''' 166 | Read config and start the app 167 | ''' 168 | for device in read_config(): 169 | autoconfigure_ha_fan(device) 170 | 171 | # Starting polling this device on a thread 172 | t = threading.Thread(target=poll, args=(device,)) 173 | t.start() 174 | 175 | 176 | def on_connect(client, userdata, _1, _2): 177 | ''' 178 | On broker connected, subscribe to the command topics 179 | ''' 180 | for cmd in ('fan', 'fan/speed', 'light', 'light/brightness'): 181 | command_topic = f"home/{userdata['device'].id}/{cmd}/command" 182 | client.subscribe(command_topic, 0) 183 | logger.info('Subscribed to %s', command_topic) 184 | 185 | 186 | def on_message(_, userdata: dict, msg: bytes): 187 | ''' 188 | On command message received, take some action 189 | 190 | Params: 191 | client: paho.mqtt.client 192 | userdata: Arbitrary data passed on this Paho event loop 193 | msg: Message received on MQTT topic sub 194 | ''' 195 | logger.debug('Received %s on %s', msg.payload, msg.topic) 196 | if not msg.payload: 197 | return 198 | 199 | device: Device = userdata['device'] 200 | 201 | # Fan on/off 202 | if msg.topic.endswith('/fan/command'): 203 | dps = device.dps['fan_state'] 204 | val = bool(msg.payload == b'ON') 205 | 206 | logger.debug('Setting %s to %s', dps, val) 207 | device.tuya.set_status(val, switch=dps) 208 | 209 | # Fan speed 210 | elif msg.topic.endswith('/fan/speed/command'): 211 | dps = device.dps['fan_speed'] 212 | val = pct_to_speed(int(msg.payload), device.dps['fan_speed_steps'][-1]) 213 | 214 | logger.debug('Setting %s to %s', dps, val) 215 | device.tuya.set_value(dps, val) 216 | 217 | # Light on/off 218 | elif msg.topic.endswith('/light/command'): 219 | dps = device.dps['light_state'] 220 | val = bool(msg.payload == b'ON') 221 | 222 | logger.debug('Setting %s to %s', dps, val) 223 | device.tuya.set_status(val, switch=dps) 224 | 225 | # Light brightness 226 | elif msg.topic.endswith('/light/brightness/command'): 227 | dps = device.dps['light_brightness'] 228 | val = pct_to_speed(int(msg.payload), device.dps['light_brightness_steps'][-1]) 229 | 230 | logger.debug('Setting %s to %s', dps, val) 231 | device.tuya.set_value(dps, val) 232 | 233 | # Immediately publish status back to HA 234 | read_and_publish_status(userdata['device']) 235 | 236 | 237 | def poll(device: Device): 238 | ''' 239 | Start MQTT threads, and then poll a device for status updates. 240 | 241 | Params: 242 | device: An instance of Device dataclass 243 | ''' 244 | logger.debug('Connecting to %s', device.ip) 245 | 246 | device.tuya = tinytuya.OutletDevice(device.id, device.ip, device.key) 247 | device.tuya.set_version(3.3) 248 | device.tuya.set_socketPersistent(True) 249 | 250 | # Connect to the broker and hookup the MQTT message event handler 251 | client = mqtt.Client(device.id, userdata={'device': device}) 252 | client.on_connect = on_connect 253 | client.on_message = on_message 254 | client.connect(MQTT_BROKER) 255 | client.loop_start() 256 | 257 | try: 258 | while True: 259 | read_and_publish_status(device) 260 | 261 | time.sleep(TIME_SLEEP) 262 | finally: 263 | client.loop_stop() 264 | logger.info('fin') 265 | 266 | 267 | def read_and_publish_status(device: Device): 268 | ''' 269 | Fetch device current status and publish on MQTT 270 | 271 | Params: 272 | device: An instance of Device dataclass 273 | ''' 274 | status = device.tuya.status().get('dps') 275 | logger.debug('STATUS: %s', status) 276 | if not status: 277 | logger.error('Failed getting device status %s', device.id) 278 | return 279 | 280 | # Make all keys integers, for convenience compat with Device.dps integers 281 | status = {int(k):v for k,v in status.items()} 282 | 283 | msgs = [ 284 | (f'home/{device.id}/online', 'online') 285 | ] 286 | 287 | # Publish fan state 288 | if device.dps.get('fan_state') in status: 289 | msgs.append( 290 | (f'home/{device.id}/fan/state', 'ON' if status[device.dps['fan_state']] else 'OFF') 291 | ) 292 | 293 | # Publish light state 294 | if device.dps.get('light_state') in status: 295 | msgs.append( 296 | (f'home/{device.id}/light/state', 'ON' if status[device.dps['light_state']] else 'OFF') 297 | ) 298 | 299 | # Publish fan speed 300 | if device.dps.get('fan_speed') in status: 301 | msgs.append( 302 | ( 303 | f'home/{device.id}/fan/speed/state', 304 | speed_to_pct( 305 | status[device.dps['fan_speed']], 306 | device.dps['fan_speed_steps'][-1], 307 | ), 308 | ) 309 | ) 310 | 311 | # Publish light brightness 312 | if device.dps.get('light_brightness') in status: 313 | msgs.append( 314 | ( 315 | f'home/{device.id}/light/brightness/state', 316 | speed_to_pct( 317 | status[device.dps['light_brightness']], 318 | device.dps['light_brightness_steps'][-1], 319 | ), 320 | ) 321 | ) 322 | 323 | logger.debug('PUBLISH: %s', msgs) 324 | publish.multiple(msgs, hostname=MQTT_BROKER) 325 | 326 | 327 | def speed_to_pct(raw: int, max_: int) -> int: 328 | 'Convert a raw value to a percentage' 329 | return round(raw / max_ * 100) 330 | 331 | 332 | def pct_to_speed(percentage: int, max_: int) -> int: 333 | 'Convert a percentage to a raw value' 334 | return round(percentage / 100 * max_) 335 | --------------------------------------------------------------------------------