├── .github └── workflows │ └── main.yml ├── .gitignore ├── MANIFEST.in ├── README.rst ├── duofern.md ├── examples ├── asyncio │ └── example_asyncio.py ├── pairing.png ├── readme.rst ├── renaming.png └── sync_devices.png ├── license.txt ├── pyduofern ├── __init__.py ├── definitions.py ├── duofern.py ├── duofern_stick.py └── exceptions.py ├── pyserial_asyncio_experiments.py ├── scripts ├── duofern_cli.py └── duofern_mqtt.py ├── setup.py ├── tests ├── __init__.py ├── files │ └── duofern_recording.json ├── replaydata │ ├── duofern_record_1528034711.6005006 │ ├── duofern_record_1528403726.1207247 │ ├── duofern_record_1528403804.8626764 │ ├── duofern_record_1578298202.5071442 │ ├── duofern_record_80op8dbi │ └── duofern_record_mg1koayu ├── test_duofern_stick.py ├── test_duofern_stick_async.py └── test_replay.py └── tox.ini /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: check 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - master 9 | schedule: 10 | - cron: "0 8 * * *" 11 | 12 | jobs: 13 | test: 14 | name: test ${{ matrix.py }} - ${{ matrix.os }} 15 | runs-on: ${{ matrix.os }}-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | os: 20 | - Ubuntu 21 | py: 22 | - "3.11" 23 | - "3.10" 24 | 25 | steps: 26 | - name: Setup python for test ${{ matrix.py }} 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: ${{ matrix.py }} 30 | - uses: actions/checkout@v4 31 | - name: Install module 32 | run: python -m pip install .[test] 33 | - name: run pytest 34 | run: pytest 35 | 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pm 2 | *.pyc 3 | **/coverage/* 4 | build/** 5 | coverage/** 6 | dist/** 7 | *.egg-info/** 8 | .coverage 9 | .idea/** 10 | **/du*ern.json 11 | **/mysensors.json 12 | .pypirc 13 | venv/** -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt 2 | recursive-include examples *.rst *.py 3 | prune tests -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | pyduofern 2 | ========= 3 | .. image:: https://travis-ci.org/gluap/pyduofern.svg?branch=master 4 | :target: https://travis-ci.org/gluap/pyduofern 5 | .. image:: https://coveralls.io/repos/github/gluap/pyduofern/badge.svg?branch=master 6 | :target: https://coveralls.io/github/gluap/pyduofern?branch=master 7 | 8 | **Disclaimer:** this library is **not** endorsed by the company Rademacher, the manufacturer of home automation products 9 | under the label duofern. The name pyduofern was chosen to indicate the function of the library: communicating with 10 | duofern devices via python. 11 | 12 | These are my efforts in porting the `FHEM `_ 13 | `Duofern USB-Stick `_ based module to 14 | `Homeassistant `_. As of now the port is rather ugly, but it is usable enough to control 15 | my Duofern blinds. I did not port the Weather-Station related features of the original module -- Mainly because I 16 | do not own the corresponding hardware and have no means to test if it works. I only tested it with the model 17 | *RolloTron Standard DuoFern 14233011 Funk-Gurtwickler Aufputz*. 18 | 19 | As reported in `#31 `_ 10-Digit codes recently announced by Rademacher are not supported as of now as the handshake/ 20 | protocol for these devices was not reverse engineered by anyone as far as I know. 21 | 22 | This requires the Duofern USB Stick Art.-Nr.: 70000093 by Rademacher. 23 | 24 | I do not provide any guarantees for the usability of this software. Use at your own risk. 25 | 26 | License:: 27 | 28 | python interface for dufoern usb stick 29 | Copyright (C) 2017 Paul Görgen 30 | Rough python python translation of the FHEM duofern modules by telekatz 31 | (also licensed under GPLv2) 32 | This re-write does not literally contain contain any verbatim lines 33 | of the original code (given it was translated to another language) 34 | apart from some comments to facilitate translation of the not-yet 35 | translated parts of the original software. Modification dates are 36 | documented as submits to the git repository of this code, currently 37 | maintained at `https://github.com/gluap/pyduofern.git `_ 38 | 39 | This program is free software; you can redistribute it and/or modify 40 | it under the terms of the GNU General Public License as published by 41 | the Free Software Foundation; either version 2 of the License, or 42 | (at your option) any later version. 43 | 44 | This program is distributed in the hope that it will be useful, 45 | but WITHOUT ANY WARRANTY; without even the implied warranty of 46 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 47 | GNU General Public License for more details. 48 | 49 | You should have received a copy of the GNU General Public License 50 | along with this program; if not, write to the Free Software Foundation, 51 | Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 52 | 53 | Getting Started 54 | =============== 55 | 56 | Install via:: 57 | 58 | pip3 install pyduofern 59 | 60 | or if you want the development version from github:: 61 | 62 | pip3 install git+https://github.com/gluap/pyduofern.git@dev 63 | 64 | udev configuration 65 | ================== 66 | to make your usb stick easy to identify deploy an `udev rules `_ file in 67 | ``/etc/udev/rules.d/98-duofern.rules`` or the equivalent of your distribution. The following worked for my 68 | stick:: 69 | 70 | SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", SYMLINK+="duofernstick" 71 | 72 | Or, if you use several USB-Serial adapters with vendor ``0403`` and product ``6001`` find out the serial number of your 73 | stick (assuming it is currently registered as ``/dev/ttyUSB0```):: 74 | 75 | user@host:~ > udevadm info -a -n /dev/ttyUSB0 | grep '{serial}' | head -n1 76 | ATTRS{serial}=="WR04ZFP4" 77 | 78 | As you can se for me the serial is ``WR04ZFP4``. Use the following udev line (use the serial you found above):: 79 | 80 | SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", ATTRS{serial}=="WR04ZFP4", SYMLINK+="duofernstick" 81 | 82 | Once the rule is deployed your stick should show up as ``/dev/duofernstick`` as soon as you plug it in. This 83 | helps avoid confusion if you use other usb-serial devices. Also be warned: The line also makes the stick 84 | accessible to non-root users. But likely on your system you will be the only user anyhow. 85 | 86 | Getting Started 87 | =============== 88 | To start using your stick you can use the ``duofern_cli.py`` script which should have been installed together 89 | with the pyduofern module. Begin by choosing a 4 hex-digit system code. If you have already used the roller shutters 90 | with FHEM use the last 4 digits of your FHEM code to preserve pairings. Ideally write it down, if you forget 91 | it, you will likely have to chose a new system code and reset your devices in order to be able to pair them again. 92 | It is also a security feature which is why no default is provided. 93 | 94 | Decide for a system configuration file. By default it will reside as a hidden config file in your home directory in 95 | ``~/.duofern.json``. Pass the config file to ``duofern_cli.py`` via the command line option ``--configfile``. 96 | The default has the advantage that you do not always have to pass the config file when using the script. Initialize 97 | the config file with your system code with the following command:: 98 | 99 | duofern_cli.py --code 100 | # or if you prefer your own configfile location 101 | doufern_cli.py --code --configfile 102 | 103 | Now RTFM of the shutter to find out how to start pairing. Set the maximum and minimum positions according to the manual. 104 | If you want to experiment with the shutter first, chose the two positions very near each other. The motors shut down 105 | after a certain maximum runtime and you could exceed that while experimenting if you move the shutters up and down 106 | several times unless the min and max positions are close to each other. 107 | 108 | Start out by pairing your first rollershutter:: 109 | 110 | duofern_cli.py --pair --pairtime 60 111 | 112 | now initiate pairing via the buttons on your shutter. Once a shutter is paired it should show up in your 113 | config file and you can name it. Say the blind that popped up has the ID ``408ea2``, run the following to give it 114 | the name ``kitchen``:: 115 | 116 | duofern_cli.py --set_name 408ea2 kitchen 117 | # you can now try to also have it move up or down: 118 | duofern_cli.py --up kitchen 119 | # or try to set the position of a blind (0=down, 100=up) 120 | duofern_cli.py --position 42 kitchen 121 | 122 | Hopefully you now have working command line interface that knows how to move up or down your shutters. But the python 123 | interface can do more, (which I was so far too lazy to expose via the command line): 124 | 125 | Indexing paired blinds 126 | ---------------------- 127 | If you have the system code of your system but lost the list of configured blinds you can use the CLI to refresh 128 | the config file with all paired blinds.:: 129 | 130 | # assuming you lost the config file 131 | duofern_cli.py --code --refresh --refreshtime 60 132 | 133 | will start up the stick and listen for connecting blinds for 60 seconds. It will store all the blinds that were found 134 | in the default config file.a 135 | 136 | Usage with Homeassistant 137 | ======================== 138 | There is a custom component for homeassistant that can be easily deployed via hacs at ``_ 139 | 140 | 141 | Usage from python 142 | ================= 143 | .. code-block:: python 144 | 145 | from pyduofern.duofern_stick import DuofernStickThreaded 146 | import time 147 | stick = DuofernStickThreaded(device="/dev/duofernstick") # by default looks for /dev/duofernstick 148 | stick._initialize() # do some initialization sequence with the stick 149 | stick.start() # start the stick in a thread so it keeps communicating with your blinds 150 | time.sleep(10) # let it settle to be able to talk to your blinds. 151 | # your code here 152 | # this uses internal variables of the duofern parser module and likely I will wrap it in 153 | # the future. 154 | 155 | print(stick.duofern_parser.modules['by_code']['1ff1d3']['position']) 156 | 157 | command("1ff1d3", "up") # open the blind with code 1ff1d3 158 | 159 | stick.command("1ff1d3", "down") # down the blind with code 1ff1d3 160 | 161 | stick.command("1ff1d3", "stop") # stop the blind with code 1ff1d3 162 | 163 | stick.command("1ff1d3", "position", 30) # set position of the blind with code 1ff1d3 to 30% 164 | 165 | Look for an indication of possible commands in ``pyduofern/definitions.py`` 166 | I just translated them into python and did not explore what might be possible. 167 | It looks like a lot of functionality requires a weather station, but you can just as 168 | easily automate the stuff using your home automation and having it send the up and down 169 | commands instead of buying a weather station. 170 | 171 | Changelog 172 | ========= 173 | **0.36** 174 | 175 | - add periodic requests for status updates. 176 | 177 | **0.36** 178 | 179 | - add rudimentary tracking of successfully sent messages and resending of unacknowledged ones. 180 | 181 | **0.35.1** 182 | 183 | - fix issue with crashes when "sets" was not defined because a bogous device type was present in duofern config. 184 | 185 | **0.35.0** 186 | 187 | - limit message sending frequency. 188 | 189 | **0.34.3** 190 | 191 | - Fix issue with asynchronous code in synchronous part of the code that was breaking homeassistant. 192 | 193 | **0.34.2** 194 | 195 | - merged pull request by [@realbuxtehuder](https://github.com/realbuxtehuder) that adds stop command to cli 196 | 197 | **0.34.1** 198 | 199 | - functionally equivalent to 0.34.0, removed shebangs where not required, moved unit tests outside of packaging 200 | 201 | **0.34.0** 202 | 203 | - merge callback state updates for covers introduced by @DomiStyle in #21 to master 204 | 205 | **0.33.0** 206 | 207 | - add smokedetector introduced by @DomiStyle in #20 to master 208 | 209 | **0.32.0** 210 | 211 | - fix case to try and fix #18 for sensorMsg 212 | 213 | **0.30.0** 214 | 215 | - **breaking change**: instead of creating multiple devices for single physical devices with multiple actor channels which was rather buggy add a ``channel`` parameter to the respective functions inpyduofern.duofern.Duofern() which allows to handle channels in a consistent manner. See discussion in https://github.com/gluap/pyduofern/pull/9 . For each device available channels are listed in in Duofern().modules['by_code'][code]['channels']. The default channel available for all devices is ``None``, otherwise an ``int`` is expected. 216 | 217 | 218 | **0.25.2** 219 | 220 | - try to fix https://github.com/gluap/pyduofern/issues/2 221 | 222 | **0.25.1** 223 | 224 | - changed custom component to fix bug in switch implementation accidentally introduced recently. 225 | 226 | **0.25** 227 | 228 | - restarted from 0.23 to get somewhat working auto detection 229 | 230 | **0.24** 231 | 232 | - somewhat broken changes for auto detection 233 | 234 | **0.23.5** 235 | 236 | - python 3.7 support should enable current hassio version 237 | 238 | **0.23.3** 239 | 240 | - added ``--position`` to CLI 241 | 242 | **0.23.2** 243 | 244 | - renamed README.rst and moved version number from `setup.py` to `__init__.py` 245 | 246 | **0.23.1** 247 | 248 | - fixed references to repository url 249 | - upped version for pypi release 250 | 251 | **0.23** 252 | 253 | - added recordings and increased coverage of unit tests (no result-based tests yet though -- just checking if every replay runs until the end without hanging) 254 | 255 | **0.22** 256 | 257 | * Added recording of actions for replay in integration tests 258 | * Improved unit tests 259 | * Enable travis 260 | * Enable coveralls 261 | 262 | **0.21.1** 263 | 264 | - fixed bug where device IDs containing `cc` would be be messed up when inserting channel number. 265 | -------------------------------------------------------------------------------- /duofern.md: -------------------------------------------------------------------------------- 1 | # Notes on the duofern protocol 2 | - The duofernstick contains an NRF905 microcontroller. 3 | - using rtlsdr, duofern packets can be received and decoded using the command below with rtl-433 (adapted from [here](https://github.com/merbanan/rtl_433/issues/33#issuecomment-715569878)). At first glance the beginning of the packet contains the data and in the end some changing data appears. 4 | - Somehow the command receives data on my desktop but not on my laptop. Possibly on the laptop there is too much interference from the USB hub etc... 5 | 6 | ``` 7 | rtl_433 -s 2.0M -f 434.5M -g 55 -X "n=nrf905,m=FSK_MC_ZEROBIT,s=10,r=100,preamble={10}fd4,invert" -S known |sed s/code/CODE/g 8 | ``` 9 | 10 | Output will look like this: 11 | 12 | ``` 13 | _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 14 | time : 2024-10-19 16:12:46 15 | model : nrf905 count : 1 num_rows : 1 rows : 16 | len : 305 data : e3ac43186fCODE4098820200000303201101070100000000000000000000aaad1593b2a326ef8 17 | codes : {305}e3ac43186fCODE4098820200000303201101070100000000000000000000aaad1593b2a326ef8 18 | _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 19 | time : 2024-10-19 16:12:50 20 | model : nrf905 count : 1 num_rows : 1 rows : 21 | len : 305 data : e3ac43186fCODE409b21017c5c12042011ff0f210d086400000041641100bc928a7931a12ec18 22 | codes : {305}e3ac43186fCODE409b21017c5c12042011ff0f210d086400000041641100bc928a7931a12ec18 23 | _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 24 | time : 2024-10-19 16:12:50 25 | model : nrf905 count : 1 num_rows : 1 rows : 26 | len : 267 data : e3ac43186fCODE409b21017c5c12032011ff0f213a10c800000082c8223fdc0455a 27 | codes : {267}e3ac43186fCODE409b21017c5c12032011ff0f213a10c800000082c8223fdc0455a 28 | _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 29 | time : 2024-10-19 16:12:51 30 | model : nrf905 count : 1 num_rows : 1 rows : 31 | len : 304 data : e3ac43186fCODE408e43011f5804032011ff0f210d086400000041001100e2ddf57cf12341f9 32 | codes : {304}e3ac43186fCODE408e43011f5804032011ff0f210d086400000041001100e2ddf57cf12341f9 33 | _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 34 | time : 2024-10-19 16:12:51 35 | model : nrf905 count : 1 num_rows : 1 rows : 36 | len : 305 data : e3ac43186fCODE408e43011f5804032011ff0f210d086400000041001100e2ddf57cf12341f98 37 | codes : {305}e3ac43186fCODE408e43011f5804032011ff0f210d086400000041001100e2ddf57cf12341f98 38 | _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 39 | time : 2024-10-19 16:12:51 40 | model : nrf905 count : 1 num_rows : 1 rows : 41 | len : 304 data : e3ac4318ffffff6fCODE010013f4042011ff0f4000000000000000000000c604313438c273e9 42 | codes : {304}e3ac4318ffffff6fCODE010013f4042011ff0f4000000000000000000000c604313438c273e9 43 | _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 44 | time : 2024-10-19 16:12:52 45 | model : nrf905 count : 1 num_rows : 1 rows : 46 | len : 304 data : e3ac43184098826fCODE00ffffff04201101070100000000000000000000d2dc2968ce7b0684 47 | codes : {304}e3ac43184098826fCODE00ffffff04201101070100000000000000000000d2dc2968ce7b0684 48 | _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 49 | ``` -------------------------------------------------------------------------------- /examples/asyncio/example_asyncio.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding=utf-8 3 | # python interface for dufoern usb stick 4 | # Copyright (C) 2017 Paul Görgen 5 | # Rough python re-write of the FHEM duofern modules by telekatz, also licensed under GPLv2 6 | # This re-write contains only negligible amounts of original code 7 | # apart from some comments to facilitate translation of the not-yet 8 | # translated parts of the original software. Modification dates are 9 | # documented as submits to the git repository of this code, currently 10 | # maintained at https://github.com/gluap/pyduofern.git 11 | 12 | # This program is free software; you can redistribute it and/or modify 13 | # it under the terms of the GNU General Public License as published by 14 | # the Free Software Foundation; either version 2 of the License, or 15 | # (at your option) any later version. 16 | 17 | # This program is distributed in the hope that it will be useful, 18 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 19 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 20 | # GNU General Public License for more details. 21 | 22 | # You should have received a copy of the GNU General Public License 23 | # along with this program; if not, write to the Free Software Foundation, 24 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 25 | 26 | import asyncio 27 | import logging 28 | 29 | import serial_asyncio 30 | 31 | from pyduofern.duofern_stick import DuofernStickAsync 32 | 33 | logging.basicConfig(level=logging.DEBUG) 34 | loop = asyncio.get_event_loop() 35 | 36 | coro = serial_asyncio.create_serial_connection(loop, lambda: DuofernStickAsync(loop), '/dev/ttyUSB0', baudrate=115200) 37 | f, proto = loop.run_until_complete(coro) 38 | # proto.handshake() 39 | 40 | initialization = asyncio.ensure_future(proto.handshake()) 41 | asyncio.wait(initialization) 42 | 43 | 44 | def cb(a): 45 | logging.info(a) 46 | asyncio.ensure_future(proto.command("409882", "position", 10)) 47 | 48 | 49 | proto.available.add_done_callback(cb) 50 | 51 | 52 | loop.run_forever() 53 | -------------------------------------------------------------------------------- /examples/pairing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gluap/pyduofern/f72f781821389e4ae4ecad4dc94b2c0d565a9f96/examples/pairing.png -------------------------------------------------------------------------------- /examples/readme.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | ======== 3 | The former prime example of using this library has moved to the separate hacs component for Homeassistant: ``_ 4 | -------------------------------------------------------------------------------- /examples/renaming.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gluap/pyduofern/f72f781821389e4ae4ecad4dc94b2c0d565a9f96/examples/renaming.png -------------------------------------------------------------------------------- /examples/sync_devices.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gluap/pyduofern/f72f781821389e4ae4ecad4dc94b2c0d565a9f96/examples/sync_devices.png -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS -------------------------------------------------------------------------------- /pyduofern/__init__.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | # python interface for dufoern usb stick 3 | # Copyright (C) 2017 Paul Görgen 4 | # Rough python re-write of the FHEM duofern modules by telekatz, also licensed under GPLv2 5 | # This re-write contains only negligible amounts of original code 6 | # apart from some comments to facilitate translation of the not-yet 7 | # translated parts of the original software. Modification dates are 8 | # documented as submits to the git repository of this code, currently 9 | # maintained at https://github.com/gluap/pyduofern.git 10 | 11 | # This program is free software; you can redistribute it and/or modify 12 | # it under the terms of the GNU General Public License as published by 13 | # the Free Software Foundation; either version 2 of the License, or 14 | # (at your option) any later version. 15 | 16 | # This program is distributed in the hope that it will be useful, 17 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 18 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 19 | # GNU General Public License for more details. 20 | 21 | # You should have received a copy of the GNU General Public License 22 | # along with this program; if not, write to the Free Software Foundation, 23 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 24 | __version__ = "0.36.2" 25 | 26 | __all__ = ['DuofernException', 'DuofernStick', 'DuofernStickAsync', 'duoACK'] 27 | 28 | try: 29 | from .duofern_stick import DuofernStick, DuofernStickAsync, duoACK 30 | from .exceptions import DuofernException 31 | except ImportError: 32 | # do not raise when called from setup.py 33 | pass -------------------------------------------------------------------------------- /pyduofern/definitions.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | # python interface for dufoern usb stick 3 | # Copyright (C) 2017 Paul Görgen 4 | # Rough python re-write of the FHEM duofern modules by telekatz, also licensed under GPLv2 5 | # This re-write contains only negligible amounts of original code 6 | # apart from some comments to facilitate translation of the not-yet 7 | # translated parts of the original software. Modification dates are 8 | # documented as submits to the git repository of this code, currently 9 | # maintained at https://github.com/gluap/pyduofern.git 10 | 11 | # This program is free software; you can redistribute it and/or modify 12 | # it under the terms of the GNU General Public License as published by 13 | # the Free Software Foundation; either version 2 of the License, or 14 | # (at your option) any later version. 15 | 16 | # This program is distributed in the hope that it will be useful, 17 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 18 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 19 | # GNU General Public License for more details. 20 | 21 | # You should have received a copy of the GNU General Public License 22 | # along with this program; if not, write to the Free Software Foundation, 23 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 24 | 25 | devices = { 26 | "40": "RolloTron Standard", 27 | "41": "RolloTron Comfort Slave", 28 | "42": "Rohrmotor-Aktor", 29 | "43": "Universalaktor", 30 | "46": "Steckdosenaktor", 31 | "47": "Rohrmotor Steuerung", 32 | "48": "Dimmaktor", 33 | "49": "Rohrmotor", 34 | "4b": "Connect-Aktor", 35 | "4C": "Troll Basis", 36 | "4e": "SX5", 37 | "61": "RolloTron Comfort Master", 38 | "62": "Super Fake Device", 39 | "65": "Bewegungsmelder", 40 | "69": "Umweltsensor", 41 | "70": "Troll Comfort DuoFern", 42 | "71": "Troll Comfort DuoFern (Lichtmodus)", 43 | "73": "Raumthermostat", 44 | "74": "Wandtaster 6fach 230V", 45 | "a0": "Handsender (6 Gruppen-48 Geraete)", 46 | "a1": "Handsender (1 Gruppe-48 Geraete)", 47 | "a2": "Handsender (6 Gruppen-1 Geraet)", 48 | "a3": "Handsender (1 Gruppe-1 Geraet)", 49 | "a4": "Wandtaster", 50 | "a5": "Sonnensensor", 51 | "a7": "Funksender UP", 52 | "a8": "HomeTimer", 53 | "aa": "Markisenwaechter", 54 | "ab": "Rauchmelder", 55 | "ad": "Wandtaster 6fach Bat", 56 | } 57 | 58 | sensorMsg = { 59 | "0701": {"name": "up", "chan": 6, "state": "Btn01"}, 60 | "0702": {"name": "stop", "chan": 6, "state": "Btn02"}, 61 | "0703": {"name": "down", "chan": 6, "state": "Btn03"}, 62 | "0718": {"name": "stepUp", "chan": 6, "state": "Btn18"}, 63 | "0719": {"name": "stepDown", "chan": 6, "state": "Btn19"}, 64 | "071a": {"name": "pressed", "chan": 6, "state": "Btn1A"}, 65 | "0713": {"name": "dawn", "chan": 5, "state": "dawn"}, 66 | "0709": {"name": "dusk", "chan": 5, "state": "dusk"}, 67 | "0708": {"name": "startSun", "chan": 5, "state": "on"}, 68 | "070a": {"name": "endSun", "chan": 5, "state": "off"}, 69 | "070d": {"name": "startWind", "chan": 5, "state": "on"}, 70 | "070e": {"name": "endWind", "chan": 5, "state": "off"}, 71 | "0711": {"name": "startRain", "chan": 5, "state": "on"}, 72 | "0712": {"name": "endRain", "chan": 5, "state": "off"}, 73 | "071c": {"name": "startTemp", "chan": 5, "state": "on"}, 74 | "071d": {"name": "endTemp", "chan": 5, "state": "off"}, 75 | "071e": {"name": "startSmoke", "chan": 5, "state": "on"}, 76 | "071f": {"name": "endSmoke", "chan": 5, "state": "off"}, 77 | "0720": {"name": "startMotion", "chan": 5, "state": "on"}, 78 | "0721": {"name": "endMotion", "chan": 5, "state": "off"}, 79 | "0723": {"name": "closeEnd", "chan": 5, "state": "off"}, 80 | "0724": {"name": "closeStart", "chan": 5, "state": "on"}, 81 | "0e01": {"name": "off", "chan": 6, "state": "Btn01"}, 82 | "0e02": {"name": "off", "chan": 6, "state": "Btn02"}, 83 | "0e03": {"name": "on", "chan": 6, "state": "Btn03"}, 84 | } 85 | 86 | deadTimes = { 87 | 0x00: "off", 88 | 0x10: "short(160ms)", 89 | 0x20: "long(480ms)", 90 | 0x30: "individual", 91 | } 92 | 93 | closingTimes = { 94 | 0x00: "off", 95 | 0x01: "30", 96 | 0x02: "60", 97 | 0x03: "90", 98 | 0x04: "120", 99 | 0x05: "150", 100 | 0x06: "180", 101 | 0x07: "210", 102 | 0x08: "240", 103 | 0x09: "error", 104 | 0x0A: "error", 105 | 0x0B: "error", 106 | 0x0C: "error", 107 | 0x0D: "error", 108 | 0x0E: "error", 109 | 0x0F: "error", 110 | } 111 | 112 | openSpeeds = { 113 | 0x00: "error", 114 | 0x10: "11", 115 | 0x20: "15", 116 | 0x30: "19", 117 | } 118 | 119 | commands = { 120 | "remotePair": {"noArg": "06010000000000000000"}, 121 | "remoteUnpair": {"noArg": "06020000000000000000"}, 122 | "up": {"noArg": "0701tt00000000000000"}, 123 | "stop": {"noArg": "07020000000000000000"}, 124 | "down": {"noArg": "0703tt00000000000000"}, 125 | "position": {"value": "0707ttnn000000000000"}, 126 | "level": {"value": "0707ttnn000000000000"}, 127 | "sunMode": {"on": "070801FF000000000000", 128 | "off": "070A0100000000000000"}, 129 | "dusk": {"noArg": "070901FF000000000000"}, 130 | "reversal": {"noArg": "070C0000000000000000"}, 131 | "modeChange": {"noArg": "070C0000000000000000"}, 132 | "windMode": {"on": "070D01FF000000000000", 133 | "off": "070E0100000000000000"}, 134 | "rainMode": {"on": "071101FF000000000000", 135 | "off": "07120100000000000000"}, 136 | "dawn": {"noArg": "071301FF000000000000"}, 137 | "rainDirection": {"down": "071400FD000000000000", 138 | "up": "071400FE000000000000"}, 139 | "windDirection": {"down": "071500FD000000000000", 140 | "up": "071500FE000000000000"}, 141 | "tempUp": {"noArg": "0718tt00000000000000"}, 142 | "tempDown": {"noArg": "0719tt00000000000000"}, 143 | "toggle": {"noArg": "071A0000000000000000"}, 144 | "slatPosition": {"value": "071B00000000nn000000"}, 145 | "desired-temp": {"temp1": "0722tt0000wwww000000"}, 146 | "sunAutomatic": {"on": "080100FD000000000000", 147 | "off": "080100FE000000000000"}, 148 | "sunPosition": {"value": "080100nn000000000000"}, 149 | "ventilatingMode": {"on": "080200FD000000000000", 150 | "off": "080200FE000000000000"}, 151 | "ventilatingPosition": {"value": "080200nn000000000000"}, 152 | "intermediateMode": {"on": "080200FD000000000000", 153 | "off": "080200FE000000000000"}, 154 | "intermediateValue": {"value": "080200nn000000000000"}, 155 | "saveIntermediateOnStop": {"on": "080200FB000000000000", 156 | "off": "080200FC000000000000"}, 157 | "runningTime": {"value3": "0803nn00000000000000"}, 158 | "timeAutomatic": {"on": "080400FD000000000000", 159 | "off": "080400FE000000000000"}, 160 | "duskAutomatic": {"on": "080500FD000000000000", 161 | "off": "080500FE000000000000"}, 162 | "manualMode": {"on": "080600FD000000000000", 163 | "off": "080600FE000000000000"}, 164 | "windAutomatic": {"on": "080700FD000000000000", 165 | "off": "080700FE000000000000"}, 166 | "rainAutomatic": {"on": "080800FD000000000000", 167 | "off": "080800FE000000000000"}, 168 | "dawnAutomatic": {"on": "080900FD000000000000", 169 | "off": "080900FE000000000000"}, 170 | "tiltInSunPos": {"on": "080C00FD000000000000", 171 | "off": "080C00FE000000000000"}, 172 | "tiltInVentPos": {"on": "080D00FD000000000000", 173 | "off": "080D00FE000000000000"}, 174 | "tiltAfterMoveLevel": {"on": "080E00FD000000000000", 175 | "off": "080E00FE000000000000"}, 176 | "tiltAfterStopDown": {"on": "080F00FD000000000000", 177 | "off": "080F00FE000000000000"}, 178 | "defaultSlatPos": {"value": "0810nn00000000000000"}, 179 | "blindsMode": {"on": "081100FD000000000000", 180 | "off": "081100FE000000000000"}, 181 | "slatRunTime": {"value4": "0812nn00000000000000"}, 182 | "motorDeadTime": {"off": "08130000000000000000", 183 | "short": "08130100000000000000", 184 | "long": "08130200000000000000"}, 185 | "stairwellFunction": {"on": "081400FD000000000000", 186 | "off": "081400FE000000000000"}, 187 | "stairwellTime": {"value2": "08140000wwww00000000"}, 188 | "reset": {"settings": "0815CB00000000000000", 189 | "full": "0815CC00000000000000"}, 190 | "10minuteAlarm": {"on": "081700FD000000000000", 191 | "off": "081700FE000000000000"}, 192 | "automaticClosing": {"off": "08180000000000000000", 193 | "30": "08180001000000000000", 194 | "60": "08180002000000000000", 195 | "90": "08180003000000000000", 196 | "120": "08180004000000000000", 197 | "150": "08180005000000000000", 198 | "180": "08180006000000000000", 199 | "210": "08180007000000000000", 200 | "240": "08180008000000000000"}, 201 | "2000cycleAlarm": {"on": "081900FD000000000000", 202 | "off": "081900FE000000000000"}, 203 | "openSpeed": {"11": "081A0001000000000000", 204 | "15": "081A0002000000000000", 205 | "19": "081A0003000000000000"}, 206 | "backJump": {"on": "081B00FD000000000000", 207 | "off": "081B00FE000000000000"}, 208 | "temperatureThreshold1": {"temp2": "081E00000001nn000000"}, 209 | "temperatureThreshold2": {"temp2": "081E0000000200nn0000"}, 210 | "temperatureThreshold3": {"temp2": "081E000000040000nn00"}, 211 | "temperatureThreshold4": {"temp2": "081E00000008000000nn"}, 212 | "actTempLimit": {"1": "081Ett00001000000000", 213 | "2": "081Ett00003000000000", 214 | "3": "081Ett00005000000000", 215 | "4": "081Ett00007000000000"}, 216 | "on": {"noArg": "0E03tt00000000000000"}, 217 | "off": {"noArg": "0E02tt00000000000000"}, 218 | } 219 | 220 | wCmds = { 221 | "interval": {"enable": 0x80, "min": 1, "max": 100, "offset": 0, 222 | "reg": 7, "byte": 0, "size": 1, "count": 1, 223 | "mask": 0xff, "shift": 0}, 224 | "DCF": {"enable": 0x02, "min": 0, "max": 0, "offset": 0, 225 | "reg": 7, "byte": 1, "size": 1, "count": 1, 226 | "mask": 0x02, "shift": 0}, 227 | "timezone": {"enable": 0x00, "min": 0, "max": 23, "offset": 0, 228 | "reg": 7, "byte": 4, "size": 1, "count": 1, 229 | "mask": 0xff, "shift": 0}, 230 | "latitude": {"enable": 0x00, "min": 0, "max": 90, "offset": 0, 231 | "reg": 7, "byte": 5, "size": 1, "count": 1, 232 | "mask": 0xff, "shift": 0}, 233 | "longitude": {"enable": 0x00, "min": -90, "max": 90, "offset": 256, 234 | "reg": 7, "byte": 7, "size": 1, "count": 1, 235 | "mask": 0xff, "shift": 0}, 236 | "triggerWind": {"enable": 0x20, "min": 1, "max": 31, "offset": 0, 237 | "reg": 6, "byte": 0, "size": 1, "count": 5, 238 | "mask": 0x7f, "shift": 0}, 239 | "triggerRain": {"enable": 0x80, "min": 0, "max": 0, "offset": 0, 240 | "reg": 6, "byte": 0, "size": 1, "count": 1, 241 | "mask": 0x80, "shift": 0}, 242 | "triggerTemperature": {"enable": 0x80, "min": -40, "max": 80, "offset": 40, 243 | "reg": 6, "byte": 5, "size": 1, "count": 5, 244 | "mask": 0xff, "shift": 0}, 245 | "triggerDawn": {"enable": 0x10000000, "min": 1, "max": 100, "offset": -1, 246 | "reg": 0, "byte": 0, "size": 4, "count": 5, 247 | "mask": 0x1000007F, "shift": 0}, 248 | "triggerDusk": {"enable": 0x20000000, "min": 1, "max": 100, "offset": -1, 249 | "reg": 0, "byte": 0, "size": 4, "count": 5, 250 | "mask": 0x201FC000, "shift": 14}, 251 | "triggerSun": {"enable": 0x20000000, "min": 1, "max": 0x3FFFFFFF, "offset": 0, 252 | "reg": 3, "byte": 0, "size": 4, "count": 5, 253 | "mask": 0x3FFFFFC0, "shift": 0}, 254 | "triggerSunDirection": {"enable": 0x00, "min": 1, "max": 0xFF, "offset": 0, 255 | "reg": 3, "byte": 1, "size": 4, "count": 5, 256 | "mask": 0x000000FF, "shift": 0}, 257 | "triggerSunHeight": {"enable": 0x00, "min": 1, "max": 0x1FFF, "offset": 0, 258 | "reg": 3, "byte": 1, "size": 4, "count": 5, 259 | "mask": 0x00001F80, "shift": 0}, 260 | } 261 | 262 | commandsStatus = { 263 | "getStatus": "0F", 264 | "getWeather": "13", 265 | "getTime": "10", 266 | } 267 | 268 | setsBasic = { 269 | "reset:settings,full": "", 270 | "remotePair:noArg": "", 271 | "remoteUnpair:noArg": "", 272 | } 273 | 274 | setsDefaultRollerShutter = { 275 | "getStatus:noArg": "", 276 | "up:noArg": "", 277 | "down:noArg": "", 278 | "stop:noArg": "", 279 | "toggle:noArg": "", 280 | "dusk:noArg": "", 281 | "dawn:noArg": "", 282 | "sunMode:on,off": "", 283 | "position:slider,0,1,100": "", 284 | "sunPosition:slider,0,1,100": "", 285 | "ventilatingPosition:slider,0,1,100": "", 286 | "dawnAutomatic:on,off": "", 287 | "duskAutomatic:on,off": "", 288 | "manualMode:on,off": "", 289 | "sunAutomatic:on,off": "", 290 | "timeAutomatic:on,off": "", 291 | "ventilatingMode:on,off": "", 292 | } 293 | 294 | setsRolloTube = { 295 | "windAutomatic:on,off": "", 296 | "rainAutomatic:on,off": "", 297 | "windDirection:up,down": "", 298 | "rainDirection:up,down": "", 299 | "windMode:on,off": "", 300 | "rainMode:on,off": "", 301 | "reversal:on,off": "", 302 | } 303 | 304 | setsTroll = { 305 | "windAutomatic:on,off": "", 306 | "rainAutomatic:on,off": "", 307 | "windDirection:up,down": "", 308 | "rainDirection:up,down": "", 309 | "windMode:on,off": "", 310 | "rainMode:on,off": "", 311 | "runningTime:slider,0,1,150": "", 312 | "motorDeadTime:off,short,long": "", 313 | "reversal:on,off": "", 314 | } 315 | 316 | setsBlinds = { 317 | "tiltInSunPos:on,off": "", 318 | "tiltInVentPos:on,off": "", 319 | "tiltAfterMoveLevel:on,off": "", 320 | "tiltAfterStopDown:on,off": "", 321 | "defaultSlatPos:slider,0,1,100": "", 322 | "slatRunTime:slider,0,100,5000": "", 323 | "slatPosition:slider,0,1,100": "", 324 | } 325 | 326 | setsSwitchActor = { 327 | "getStatus:noArg": "", 328 | "dawnAutomatic:on,off": "", 329 | "duskAutomatic:on,off": "", 330 | "manualMode:on,off": "", 331 | "sunAutomatic:on,off": "", 332 | "timeAutomatic:on,off": "", 333 | "sunMode:on,off": "", 334 | "modeChange:on,off": "", 335 | "stairwellFunction:on,off": "", 336 | "stairwellTime:slider,0,10,3200": "", 337 | "on:noArg": "", 338 | "off:noArg": "", 339 | "dusk:noArg": "", 340 | "dawn:noArg": "", 341 | } 342 | 343 | setsUmweltsensor = { 344 | "getStatus:noArg": "", 345 | "getWeather:noArg": "", 346 | "getTime:noArg": "", 347 | } 348 | 349 | setsUmweltsensor00 = { 350 | "getWeather:noArg": "", 351 | "getTime:noArg": "", 352 | "getConfig:noArg": "", 353 | "writeConfig:noArg": "", 354 | "DCF:on,off": "", 355 | "interval:off,1,2,3,4,5,6,7,8,9,10,15,20,30,40,50,60,70,80,90,100": "", 356 | "latitude": "", 357 | "longitude": "", 358 | "timezone": "", 359 | "time:noArg": "", 360 | "triggerDawn": "", 361 | "triggerDusk": "", 362 | "triggerRain:on,off": "", 363 | "triggerSun": "", 364 | "triggerSunDirection": "", 365 | "triggerSunHeight": "", 366 | "triggerTemperature": "", 367 | "triggerWind": "", 368 | } 369 | 370 | setsUmweltsensor01 = { 371 | "windAutomatic:on,off": "", 372 | "rainAutomatic:on,off": "", 373 | "windDirection:up,down": "", 374 | "rainDirection:up,down": "", 375 | "windMode:on,off": "", 376 | "rainMode:on,off": "", 377 | "runningTime:slider,0,1,100": "", 378 | "reversal:on,off": "", 379 | } 380 | 381 | setsSX5 = { 382 | "getStatus:noArg": "", 383 | "up:noArg": "", 384 | "down:noArg": "", 385 | "stop:noArg": "", 386 | "position:slider,0,1,100": "", 387 | "ventilatingPosition:slider,0,1,100": "", 388 | "manualMode:on,off": "", 389 | "timeAutomatic:on,off": "", 390 | "ventilatingMode:on,off": "", 391 | "10minuteAlarm:on,off": "", 392 | "automaticClosing:off,30,60,90,120,150,180,210,240": "", 393 | "2000cycleAlarm:on,off": "", 394 | "openSpeed:11,15,19": "", 395 | "backJump:on,off": "", 396 | } 397 | 398 | setsDimmer = { 399 | "getStatus:noArg": "", 400 | "level:slider,0,1,100": "", 401 | "on:noArg": "", 402 | "off:noArg": "", 403 | "dawnAutomatic:on,off": "", 404 | "duskAutomatic:on,off": "", 405 | "manualMode:on,off": "", 406 | "sunAutomatic:on,off": "", 407 | "timeAutomatic:on,off": "", 408 | "sunMode:on,off": "", 409 | "modeChange:on,off": "", 410 | "stairwellFunction:on,off": "", 411 | "stairwellTime:slider,0,10,3200": "", 412 | "runningTime:slider,0,1,255": "", 413 | "intermediateMode:on,off": "", 414 | "intermediateValue:slider,0,1,100": "", 415 | "saveIntermediateOnStop:on,off": "", 416 | "dusk:noArg": "", 417 | "dawn:noArg": "", 418 | } 419 | 420 | tempSetList = "4.0,4.5,5.0,5.5,6.0,6.5,7.0,7.5,8.0,8.5,9.0,9.5,10.0,10.5,11.0,11.5,12.0,12.5,13.0,13.5,14.0,14.5,15.0,15.5,16.0,16.5,17.0,17.5,18.0,18.5,19.0,19.5,20.0,20.5,21.0,21.5,22.0,22.5,23.0,23.5,24.0,24.5,25.0,25.5,26.0,26.5,27.0,27.5,28.0,28.5,29.0,29.5,30.0" 421 | 422 | setsThermostat = { 423 | "getStatus:noArg": "", 424 | "tempUp:noArg": "", 425 | "tempDown:noArg": "", 426 | "manualMode:on,off": "", 427 | "timeAutomatic:on,off": "", 428 | "temperatureThreshold1:$tempSetList": "", 429 | "temperatureThreshold2:$tempSetList": "", 430 | "temperatureThreshold3:$tempSetList": "", 431 | "temperatureThreshold4:$tempSetList": "", 432 | "actTempLimit:0,1,2,3": "", 433 | "desired-temp:$tempSetList": "", 434 | } 435 | -------------------------------------------------------------------------------- /pyduofern/duofern.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | # python interface for dufoern usb stick 3 | # Copyright (C) 2017 Paul Görgen 4 | # Rough python re-write of the FHEM duofern modules by telekatz, also licensed under GPLv2 5 | # This re-write contains only negligible amounts of original code 6 | # apart from some comments to facilitate translation of the not-yet 7 | # translated parts of the original software. Modification dates are 8 | # documented as submits to the git repository of this code, currently 9 | # maintained at https://github.com/gluap/pyduofern.git 10 | 11 | # This program is free software; you can redistribute it and/or modify 12 | # it under the terms of the GNU General Public License as published by 13 | # the Free Software Foundation; either version 2 of the License, or 14 | # (at your option) any later version. 15 | 16 | # This program is distributed in the hope that it will be useful, 17 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 18 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 19 | # GNU General Public License for more details. 20 | 21 | # You should have received a copy of the GNU General Public License 22 | # along with this program; if not, write to the Free Software Foundation, 23 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 24 | 25 | import asyncio 26 | import logging 27 | import time 28 | 29 | from .definitions import * 30 | 31 | # regexe for replacing: 32 | # hash->\{([^\}]+)\}\{([^\}]+)\} 33 | # hash['$1']['$2'] 34 | # 35 | # ^([^\n]+=) \(([^?\n]+)\?([^:\n]+):([^\)\n]+)\)?#? 36 | # $1 $3 if $2 else $4 37 | 38 | logger = logging.getLogger(__name__) 39 | 40 | duoStatusRequest = "0DFFnn400000000000000000000000000000yyyyyy01" 41 | duoCommand = "0Dkknnnnnnnnnnnnnnnnnnnn000000zzzzzzyyyyyy00" 42 | duoWeatherConfig = "0D001B400000000000000000000000000000yyyyyy00" 43 | duoWeatherWriteConfig = "0DFF1Brrnnnnnnnnnnnnnnnnnnnn00000000yyyyyy00" 44 | duoSetTime = "0D0110800001mmmmmmmmnnnnnn0000000000yyyyyy00" 45 | 46 | 47 | def merge_dicts(*dict_args): 48 | """ 49 | Given any number of dicts, shallow copy and merge into a new dict, 50 | precedence goes to key value pairs in latter dicts. 51 | """ 52 | result = {} 53 | for dictionary in dict_args: 54 | result.update(dictionary) 55 | return result 56 | 57 | 58 | def DoTrigger(*args): 59 | logger.debug("called DoTrigger({})".format(args)) 60 | 61 | 62 | #def readingsBulkUpdate(*args): 63 | # pass 64 | 65 | 66 | #def readingsSingleUpdate(*args): 67 | # pass 68 | 69 | 70 | #def readingsEndUpdate(*args): 71 | # pass 72 | 73 | 74 | #def readingsBeginUpdate(*args): 75 | # pass 76 | 77 | 78 | def RemoveInternalTimer(*args): 79 | pass 80 | 81 | 82 | def DUOFERN_DecodeWeatherSensorConfig(*args): 83 | pass 84 | 85 | 86 | class Duofern(object): 87 | def __init__(self, send_hook=None, asyncio=False, changes_callback=None): 88 | self.asyncio = asyncio 89 | self.modules = {'by_code': {}} # i guess this is supposed to be a hash of self.modules... 90 | self.ignore_devices = {} # should replace attrValrel 91 | assert send_hook is not None, "Must define send callback" 92 | self.send_hook = send_hook 93 | self.changes_callback = changes_callback 94 | pass 95 | 96 | def add_device(self, code, name=None): 97 | if name is None: 98 | name = len(self.modules['by_code']) 99 | logger.debug("adding {}".format(code)) 100 | self.modules['by_code'][code] = {'name': name, 'channels': {None}} 101 | 102 | def del_device(self, code, name=None): 103 | if name is None: 104 | name = len(self.modules['by_code']) 105 | logger.info("removing {}".format(code)) 106 | if code in self.modules['by_code']: 107 | del self.modules['by_code'][code] 108 | 109 | def update_state(self, code, key, value, trigger=None, channel: int = None): 110 | """ 111 | 112 | :param code: duofern system code 113 | :param key: some arbitrary key that should be set in the state dict 114 | :param value: the corresponding value 115 | :param trigger: whether or not to call the callback 116 | :param channel: if this is a multichannel actor: The channel the key should be set for 117 | :return: 118 | """ 119 | if channel is not None: 120 | channel_str = "{:02x}".format(channel) 121 | key = key + "_" + channel_str 122 | self.modules['by_code'][code]['channels'].add(channel_str) 123 | 124 | self.modules['by_code'][code][key] = value 125 | 126 | if self.changes_callback and trigger: 127 | self.changes_callback(code, key, value) 128 | 129 | def delete_state(self, code, key, channel: int = None): 130 | if channel is not None: 131 | channel_str = "{:02x}".format(channel) 132 | key = key + "_" + channel_str 133 | if key in self.modules['by_code'][code]: 134 | del self.modules['by_code'][code][key] 135 | 136 | def get_state(self, code, key, channel=None, default=None): 137 | if channel is not None: 138 | channel_str = "{:02x}".format(channel) 139 | key = key + "_" + channel_str 140 | 141 | if not key in self.modules['by_code'][code]: 142 | return default 143 | 144 | return self.modules['by_code'][code][key] 145 | 146 | def parse(self, msg): 147 | code = msg[30:36] 148 | if msg[0:2] == '81': 149 | code = msg[36:42] 150 | 151 | if code.lower() == 'ffffff': 152 | return 153 | # return hash->{NAME} if (code == "FFFFFF") 154 | 155 | try: 156 | # module_definition = self.modules['by_code'][code] 157 | name = self.modules['by_code'][code]['name'] 158 | 159 | except KeyError: 160 | self.add_device(code) 161 | logger.info("detected unknown device, ID={}".format(code)) 162 | name = self.modules['by_code'][code]['name'] 163 | 164 | #hash="asdf" 165 | # module_definition01 = None 166 | # module_definition02 = None 167 | channel2 = None 168 | 169 | # if not module_definition: 170 | # DoTrigger("global", "Undefined code {}".format(code)) 171 | # # module_definition = self.modules['by_code']{code} 172 | # logger.warning("Undefined code {}".format(code)) 173 | # raise DuofernException("Undefined code {}".format(code)) 174 | 175 | # hash = module_definition 176 | # name = hash['name'] 177 | channel = None 178 | 179 | if name in self.ignore_devices: 180 | return name 181 | 182 | # Device paired 183 | if msg[0:4] == "0602": 184 | self.update_state(code, "state", "paired", "1", channel=channel) 185 | # del hash['READINGS']['unpaired'] 186 | logger.info("DUOFERN device paired, ID {}".format(code)) 187 | 188 | # Device unpaired 189 | elif (msg[0:4] == "0603"): 190 | # readingsBeginUpdate(hash) 191 | self.update_state(code, "unpaired", 1, "1", channel=channel) 192 | self.update_state(code, "state", "unpaired", "1", channel=channel) 193 | self.del_device(code) 194 | # # readingsEndUpdate(hash, 1) # Notify is done by Dispatch 195 | logger.warning("DUOFERN device unpaired, code {}".format(code)) 196 | 197 | # Status Nachricht Aktor 198 | elif msg[0:6] == "0fff0f": 199 | format = msg[6:6 + 2] 200 | ver = msg[24:24 + 1] + msg[25:25 + 1] 201 | 202 | self.update_state(code, "version", ver, "0", channel=channel) 203 | 204 | # RemoveInternalTimer(hash) 205 | # del hash['helper']['timeout'] 206 | 207 | # Bewegungsmelder, Wettersensor, Mehrfachwandtaster not tested yet 208 | if code[0:2] in ("65", "69", "74"): # pragma: no cover 209 | #self.update_state(code, "state", "OK", "1", channel=channel) 210 | #module_definition01 = self.modules['by_code'][code + "01"] 211 | channel = 1 212 | #if not module_definition01: 213 | #DoTrigger("global", "UNDEFINED DUOFERN_code_actor DUOFERN code01") 214 | #module_definition01 = self.modules['by_code'][code + "01"] 215 | 216 | # Universalaktor -- not tested yet 217 | elif code[0:2] == "43": # pragma: no cover 218 | self.update_state(code, "state", "OK", "1", channel=channel) 219 | #module_definition01 = self.modules['by_code'][code] 220 | channel = 1 221 | #if not module_definition01: 222 | # DoTrigger("global", "UNDEFINED DUOFERN_code+_01 DUOFERN code+01") 223 | 224 | #module_definition02 = None 225 | channel2 = 2 226 | 227 | #if module_definition01: 228 | # it seems that sometimes "module_definition01" corresponts to channel "01", at other times 229 | # channel="00". I am trying to stick with what module_definition was set to. 230 | #hash = module_definition01 231 | #channel = 1 232 | 233 | # RolloTron 234 | if format == "21": 235 | pos = int(msg[22:22 + 2], 16) & 0x7F 236 | ventPos = int(msg[12:12 + 2], 16) & 0x7F 237 | ventMode = "on" if int(msg[12:12 + 2], 16) & 0x80 else "off" 238 | sunPos = int(msg[20:20 + 2], 16) & 0x7F 239 | sunMode = "on" if int(msg[20:20 + 2], 16) & 0x80 else "off" 240 | timerAuto = "on" if int(msg[8:8 + 2], 16) & 0x01 else "off" 241 | sunAuto = "on" if int(msg[8:8 + 2], 16) & 0x04 else "off" 242 | dawnAuto = "on" if int(msg[10:10 + 2], 16) & 0x08 else "off" 243 | duskAuto = "on" if int(msg[8:8 + 2], 16) & 0x08 else "off" 244 | manualMode = "on" if int(msg[8:8 + 2], 16) & 0x80 else "off" 245 | 246 | state = pos 247 | state = "opened" if (pos == 0) else pos 248 | state = "closed" if (pos == 100) else pos 249 | 250 | # readingsBeginUpdate(hash) 251 | self.update_state(code, "ventilatingPosition", ventPos, "1", channel=channel) 252 | self.update_state(code, "ventilatingMode", ventMode, "1", channel=channel) 253 | self.update_state(code, "sunPosition", sunPos, "1", channel=channel) 254 | self.update_state(code, "sunMode", sunMode, "1", channel=channel) 255 | self.update_state(code, "timeAutomatic", timerAuto, "1", channel=channel) 256 | self.update_state(code, "sunAutomatic", sunAuto, "1", channel=channel) 257 | self.update_state(code, "dawnAutomatic", dawnAuto, "1", channel=channel) 258 | self.update_state(code, "duskAutomatic", duskAuto, "1", channel=channel) 259 | self.update_state(code, "manualMode", manualMode, "1", channel=channel) 260 | self.update_state(code, "position", pos, "1", channel=channel) 261 | self.update_state(code, "state", state, "1", channel=channel) 262 | self.update_state(code, "moving", "stop", "1", channel=channel) 263 | # readingsEndUpdate(hash, 1) # Notify is done by Dispatch 264 | 265 | # Universal Aktor, Steckdosenaktor, Troll Comfort DuoFern (Lichtmodus) not tested yet 266 | elif format == "22": # pragma: no cover 267 | level = int(msg[22:22 + 2], 16) & 0x7F 268 | modeChange = "on" if int(msg[22:22 + 2], 16) & 0x80 else "off" 269 | sunMode = "on" if int(msg[14:14 + 2], 16) & 0x10 else "off" 270 | timerAuto = "on" if int(msg[14:14 + 2], 16) & 0x01 else "off" 271 | sunAuto = "on" if int(msg[14:14 + 2], 16) & 0x04 else "off" 272 | dawnAuto = "on" if int(msg[14:14 + 2], 16) & 0x40 else "off" 273 | duskAuto = "on" if int(msg[14:14 + 2], 16) & 0x02 else "off" 274 | manualMode = "on" if int(msg[14:14 + 2], 16) & 0x20 else "off" 275 | stairwellFunction = "on" if int(msg[16:16 + 4], 16) & 0x8000 else "off" 276 | stairwellTime = (int(msg[16:16 + 4], 16) & 0x7FFF) / 10 277 | 278 | state = level 279 | if level == 0: 280 | state = "off" 281 | if level == 100: 282 | state = "on" 283 | 284 | # readingsBeginUpdate(hash) 285 | self.update_state(code, "sunMode", sunMode, "1", channel=channel) 286 | self.update_state(code, "timeAutomatic", timerAuto, "1", channel=channel) 287 | self.update_state(code, "sunAutomatic", sunAuto, "1", channel=channel) 288 | self.update_state(code, "dawnAutomatic", dawnAuto, "1", channel=channel) 289 | self.update_state(code, "duskAutomatic", duskAuto, "1", channel=channel) 290 | self.update_state(code, "manualMode", manualMode, "1", channel=channel) 291 | self.update_state(code, "modeChange", modeChange, "1", channel=channel) 292 | self.update_state(code, "stairwellFunction", stairwellFunction, "1", channel=channel) 293 | self.update_state(code, "stairwellTime", stairwellTime, "1", channel=channel) 294 | self.update_state(code, "level", level, "1", channel=channel) 295 | self.update_state(code, "state", state, "1", channel=channel) 296 | # readingsEndUpdate(hash, 1) 297 | 298 | if channel2 is not None: 299 | level = int(msg[20:20 + 2], 16) & 0x7F 300 | modeChange = "on" if int(msg[20:20 + 2], 16) & 0x80 else "off" 301 | sunMode = "on" if int(msg[12:12 + 2], 16) & 0x10 else "off" 302 | timerAuto = "on" if int(msg[12:12 + 2], 16) & 0x01 else "off" 303 | sunAuto = "on" if int(msg[12:12 + 2], 16) & 0x04 else "off" 304 | dawnAuto = "on" if int(msg[12:12 + 2], 16) & 0x40 else "off" 305 | duskAuto = "on" if int(msg[12:12 + 2], 16) & 0x02 else "off" 306 | manualMode = "on" if int(msg[12:12 + 2], 16) & 0x20 else "off" 307 | stairwellFunction = "on" if int(msg[8:8 + 4], 16) & 0x8000 else "off" 308 | stairwellTime = (int(msg[8:8 + 4], 16) & 0x7FFF) / 10 309 | 310 | state = level 311 | if level == 0: 312 | state = "off" 313 | if level == 100: 314 | state = "on" 315 | 316 | # readingsBeginUpdate(hash) 317 | self.update_state(code, "sunMode", sunMode, "1", channel=channel2) 318 | self.update_state(code, "timeAutomatic", timerAuto, "1", channel=channel2) 319 | self.update_state(code, "sunAutomatic", sunAuto, "1", channel=channel2) 320 | self.update_state(code, "dawnAutomatic", dawnAuto, "1", channel=channel2) 321 | self.update_state(code, "duskAutomatic", duskAuto, "1", channel=channel2) 322 | self.update_state(code, "manualMode", manualMode, "1", channel=channel2) 323 | self.update_state(code, "modeChange", modeChange, "1", channel=channel2) 324 | self.update_state(code, "stairwellFunction", stairwellFunction, "1", channel=channel2) 325 | self.update_state(code, "stairwellTime", stairwellTime, "1", channel=channel2) 326 | self.update_state(code, "level", level, "1", channel=channel2) 327 | self.update_state(code, "state", state, "1", channel=channel2) 328 | # readingsEndUpdate(hash, 1) # Notify is done by Dispatch 329 | elif format == "23": 330 | pos = int(msg[22:22 + 2], 16) & 0x7F 331 | reversal = "on" if int(msg[22:22 + 2], 16) & 0x80 else "off" 332 | ventPos = int(msg[16:16 + 2], 16) & 0x7F 333 | ventMode = "on" if int(msg[16:16 + 2], 16) & 0x80 else "off" 334 | sunPos = int(msg[18:18 + 2], 16) & 0x7F 335 | sunMode = "on" if int(msg[14:14 + 2], 16) & 0x10 else "off" 336 | timerAuto = "on" if int(msg[14:14 + 2], 16) & 0x01 else "off" 337 | sunAuto = "on" if int(msg[14:14 + 2], 16) & 0x04 else "off" 338 | dawnAuto = "on" if int(msg[12:12 + 2], 16) & 0x02 else "off" 339 | duskAuto = "on" if int(msg[14:14 + 2], 16) & 0x02 else "off" 340 | manualMode = "on" if int(msg[14:14 + 2], 16) & 0x20 else "off" 341 | windAuto = "on" if int(msg[14:14 + 2], 16) & 0x40 else "off" 342 | windMode = "on" if int(msg[14:14 + 2], 16) & 0x08 else "off" 343 | windDir = "down" if int(msg[12:12 + 2], 16) & 0x04 else "up" 344 | rainAuto = "on" if int(msg[14:14 + 2], 16) & 0x80 else "off" 345 | rainMode = "on" if int(msg[12:12 + 2], 16) & 0x01 else "off" 346 | rainDir = "down" if int(msg[12:12 + 2], 16) & 0x08 else "up" 347 | runningTime = int(msg[20:20 + 2], 16) 348 | deadTime = int(msg[12:12 + 2], 16) & 0x30 349 | blindsMode = "on" if int(msg[26:26 + 2], 16) & 0x80 else "off" 350 | tiltInSunPos = "on" if int(msg[18:18 + 2], 16) & 0x80 else "off" 351 | tiltInVentPos = "on" if int(msg[8:8 + 2], 16) & 0x80 else "off" 352 | tiltAfterMoveLevel = "on" if int(msg[8:8 + 2], 16) & 0x40 else "off" 353 | tiltAfterStopDown = "on" if int(msg[10:10 + 2], 16) & 0x80 else "off" 354 | defaultSlatPos = int(msg[10:10 + 2], 16) & 0x7F 355 | slatRunTime = int(msg[8:8 + 2], 16) & 0x3F 356 | slatPosition = int(msg[26:26 + 2], 16) & 0x7F 357 | 358 | state = "opened" if (pos == 0) else pos 359 | state = "closed" if (pos == 100) else state 360 | 361 | # readingsBeginUpdate(hash) 362 | self.update_state(code, "ventilatingPosition", ventPos, "1", channel=channel) 363 | self.update_state(code, "ventilatingMode", ventMode, "1", channel=channel) 364 | self.update_state(code, "sunPosition", sunPos, "1", channel=channel) 365 | self.update_state(code, "sunMode", sunMode, "1", channel=channel) 366 | self.update_state(code, "timeAutomatic", timerAuto, "1", channel=channel) 367 | self.update_state(code, "sunAutomatic", sunAuto, "1", channel=channel) 368 | self.update_state(code, "dawnAutomatic", dawnAuto, "1", channel=channel) 369 | self.update_state(code, "duskAutomatic", duskAuto, "1", channel=channel) 370 | self.update_state(code, "manualMode", manualMode, "1", channel=channel) 371 | self.update_state(code, "windAutomatic", windAuto, "1", channel=channel) 372 | self.update_state(code, "windMode", windMode, "1", channel=channel) 373 | self.update_state(code, "windDirection", windDir, "1", channel=channel) 374 | self.update_state(code, "rainAutomatic", rainAuto, "1", channel=channel) 375 | self.update_state(code, "rainMode", rainMode, "1", channel=channel) 376 | self.update_state(code, "rainDirection", rainDir, "1", channel=channel) 377 | self.update_state(code, "runningTime", runningTime, "1", channel=channel) 378 | self.update_state(code, "motorDeadTime", deadTimes[deadTime], "1", channel=channel) 379 | self.update_state(code, "position", pos, "1", channel=channel) 380 | self.update_state(code, "reversal", reversal, "1", channel=channel) 381 | self.update_state(code, "blindsMode", blindsMode, "1", channel=channel) 382 | 383 | # not tested yet 384 | if blindsMode == "on": # pragma: no cover 385 | self.update_state(code, "tiltInSunPos", tiltInSunPos, "1", channel=channel) 386 | self.update_state(code, "tiltInVentPos", tiltInVentPos, "1", channel=channel) 387 | self.update_state(code, "tiltAfterMoveLevel", tiltAfterMoveLevel, "1", channel=channel) 388 | self.update_state(code, "tiltAfterStopDown", tiltAfterStopDown, "1", channel=channel) 389 | self.update_state(code, "defaultSlatPos", defaultSlatPos, "1", channel=channel) 390 | self.update_state(code, "slatRunTime", slatRunTime, "1", channel=channel) 391 | self.update_state(code, "slatPosition", slatPosition, "1", channel=channel) 392 | else: 393 | self.delete_state(code, 'tiltInSunPos', channel=channel) 394 | self.delete_state(code, 'tiltInVentPos', channel=channel) 395 | self.delete_state(code, 'tiltAfterMoveLevel', channel=channel) 396 | self.delete_state(code, 'tiltAfterStopDown', channel=channel) 397 | self.delete_state(code, 'defaultSlatPos', channel=channel) 398 | self.delete_state(code, 'slatRunTime', channel=channel) 399 | self.delete_state(code, 'slatPosition', channel=channel) 400 | 401 | self.update_state(code, "moving", "stop", "1", channel=channel) 402 | self.update_state(code, "state", state, "1", channel=channel) 403 | # readingsEndUpdate(hash, 1) # Notify is done by Dispatch 404 | # Rohrmotor, SX5 -- not tested yet 405 | elif format == "24": # pragma: no cover 406 | 407 | pos = int(msg[22:22 + 2], 16) & 0x7F 408 | reversal = "on" if int(msg[22:22 + 2], 16) & 0x80 else "off" 409 | ventPos = int(msg[16:16 + 2], 16) & 0x7F 410 | ventMode = "on" if int(msg[16:16 + 2], 16) & 0x80 else "off" 411 | sunPos = int(msg[18:18 + 2], 16) & 0x7F 412 | sunMode = "on" if int(msg[14:14 + 2], 16) & 0x10 else "off" 413 | timerAuto = "on" if int(msg[14:14 + 2], 16) & 0x01 else "off" 414 | sunAuto = "on" if int(msg[14:14 + 2], 16) & 0x04 else "off" 415 | dawnAuto = "on" if int(msg[12:12 + 2], 16) & 0x02 else "off" 416 | duskAuto = "on" if int(msg[14:14 + 2], 16) & 0x02 else "off" 417 | manualMode = "on" if int(msg[14:14 + 2], 16) & 0x20 else "off" 418 | windAuto = "on" if int(msg[14:14 + 2], 16) & 0x40 else "off" 419 | windMode = "on" if int(msg[14:14 + 2], 16) & 0x08 else "off" 420 | windDir = "down" if int(msg[12:12 + 2], 16) & 0x04 else "up" 421 | rainAuto = "on" if int(msg[14:14 + 2], 16) & 0x80 else "off" 422 | rainMode = "on" if int(msg[12:12 + 2], 16) & 0x01 else "off" 423 | rainDir = "down" if int(msg[12:12 + 2], 16) & 0x08 else "up" 424 | obstacle = "1" if int(msg[12:12 + 2], 16) & 0x10 else "0" 425 | block = "1" if int(msg[12:12 + 2], 16) & 0x40 else "0" 426 | lightCurtain = "1" if int(msg[8:8 + 2], 16) & 0x80 else "0" 427 | autoClose = int(msg[10:10 + 2], 16) & 0x0F 428 | openSpeed = int(msg[10:10 + 2], 16) & 0x30 429 | alert2000 = "on" if int(msg[10:10 + 2], 16) & 0x80 else "off" 430 | backJump = "on" if int(msg[26:26 + 2], 16) & 0x01 else "off" 431 | alert10 = "on" if int(msg[26:26 + 2], 16) & 0x02 else "off" 432 | 433 | state = pos 434 | state = "opened" if (pos == 0) else pos 435 | state = "closed" if (pos == 100) else pos 436 | state = "light curtain" if (lightCurtain == "1") else pos 437 | state = "obstacle" if (obstacle == "1") else pos 438 | state = "block" if (block == "1") else pos 439 | 440 | # readingsBeginUpdate(hash) 441 | self.update_state(code, "manualMode", manualMode, "1", channel=channel) 442 | self.update_state(code, "timeAutomatic", timerAuto, "1", channel=channel) 443 | self.update_state(code, "ventilatingPosition", ventPos, "1", channel=channel) 444 | self.update_state(code, "ventilatingMode", ventMode, "1", channel=channel) 445 | self.update_state(code, "position", pos, "1", channel=channel) 446 | self.update_state(code, "state", state, "1", channel=channel) 447 | self.update_state(code, "obstacle", obstacle, "1", channel=channel) 448 | self.update_state(code, "block", block, "1", channel=channel) 449 | self.update_state(code, "moving", "stop", "1", channel=channel) 450 | 451 | if code[0:2] == "4E": # SX5 452 | self.update_state(code, "10minuteAlarm", alert10, "1", channel=channel) 453 | self.update_state(code, "automaticClosing", closingTimes['autoClose'], "1", channel=channel) 454 | self.update_state(code, "2000cycleAlarm", alert2000, "1", channel=channel) 455 | self.update_state(code, "openSpeed", openSpeeds['openSpeed'], "1", channel=channel) 456 | self.update_state(code, "backJump", backJump, "1", channel=channel) 457 | self.update_state(code, "lightCurtain", lightCurtain, "1", channel=channel) 458 | else: 459 | self.update_state(code, "sunPosition", sunPos, "1", channel=channel) 460 | self.update_state(code, "sunMode", sunMode, "1", channel=channel) 461 | self.update_state(code, "sunAutomatic", sunAuto, "1", channel=channel) 462 | self.update_state(code, "dawnAutomatic", dawnAuto, "1", channel=channel) 463 | self.update_state(code, "duskAutomatic", duskAuto, "1", channel=channel) 464 | self.update_state(code, "windAutomatic", windAuto, "1", channel=channel) 465 | self.update_state(code, "windMode", windMode, "1", channel=channel) 466 | self.update_state(code, "windDirection", windDir, "1", channel=channel) 467 | self.update_state(code, "rainAutomatic", rainAuto, "1", channel=channel) 468 | self.update_state(code, "rainMode", rainMode, "1", channel=channel) 469 | self.update_state(code, "rainDirection", rainDir, "1", channel=channel) 470 | self.update_state(code, "reversal", reversal, "1", channel=channel) 471 | 472 | # readingsEndUpdate(hash, 1) 473 | 474 | # Dimmaktor -- not tested yet 475 | elif format == "25": # pragma: no cover 476 | stairwellFunction = "on" if int(msg[10:10 + 4], 16) & 0x8000 else "off" 477 | stairwellTime = (int(msg[10:10 + 4], 16) & 0x7FFF) / 10 478 | timerAuto = "on" if int(msg[14:14 + 2], 16) & 0x01 else "off" 479 | duskAuto = "on" if int(msg[14:14 + 2], 16) & 0x02 else "off" 480 | sunAuto = "on" if int(msg[14:14 + 2], 16) & 0x04 else "off" 481 | sunMode = "on" if int(msg[14:14 + 2], 16) & 0x08 else "off" 482 | manualMode = "on" if int(msg[14:14 + 2], 16) & 0x20 else "off" 483 | dawnAuto = "on" if int(msg[14:14 + 2], 16) & 0x40 else "off" 484 | intemedSave = "on" if int(msg[14:14 + 2], 16) & 0x80 else "off" 485 | runningTime = int(msg[18:18 + 2], 16) 486 | intemedVal = int(msg[20:20 + 2], 16) & 0x7F 487 | intermedMode = "on" if int(msg[20:20 + 2], 16) & 0x80 else "off" 488 | level = int(msg[22:22 + 2], 16) & 0x7F 489 | modeChange = "on" if int(msg[22:22 + 2], 16) & 0x80 else "off" 490 | 491 | state = level 492 | 493 | if level == 0: 494 | state = "off" 495 | if level == 100: 496 | state = "on" 497 | 498 | # readingsBeginUpdate(hash) 499 | self.update_state(code, "stairwellFunction", stairwellFunction, "1", channel=channel) 500 | self.update_state(code, "stairwellTime", stairwellTime, "1", channel=channel) 501 | self.update_state(code, "timeAutomatic", timerAuto, "1", channel=channel) 502 | self.update_state(code, "duskAutomatic", duskAuto, "1", channel=channel) 503 | self.update_state(code, "sunAutomatic", sunAuto, "1", channel=channel) 504 | self.update_state(code, "sunMode", sunMode, "1", channel=channel) 505 | self.update_state(code, "manualMode", manualMode, "1", channel=channel) 506 | self.update_state(code, "dawnAutomatic", dawnAuto, "1", channel=channel) 507 | self.update_state(code, "saveIntermediateOnStop", intemedSave, "1", channel=channel) 508 | self.update_state(code, "runningTime", runningTime, "1", channel=channel) 509 | self.update_state(code, "intermediateValue", intemedVal, "1", channel=channel) 510 | self.update_state(code, "intermediateMode", intermedMode, "1", channel=channel) 511 | self.update_state(code, "level", level, "1", channel=channel) 512 | self.update_state(code, "modeChange", modeChange, "1", channel=channel) 513 | self.update_state(code, "state", state, "1", channel=channel) 514 | # readingsEndUpdate(hash, 1) # Notify is done by Dispatch 515 | 516 | # Thermostat -- not tested yet 517 | elif format == "27": # pragma: no cover 518 | temperature1 = "%0.1f" % (((int(msg[8:8 + 4], 16) & 0x07FF) - 400) / 10) 519 | temperature2 = "%0.1f" % (((int(msg[12:12 + 4], 16) & 0x07FF) - 400) / 10) 520 | tempThreshold1 = "%0.1f" % ((int(msg[16:16 + 2], 16) - 80) / 2) 521 | tempThreshold2 = "%0.1f" % ((int(msg[18:18 + 2], 16) - 80) / 2) 522 | tempThreshold3 = "%0.1f" % ((int(msg[20:20 + 2], 16) - 80) / 2) 523 | tempThreshold4 = "%0.1f" % ((int(msg[22:22 + 2], 16) - 80) / 2) 524 | desiredTemp = "%0.1f" % ((int(msg[26:26 + 2], 16) - 80) / 2) 525 | output = "on" if int(msg[8:8 + 2], 16) & 0x08 else "off" 526 | manualOverride = "on" if int(msg[8:8 + 2], 16) & 0x10 else "off" 527 | actTempLimit = (int(msg[8:8 + 2], 16) & 0x60) >> 5 528 | timerAuto = "on" if int(msg[12:12 + 2], 16) & 0x08 else "off" 529 | manualMode = "on" if int(msg[12:12 + 2], 16) & 0x10 else "off" 530 | 531 | state = "T: temperature1 desired: desiredTemp" 532 | 533 | # readingsBeginUpdate(hash) 534 | self.update_state(code, "measured-temp", temperature1, "1", channel=channel) 535 | self.update_state(code, "measured-temp2", temperature2, "1", channel=channel) 536 | self.update_state(code, "temperatureThreshold1", tempThreshold1, "1", channel=channel) 537 | self.update_state(code, "temperatureThreshold2", tempThreshold2, "1", channel=channel) 538 | self.update_state(code, "temperatureThreshold3", tempThreshold3, "1", channel=channel) 539 | self.update_state(code, "temperatureThreshold4", tempThreshold4, "1", channel=channel) 540 | self.update_state(code, "desired-temp", desiredTemp, "1", channel=channel) 541 | self.update_state(code, "output", output, "1", channel=channel) 542 | self.update_state(code, "manualOverride", manualOverride, "1", channel=channel) 543 | self.update_state(code, "actTempLimit", actTempLimit, "1", channel=channel) 544 | self.update_state(code, "timeAutomatic", timerAuto, "1", channel=channel) 545 | self.update_state(code, "manualMode", manualMode, "1", channel=channel) 546 | 547 | self.update_state(code, "state", state, "1", channel=channel) 548 | # readingsEndUpdate(hash, 1) 549 | 550 | else: 551 | logger.info("DUOFERN unknown msg: {}".format(msg)) 552 | 553 | 554 | # Wandtaster, Funksender UP, Handsender, Sensoren 555 | elif msg[0:2] == "0f" and msg[4:6] in ['07', '0e']: # pragma: no cover 556 | id = msg[4:4 + 4] 557 | 558 | if id not in sensorMsg: 559 | logger.warning("unknown message {}".format(msg)) 560 | return 561 | 562 | chan = msg[sensorMsg[id]['chan'] * 2 + 2:sensorMsg[id]['chan'] * 2 + 4] 563 | if code[0:2] in ("61", "70", "71"): 564 | chan = "01" 565 | 566 | chans = [] 567 | if (sensorMsg[id]["chan"] == 5): 568 | chanCount = 4 if (code[0:2] == "73") else 5 569 | for x in range(0, chanCount): 570 | if ((0x01 << x) & int(chan, 16)): 571 | chans.append(x + 1) 572 | 573 | 574 | else: 575 | chans.append(chan) 576 | 577 | if code[0:2] in ("65", "69", "74"): 578 | # module_definition01 = self.modules['by_code'][code + "00"] 579 | channel = 0 580 | #if not module_definition01: 581 | #DoTrigger("global", "UNDEFINED DUOFERN_code_sensor DUOFERN code00") 582 | #module_definition01 = self.modules['by_code'][code + "00"] 583 | 584 | #if (module_definition01): 585 | # hash = module_definition01 586 | # channel = 0 587 | 588 | for chan in chans: 589 | if id[2:4] in ("1a", "18", "19", "01", "02", "03"): 590 | if (id[2:4] == "1a") or (id[0:2] == "0e") or (code[0:2] in ("a0", "a2")): 591 | self.update_state(code, "state", sensorMsg[id]['state'] + "." + chan, "1", channel=channel) 592 | else: 593 | self.update_state(code, "state", sensorMsg[id]['state'], "1", channel=channel) 594 | 595 | self.update_state(code, "channelchan", sensorMsg[id]['name'], "1", channel=channel) 596 | else: 597 | if (code[0:2] not in ("69", "73")) or (id[2:4] in ("11", "12")): 598 | chan = "" 599 | if code[0:2] in ("65", "a5", "aa", "ab"): 600 | self.update_state(code, "state", sensorMsg[id]['state'], "1", channel=channel) 601 | 602 | self.update_state(code, "event", sensorMsg[id]['name'] + "." + chan, "1", channel=channel) 603 | # DoTrigger(hash["name"], sensorMsg[id][name] + "." + chan) 604 | 605 | 606 | 607 | # Umweltsensor Wetter -- not tested yet 608 | elif msg[0:8] == "0f011322": # pragma: no cover 609 | # module_definition01 = self.modules['by_code'][code + "00"] 610 | # if not module_definition01: 611 | # DoTrigger("global", "UNDEFINED DUOFERN_code_sensor DUOFERN code00") 612 | # module_definition01 = self.modules['by_code'][code + "00"] 613 | # 614 | # hash = module_definition01 615 | channel = 0 616 | 617 | brightnessExp = 1000 if int(msg[8:8 + 4], 16) & 0x0400 else 1 618 | brightness = (int(msg[8:8 + 4], 16) & 0x01FF) * brightnessExp 619 | sunDirection = int(msg[14:14 + 2], 16) * 1.5 620 | sunHeight = int(msg[16:16 + 2], 16) - 90 621 | temperature = ((int(msg[18:18 + 4], 16) & 0x7FFF) - 400) / 10 622 | isRaining = 1 if int(msg[18:18 + 4], 16) & 0x8000 else 0 623 | wind = (int(msg[22:22 + 4], 16) & 0x03FF) / 10 624 | 625 | state = "T: {}".format(temperature) 626 | state += " W: {}".format(wind) 627 | state += " IR: ".format(isRaining) 628 | state += " B: ".format(brightness) 629 | 630 | # readingsBeginUpdate(hash) 631 | self.update_state(code, "brightness", brightness, "1", channel=channel) 632 | self.update_state(code, "sunDirection", sunDirection, "1", channel=channel) 633 | self.update_state(code, "sunHeight", sunHeight, "1", channel=channel) 634 | self.update_state(code, "temperature", temperature, "1", channel=channel) 635 | self.update_state(code, "isRaining", isRaining, "1", channel=channel) 636 | self.update_state(code, "state", state, "1", channel=channel) 637 | self.update_state(code, "wind", wind, "1", channel=channel) 638 | # readingsEndUpdate(hash, 1) # Notify is done by Dispatch 639 | 640 | # Umweltsensor Zeit 641 | elif msg[0:8] == "0fff1020": # pragma: no cover 642 | # module_definition01 = self.modules['by_code'][code + "00"] 643 | # if (not module_definition01): 644 | # DoTrigger("global", "UNDEFINED DUOFERN_code_sensor DUOFERN code00") 645 | # module_definition01 = self.modules['by_code'][code + "00"] 646 | # 647 | # hash = module_definition01 648 | channel = 0 649 | 650 | year = msg[12:12 + 2] 651 | month = msg[14:14 + 2] 652 | day = msg[18:18 + 2] 653 | hour = msg[20:20 + 2] 654 | minute = msg[22:22 + 2] 655 | second = msg[24:24 + 2] 656 | 657 | # readingsBeginUpdate(hash) 658 | self.update_state(code, "date", "20" + str(year) + "-" + str(month) + "-" + str(day), "1", channel=channel) 659 | self.update_state(code, "time", str(hour) + ":" + str(minute) + ":" + str(second), "1", channel=channel) 660 | # readingsEndUpdate(hash, 1) # Notify is done by Dispatch 661 | 662 | # Umweltsensor Konfiguration 663 | elif msg[0:7] == "0fff1b2" and msg[7] in ["0", "1", "2", "3", "4", "5", "6", "7", "8"]: # pragma: no cover 664 | reg = msg[6:6 + 2] - 21 665 | regVal = msg[8:8 + 20] 666 | 667 | # module_definition01 = self.modules['by_code'][code + "00"] 668 | # if not module_definition01: 669 | # DoTrigger("global", "UNDEFINED DUOFERN_code_sensor DUOFERN {}00".format(code)) 670 | # module_definition01 = self.modules['by_code'][code + "00"] 671 | 672 | # hash = module_definition01 673 | channel = 0 674 | 675 | logger.warning("Weather sensor not supported yet") 676 | #del hash['READINGS']['configModified'] 677 | self.update_state(code, ".regreg", "regVal", "1", channel=channel) 678 | # self.update_state(code, "regreg", "regVal", "1", channel=channel) 679 | 680 | #DUOFERN_DecodeWeatherSensorConfig(hash) 681 | 682 | # Rauchmelder Batterie 683 | elif msg[0:8] == "0fff1323": # pragma: no cover 684 | battery = "low" if int(msg[8:8 + 2], 16) <= 10 else "ok" 685 | batteryLevel = int(msg[8:8 + 2], 16) 686 | 687 | # readingsBeginUpdate(hash) 688 | self.update_state(code, "battery", battery, "1", channel=channel) 689 | self.update_state(code, "batteryLevel", batteryLevel, "1", channel=channel) 690 | # readingsEndUpdate(hash, 1) # Notify is done by Dispatch 691 | 692 | # ACK, Befehl vom Aktor empfangen 693 | elif msg[0:8] == "810003cc": 694 | logger.debug("ack received {}".format(self.modules['by_code'][code])) 695 | #hash['helper']['timeout']['t'] = hash['name']["timeout"]["60"] 696 | ##InternalTimer(gettimeofday()+hash['helper']['timeout']{t}, "DUOFERN_StatusTimeout", hash, 0) 697 | #hash['helper']['timeout']['count'] = 4 698 | 699 | # NACK, Befehl nicht vom Aktor empfangen 700 | elif msg[0:8] == "810108aa": 701 | logger.info("missing ack for {}".format(self.modules['by_code'][code])) 702 | # self.update_state(code, "state", "MISSING ACK", "1", channel=channel) 703 | # foreach (grep (/^channel_/, keys%{hash})){ 704 | # chnHash = module_definitions{hash->{_}} 705 | # readingsSingleUpdate(chnHash, "state", "MISSING ACK", 1) 706 | # } 707 | # Log3 hash, 3, "DUOFERN error: name MISSING ACK" 708 | 709 | else: 710 | logger.info("Unknown msg: {}".format(msg)) 711 | 712 | # if module_definition01: 713 | # DoTrigger(module_definition01['name'], None) 714 | # if module_definition02: 715 | # DoTrigger(module_definition02['name'], None) 716 | 717 | return name 718 | 719 | def send(self, cmd): 720 | self.send_hook(cmd) 721 | 722 | def set(self, code, cmd, *args, channel: int = None): 723 | # my (hash, @a) = @_ 724 | # b = @a 725 | 726 | # return "set name needs at least one parameter" if(@a < 2) 727 | 728 | # me = shift @a 729 | # cmd = shift @a 730 | arg = args[0] if len(args) >= 1 else None 731 | arg2 = args[1] if len(args) > 1 else None 732 | assert len(code) == 6, "code should be 6 hex digits" 733 | # code = code[0:0 + 6] 734 | name = self.modules['by_code'][code]['name'] 735 | 736 | sets = {} 737 | 738 | if code[0:2] == "49": 739 | sets = merge_dicts(setsBasic, setsDefaultRollerShutter, setsRolloTube) 740 | if code[0:2] in ("42", "4b", "4c", "70"): 741 | sets = merge_dicts(setsBasic, setsDefaultRollerShutter, setsTroll, {"blindsMode:on,off": ""}) 742 | if code[0:2] == "47": 743 | sets = merge_dicts(setsBasic, setsDefaultRollerShutter, setsTroll) 744 | if code[0:2] in ("40", "41", "61"): 745 | sets = merge_dicts(setsBasic, setsDefaultRollerShutter) # if (code =~ /^(40|41|61)..../) 746 | if code[0:2] == "69": 747 | sets = merge_dicts(setsBasic, setsUmweltsensor) # if (code =~ /^69..../) 748 | if code[0:2] == "69" and len(code) >= 8 and code[6:8] == "00": 749 | sets = merge_dicts(setsUmweltsensor00) # if (code =~ /^69....00/) 750 | if code[0:2] == "69" and len(code) >= 8 and code[6:8] == "01": 751 | sets = merge_dicts(setsDefaultRollerShutter, setsUmweltsensor01) # if (code =~ /^69....01/) 752 | if code[0:2] == "43" and len(code) >= 8 and code[6:8] in ("01", "02"): 753 | sets = merge_dicts(*setsSwitchActor) # if (code =~ /^43....(01|02)/) 754 | if code[0:2] in ("43", "65", "74"): 755 | sets = merge_dicts(setsBasic, {"getStatus:noArg": ""}) # if (code =~ /^(43|65|74)..../) 756 | if code[0:2] in ("46", "71"): 757 | sets = merge_dicts(setsBasic, setsSwitchActor) # if (code =~ /^(46|71)..../) 758 | if code[0:2] == "4e": 759 | sets = merge_dicts(setsBasic, setsSX5) # if (code =~ /^4E..../) 760 | if code[0:2] == "48": 761 | sets = merge_dicts(setsBasic, setsDimmer) # if (code =~ /^48..../) 762 | if code[0:2] == "73": 763 | sets = merge_dicts(setsBasic, setsThermostat) # if (code =~ /^73..../) 764 | if code[0:2] in ("65", "74") and len(code) >= 8 and code[6:8] == "01": 765 | sets = merge_dicts(setsSwitchActor) # if (code =~ /^(65|74)....01/) 766 | 767 | blindsMode = "off" if not "blindsMode" in self.modules['by_code'][code] else self.modules['by_code'][code] 768 | if (blindsMode == "on"): 769 | sets = merge_dicts(sets, setsBlinds) 770 | 771 | logger.debug(sets.keys()) # join(" ", sort keys sets) 772 | if cmd in commandsStatus: 773 | buf = duoStatusRequest 774 | buf = buf.replace("nn", commandsStatus[cmd]) 775 | buf = buf.replace("yyyyyy", code) 776 | self.send(buf) 777 | return None 778 | 779 | elif cmd == "clear": 780 | keys = self.modules['by_code'][code].keys() 781 | for key in keys: 782 | if key != 'name': 783 | self.modules['by_code'][code].__delitem__(key) 784 | return None 785 | # cH = (hash) 786 | # delete _->{READINGS} foreach (@cH) 787 | # return undef 788 | 789 | elif cmd == "getConfig": 790 | buf = duoWeatherConfig 791 | buf = buf.replace("yyyyyy", code) 792 | self.send(buf) 793 | return None 794 | 795 | elif cmd == "writeConfig": 796 | for x in range(0, 8): 797 | # for(my x=0; x<8; x++) { 798 | regV = "00000000000000000000" if not ".reg{}".format(x) in self.modules['by_code'][code] else \ 799 | self.modules['by_code'][code][".reg{}".format(x)] 800 | reg = "%02x" % (x + 0x81) 801 | buf = duoWeatherWriteConfig 802 | buf = buf.replace("yyyyyy", code) 803 | buf = buf.replace("rr", reg) 804 | buf = buf.replace("nnnnnnnnnnnnnnnnnnnn", regV) 805 | self.send(buf) 806 | 807 | if "configModified" in self.modules['by_code'][code]: 808 | self.modules['by_code'][code].__delitem__("configModified") 809 | 810 | # delete hash->{READINGS}{configModified} 811 | return None 812 | 813 | elif cmd == "time": 814 | buf = duoSetTime 815 | 816 | # my (sec,min,hour,mday,month,year,wday,yday,isdst) = localtime 817 | 818 | year, month, mday, hour, min, sec, wday, yday, isdst, = time.localtime() 819 | 820 | wday = wday - 1 if wday != 0 else 7 # wday = (wday==0 ? 7 : wday-1) 821 | m = "%02d%02d%02d%02d" % (year - 100, month + 1, wday, mday) 822 | n = "%02d%02d%02d" % (hour, min, sec) 823 | 824 | buf = buf.replace("mmmmmmmm", m) 825 | buf = buf.replace("nnnnnn", n) 826 | buf = buf.replace("yyyyyy", code) 827 | self.send(buf) 828 | return None 829 | 830 | elif cmd in wCmds: 831 | logger.error("this has not been implemented yet") 832 | # if code[0:2] =="69" and len(code)>=8 and code[6:8] == "00": 833 | # return "This command is not allowed for this device." 834 | # regs=[] 835 | # if len(args)<1: 836 | # return "Missing argument" 837 | # 838 | # local_args = args + ("off","off","off","off") 839 | # 840 | # for x in range(0,8):#for(my x=0; x<8; x++) { 841 | # temp="00000000000000000000" if not ".regx" in self.modules['by_code'][code] else self.modules['by_code'][code][".regx"] 842 | # regs.append(temp) 843 | # 844 | # 845 | # if cmd == "triggerSun": 846 | # logger.error("this needs to be implemented (triggerSun)") 847 | # # newargs=[] 848 | # # for _arg in args: 849 | # # if (_arg != "off"): 850 | # # args2 = _arg.split(":") 851 | # # temp = _arg 852 | # # #return "Wrong argument _" if (args2[0] !~ m/^\d+/ || args2[0] < 1 || args2[0] > 100) 853 | # # #return "Wrong argument _" if (args2[1] !~ m/^\d+/ || args2[1] < 1 || args2[1] > 30) 854 | # # #return "Wrong argument _" if (args2[2] !~ m/^\d+/ || args2[2] < 1 || args2[2] > 30) 855 | # # 856 | # # if (len(args2) < 3): 857 | # # return "Missing argument" 858 | # # if (int(args2[0]) < 1 or args2[0] > 100) or\ 859 | # # (int(args2[1]) < 1 or args2[1] > 30) or\ 860 | # # (int(args2[2]) < 1 or args2[2] > 30): 861 | # # return "Wrong argument {}".format(_arg) 862 | # # _arg = ((args2[0]-1)<<12) | ((args2[1]-1)<<19) | ((args2[2]-1)<<24) 863 | # # 864 | # # if(len(args2) > 3): 865 | # # if (int(args2[3]) < -5 or int(args2[3]) > 26): 866 | # # return "Wrong argument {}".format(temp) 867 | # # _arg |= (((int(args2[3]) + 5) << 7) | 0x40) 868 | # # newargs.append(arg) 869 | # # args=newargs 870 | # 871 | # 872 | # 873 | # 874 | # 875 | # if cmd == "triggerSunDirection": 876 | # logger.error("not implemented (triggersundirection)") 877 | # # for(my x=0; x<5; x++) { 878 | # # if (args[x] ne "off") { 879 | # # args2 = split(/:/, args[x]) 880 | # # return "Missing argument" if(@args2 < 2) 881 | # # return "Wrong argument args[x]" if (args2[0] !~ m/^\d+(\.\d+|)/ || args2[0] < 0 || args2[0] > 315) 882 | # # return "Wrong argument args[x]" if (args2[1] !~ m/^\d+/ || args2[1] < 45 || args2[1] > 180) 883 | # # args2[0] = int((args2[0]+11.25)/22.5) 884 | # # args2[1] = int((args2[1]+22.5)/45) 885 | # # args2[0] = 15 - (args2[1]*2) if ((args2[0] + args2[1]*2) > 15) 886 | # # args[x] = (args2[0]+args2[1]) | ((args2[1])<<4) | 0x80 887 | # # } else { 888 | # # tSunHeight = map{hex(_)} unpack 'x66A2x8A2x8A2x8A2x8A2', regs 889 | # # if (tSunHeight[x] & 0x18) { 890 | # # args[x] = 0x81 891 | # # } else { 892 | # # args[x] = 0x01 893 | # # } 894 | # # } 895 | # # } 896 | # } 897 | # 898 | # if cmd == "triggerSunHeight": 899 | # logger.error("not implemented (triggersundirection)") 900 | # # 901 | # # 902 | # # for(my x=0; x<5; x++) { 903 | # # if (args[x] ne "off") { 904 | # # args2 = split(/:/, args[x]) 905 | # # return "Missing argument" if(@args2 < 2) 906 | # # return "Wrong argument1 args[x]" if (args2[0] !~ m/^\d+/ || args2[0] < 0 || args2[0] > 90) 907 | # # return "Wrong argument2 args[x]" if (args2[1] !~ m/^\d+/ || args2[1] < 20 || args2[1] > 60) 908 | # # args2[0] = int((args2[0]+6.5)/13) 909 | # # args2[1] = int((args2[1]+13)/26) 910 | # # args2[0] = 7 - (args2[1]*2) if ((args2[0] + args2[1]*2) > 7) 911 | # # args[x] = ((args2[0]+args2[1])<<8) | ((args2[1])<<11) | 0x80 912 | # # } else { 913 | # # tSunDir = map{hex(_)} unpack 'x68A2x8A2x8A2x8A2x8A2', regs 914 | # # if (tSunDir[x] & 0x70) { 915 | # # args[x] = 0x0180 916 | # # } else { 917 | # # args[x] = 0x0100 918 | # # } 919 | # # } 920 | # # } 921 | # } 922 | # 923 | # for (my c = 0; c= wCmds{cmd}{min}) and (args[c] <= wCmds{cmd}{max})) { 935 | # reg &= ~(wCmds{cmd}{mask}) 936 | # reg |= wCmds{cmd}{enable} 937 | # reg |= ((args[c] + wCmds{cmd}{offset})< 0)) { 940 | # reg &= ~(wCmds{cmd}{enable}) 941 | # 942 | # } elsif ((args[c] == "on") and (wCmds{cmd}{min} == 0) and (wCmds{cmd}{max} == 0)) { 943 | # reg |= wCmds{cmd}{enable} 944 | # 945 | # } else { 946 | # return "wrong argument ".args[c] 947 | # 948 | # } 949 | # 950 | # size = wCmds{cmd}{size}*2 951 | # 952 | # substr(regs, regStart ,size, sprintf("0".size."x",reg)) 953 | # 954 | # } 955 | # 956 | # @regsA = unpack('(A20)*', regs) 957 | # 958 | # # readingsBeginUpdate(hash) 959 | # for(my x=0; x<8; x++) { 960 | # readingsBulkUpdate(hash, ".regx", regsA[x], 0) 961 | # #readingsBulkUpdate(hash, "regx", regsA[x], 0) 962 | # } 963 | # readingsBulkUpdate(hash, "configModified", 1, 0) 964 | # # readingsEndUpdate(hash, 1) 965 | # 966 | # DUOFERN_DecodeWeatherSensorConfig(hash) 967 | # return undef 968 | 969 | elif cmd in commands: 970 | logger.info("command valid") 971 | subCmd = None 972 | if channel is None: 973 | chanNo = "01" 974 | else: 975 | chanNo = "{:02x}".format(channel) 976 | argV = "00" 977 | argW = "0000" 978 | timer = "00" 979 | buf = duoCommand 980 | command = None 981 | 982 | if 'noArg' in commands[cmd]: 983 | if (arg and (arg == "timer")): 984 | timer = "01" 985 | subCmd = "noArg" 986 | argV = "00" 987 | 988 | elif 'value' in commands[cmd]: 989 | if (arg2 and (arg2 == "timer")): 990 | timer = "01" 991 | if arg is None: 992 | return "Missing argument" 993 | if (int(arg) < 0 or int(arg) > 100): 994 | raise Exception("Wrong argument arg") 995 | subCmd = "value" 996 | argV = "%02x" % arg 997 | 998 | elif 'value2' in commands[cmd]: 999 | if arg is None: 1000 | return "Missing argument" 1001 | if int(arg) < 0 or int(arg) > 3200: 1002 | raise Exception("Wrong argument arg") 1003 | subCmd = "value2" 1004 | argW = "%04x" % (arg * 10) 1005 | 1006 | elif 'value3' in commands[cmd]: 1007 | maxArg = 150 1008 | if code[0:2] == "48": 1009 | maxArg = 255 1010 | if arg2 and (arg2 == "timer"): 1011 | timer = "01" 1012 | if arg is None: 1013 | return "Missing argument" 1014 | if int(arg) < 0 or int(arg) > maxArg: 1015 | raise Exception("Wrong argument arg") 1016 | subCmd = "value3" 1017 | argV = "%02x" % arg 1018 | 1019 | elif 'value4' in commands[cmd]: 1020 | if arg2 and (arg2 == "timer"): 1021 | timer = "01" 1022 | if arg is None: 1023 | return "Missing argument" 1024 | if int(arg) < 0 or int(arg) > 5000: 1025 | raise Exception("Wrong argument arg") 1026 | arg = arg / 100 1027 | subCmd = "value4" 1028 | argV = "%02x" % arg 1029 | 1030 | elif 'temp1' in commands[cmd]: 1031 | if arg is None: 1032 | return "Missing Argument" 1033 | if int(arg) < -40 or int(arg) > 80: 1034 | return "Wrong argument {}".format(arg) 1035 | 1036 | # return "Missing argument" if (!defined(arg)) 1037 | # return "Wrong argument arg" if (arg !~ m/^\d+(\.\d+|)/ || arg < -40 || arg > 80) 1038 | subCmd = "temp1" 1039 | argW = "%04x" % ((arg * 10) + 400) 1040 | 1041 | elif 'temp2' in commands[cmd]: 1042 | if arg is None: 1043 | return "Missing Argument" 1044 | if int(arg) < -40 or int(arg) > 80: 1045 | return "Wrong argument {}".format(arg) 1046 | subCmd = "temp2" 1047 | argV = "%02x" % ((arg * 2) + 80) 1048 | 1049 | else: 1050 | if arg is None: 1051 | return "Missing Argument" 1052 | if (arg2 and (arg2 == "timer")): 1053 | timer = "01" 1054 | subCmd = arg 1055 | argV = "00" 1056 | 1057 | if subCmd not in commands[cmd]: 1058 | raise Exception("Wrong argument {}, {}".format(arg, subCmd)) 1059 | 1060 | channel_suffix = "" 1061 | if channel is not None: 1062 | channel_suffix = "_" + chanNo 1063 | 1064 | position = -1 if "position"+channel_suffix not in self.modules['by_code'][code] else \ 1065 | self.modules['by_code'][code]["position"+channel_suffix] 1066 | # toggleUpDown = AttrVal(name, "toggleUpDown", "0") 1067 | toggleUpDown = self.modules['by_code'][code]['toggleUpDown'+channel_suffix] \ 1068 | if 'toggleUpDown'+channel_suffix in self.modules['by_code'][code] else 0 1069 | moving = "stop" if "moving"+channel_suffix not in self.modules['by_code'][code] else \ 1070 | self.modules['by_code'][code]["moving"+channel_suffix] 1071 | timeAutomatic = "on" if "timeAutomatic"+channel_suffix not in self.modules['by_code'][code] else \ 1072 | self.modules['by_code'][code]["timeAutomatic"+channel_suffix] 1073 | dawnAutomatic = "on" if "dawnAutomatic"+channel_suffix not in self.modules['by_code'][code] else \ 1074 | self.modules['by_code'][code]["dawnAutomatic"+channel_suffix] 1075 | duskAutomatic = "on" if "duskAutomatic"+channel_suffix not in self.modules['by_code'][code] else \ 1076 | self.modules['by_code'][code]["duskAutomatic"+channel_suffix] 1077 | 1078 | if moving != "stop": 1079 | if cmd in ('up', 'down', 'toggle'): 1080 | if toggleUpDown: 1081 | cmd = "stop" 1082 | # self.update_state(code,"moving","moving", channel=channel) 1083 | 1084 | if ((cmd == "toggle") and (position > -1)): 1085 | self.update_state(code, "moving", "moving", 1, channel=channel) 1086 | if ((cmd == "dawn") and (dawnAutomatic == "on") and (position > 0)): 1087 | self.update_state(code, "moving", "up", 1, channel=channel) 1088 | if ((cmd == "dusk") and (duskAutomatic == "on") and (position < 100) and (position > -1)): 1089 | self.update_state(code, "moving", "down", 1, channel=channel) 1090 | 1091 | if timer == "00" or timeAutomatic == "on": 1092 | if ((cmd == "up") and (position > 0)): 1093 | self.update_state(code, "moving", "up", 1, channel=channel) 1094 | if ((cmd == "down") and (position < 100) and (position > -1)): 1095 | self.update_state(code, "moving", "down", 1, channel=channel) 1096 | 1097 | if cmd == "position": 1098 | if arg > position: 1099 | self.update_state(code, "moving", "down", 1, channel=channel) 1100 | elif (arg < position): 1101 | self.update_state(code, "moving", "up", 1, channel=channel) 1102 | else: 1103 | self.update_state(code, "moving", "stop", 1, channel=channel) 1104 | 1105 | command = commands[cmd][subCmd] 1106 | 1107 | buf = buf.replace("yyyyyy", code) 1108 | buf = buf.replace("nnnnnnnnnnnnnnnnnnnn", command) 1109 | buf = buf.replace("nn", argV) 1110 | buf = buf.replace("tt", timer) 1111 | buf = buf.replace("wwww", argW) 1112 | buf = buf.replace("kk", chanNo) 1113 | logger.debug("trying to send {}".format(buf)) 1114 | self.send(buf) 1115 | # if ('device' in self.modules['by_code'][code]): 1116 | # hash = defs{hash->{device}} 1117 | 1118 | else: 1119 | raise Exception("command {} not found".format(cmd)) 1120 | 1121 | 1122 | # return SetExtensions(hash, list, @b) 1123 | 1124 | if __name__ == "__main__": 1125 | formatter = logging.Formatter('%(asctime)s - %(filename)s:%(lineno)d - %(levelname)s - %(message)s') 1126 | handler = logging.StreamHandler() 1127 | handler.setFormatter(formatter) 1128 | logger.addHandler(handler) 1129 | logger.setLevel(logging.DEBUG) 1130 | -------------------------------------------------------------------------------- /pyduofern/duofern_stick.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | # python interface for dufoern usb stick 3 | # Copyright (C) 2017 Paul Görgen 4 | # Rough python re-write of the FHEM duofern modules by telekatz, also licensed under GPLv2 5 | # This re-write contains only negligible amounts of original code 6 | # apart from some comments to facilitate translation of the not-yet 7 | # translated parts of the original software. Modification dates are 8 | # documented as submits to the git repository of this code, currently 9 | # maintained at https://github.com/gluap/pyduofern.git 10 | 11 | # This program is free software; you can redistribute it and/or modify 12 | # it under the terms of the GNU General Public License as published by 13 | # the Free Software Foundation; either version 2 of the License, or 14 | # (at your option) any later version. 15 | 16 | # This program is distributed in the hope that it will be useful, 17 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 18 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 19 | # GNU General Public License for more details. 20 | 21 | # You should have received a copy of the GNU General Public License 22 | # along with this program; if not, write to the Free Software Foundation, 23 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 24 | 25 | import asyncio 26 | import codecs 27 | import datetime 28 | import json 29 | import logging 30 | import os 31 | import os.path 32 | import random 33 | import tempfile 34 | import threading 35 | import time 36 | from dataclasses import dataclass 37 | from queue import Queue, Empty 38 | from typing import Dict 39 | 40 | import serial 41 | import serial.tools.list_ports 42 | 43 | from .duofern import Duofern 44 | from .exceptions import DuofernTimeoutException, DuofernException 45 | 46 | 47 | def hex(stuff): 48 | return codecs.getencoder('hex')(stuff)[0].decode("utf-8") 49 | 50 | 51 | logger = logging.getLogger(__name__) 52 | 53 | duoInit1 = "01000000000000000000000000000000000000000000" 54 | duoInit2 = "0E000000000000000000000000000000000000000000" 55 | duoSetDongle = "0Azzzzzz000100000000000000000000000000000000" 56 | duoInit3 = "14140000000000000000000000000000000000000000" 57 | duoSetPairs = "03nnyyyyyy0000000000000000000000000000000000" 58 | duoInitEnd = "10010000000000000000000000000000000000000000" 59 | duoACK = "81000000000000000000000000000000000000000000" 60 | duoStatusRequest = "0DFF0F400000000000000000000000000000FFFFFF01" 61 | duoStartPair = "04000000000000000000000000000000000000000000" 62 | duoStopPair = "05000000000000000000000000000000000000000000" 63 | duoStartUnpair = "07000000000000000000000000000000000000000000" 64 | duoStopUnpair = "08000000000000000000000000000000000000000000" 65 | duoRemotePair = "0D0006010000000000000000000000000000yyyyyy01" 66 | 67 | 68 | MIN_MESSAGE_INTERVAL_MILLIS = 50 69 | RESEND_SECONDS = (2,4) 70 | 71 | def refresh_serial_connection(function): 72 | def new_funtion(*args, **kwargs): 73 | self = args[0] 74 | if self.serial_connection.isOpen(): 75 | return function(*args, **kwargs) 76 | else: # pragma: no cover 77 | self.serial_connection.open() 78 | return function(*args, **kwargs) 79 | 80 | return new_funtion 81 | 82 | 83 | class DuofernStick(object): 84 | def __init__(self, system_code=None, config_file_json=None, duofern_parser=None, recording=None, 85 | changes_callback=None, ephemeral=None, *args, **kwargs): 86 | """ 87 | :param device: path to com port opened by usb stick (e.g. /dev/ttyUSB0) 88 | :param system_code: system code 89 | :param config_file_json: path to config file. use the same one to conveniently update info about your system 90 | :param duofern_parser: parser object. Unless you hacked your own one just leave None and it 91 | defaults to pyduofern.duofern.Duofern() 92 | """ 93 | super().__init__(*args, **kwargs) 94 | self.config_file = None 95 | self.ephemeral = ephemeral 96 | config_file_json = self.prepare_config(config_file_json) 97 | self.config_file = config_file_json 98 | 99 | if duofern_parser is None: 100 | duofern_parser = Duofern(send_hook=self.add_serial_and_send, changes_callback=changes_callback) 101 | 102 | self.duofern_parser = duofern_parser 103 | self.running = False 104 | self.pairing = False 105 | self.unpairing = False 106 | 107 | self.updating_interval = 30 108 | 109 | self.system_code = None 110 | if system_code is not None: 111 | if not ephemeral and 'system_code' in self.config: 112 | assert self.config['system_code'].lower() == system_code.lower(), \ 113 | 'System code passed as argument "{}" differs from config file "{}", please manually change the ' \ 114 | 'config file {} if this is what you intended. If you change the code you paired your devices with' \ 115 | ' you might have to reset them and re-pair.'.format(system_code, 116 | self.config['system_code'], 117 | os.path.abspath(config_file_json)) 118 | 119 | self.system_code = system_code 120 | elif 'system_code' in self.config and not ephemeral: # pragma: no cover 121 | self.system_code = self.config['system_code'] 122 | else: 123 | raise DuofernException("No system code specified. Since the system code is a security feature no default" 124 | "can be provided. Please re-run wiht a valid system code {}".format(self.config)) 125 | assert len(self.system_code) == 4, "system code (serial) must be a string of 4 hexadecimal numbers" 126 | 127 | self.pairing = False 128 | self.unpairing = False 129 | self.write_queue = Queue() 130 | if not ephemeral: 131 | self.config['system_code'] = self.system_code 132 | self._dump_config() 133 | self.initialized = False 134 | 135 | if recording is None and 'recording' in self.config: 136 | recording = bool(self.config['recording']) 137 | 138 | self.recording = recording 139 | if recording: 140 | self._initialize_recording() 141 | 142 | def prepare_config(self, config_file_json): 143 | if config_file_json is None: # pragma: no cover 144 | config_file_json = os.path.expanduser("~/.duofern.json") 145 | if os.path.isfile(config_file_json): 146 | try: 147 | with open(config_file_json, "r") as config_file_fh: 148 | self.config = json.load(config_file_fh) 149 | except json.decoder.JSONDecodeError: # pragma: no cover 150 | self.config = {'devices': []} 151 | logger.info('failed reading config') 152 | else: 153 | logger.info('config is not file') 154 | self.config = {'devices': []} 155 | return config_file_json 156 | 157 | def _initialize_recording(self): 158 | if 'recording_dir' in self.config: 159 | dir = self.config['recording_dir'] 160 | assert not os.path.isfile(dir), 'must pass existing or creatable dir as `recording_dir` in config' 161 | if not os.path.isdir(dir): 162 | os.makedirs(dir) 163 | record_filename = os.path.join(dir, "duofern_record_{}".format(str(time.time()))) 164 | else: 165 | record_filename = tempfile.mktemp(prefix="duofern_record_") 166 | print("recording to {}".format(record_filename)) 167 | self.record_filename = record_filename 168 | 169 | def _initialize(self, **kwargs): # pragma: no cover 170 | raise NotImplementedError("need to use an implementation of the Duofernstick") 171 | 172 | def _simple_write(self, *args, **kwargs): # pragma: no cover 173 | raise NotImplementedError("need to use an implementation of the Duofernstick") 174 | 175 | def send(self, msg, **kwargs): # pragma: no cover 176 | raise NotImplementedError("need to use an implementation of the Duofernstick") 177 | 178 | def add_updates_callback(self, callback): 179 | self.duofern_parser.changes_callback = callback 180 | 181 | def _dump_config(self): 182 | with open(self.config_file, "w") as config_fh: 183 | json.dump(self.config, config_fh, indent=4) 184 | 185 | def process_message(self, message): 186 | logger.debug(message) 187 | if self.recording: 188 | with open(self.record_filename, "a") as recorder: 189 | recorder.write("received {}\n".format(message)) 190 | recorder.flush() 191 | if message[0:2] == '81': 192 | if hasattr(self, "unacknowledged"): 193 | if message[-14:-2] in self.unacknowledged: 194 | del self.unacknowledged[message[-14:-2]] 195 | return 196 | if message[0:4] == '0602': 197 | logger.info("got pairing reply") 198 | self.pairing = False 199 | self.duofern_parser.parse(message) 200 | self.sync_devices() 201 | return 202 | # if ($rmsg =~ m / 0602.{40} / ) { 203 | # my %addvals = (RAWMSG => $rmsg); 204 | # Dispatch($hash, $rmsg, \%addvals) if ($hash->{pair}); 205 | # delete($hash->{pair}); 206 | # RemoveInternalTimer($hash); 207 | # return undef; 208 | # 209 | elif message[0:4] == '0603': 210 | logger.info("got unpairing reply") 211 | self.unpairing = False 212 | self.duofern_parser.parse(message) 213 | self.sync_devices() 214 | return 215 | # } elsif ($rmsg =~ m/0603.{40}/) { 216 | # my %addvals = (RAWMSG => $rmsg); 217 | # Dispatch($hash, $rmsg, \%addvals) if ($hash->{unpair}); 218 | # delete($hash->{unpair}); 219 | # RemoveInternalTimer($hash); 220 | # return undef; 221 | # 222 | elif message[0:6] == '0FFF11': 223 | return 224 | 225 | elif message[0:8] == '81000000': 226 | return 227 | # } elsif ($rmsg =~ m/0FFF11.{38}/) { 228 | # return undef; 229 | # 230 | # } elsif ($rmsg =~ m/81000000.{36}/) { 231 | # return undef; 232 | # 233 | # } 234 | # 235 | # my %addvals = (RAWMSG => $rmsg); 236 | # Dispatch($hash, $rmsg, \%addvals); 237 | # logger.info("got {}".format(message)) 238 | self.duofern_parser.parse(message) 239 | 240 | def clean_config(self): 241 | self.config = dict(devices=[]) 242 | self._dump_config() 243 | 244 | def sync_devices(self): 245 | known_codes = [device['id'].lower() for device in self.config['devices']] 246 | logger.debug("known codes {}".format(known_codes)) 247 | for module_id in self.duofern_parser.modules['by_code']: 248 | if module_id.lower() not in known_codes: 249 | self.config['devices'].append({'id': module_id, 'name': module_id}) 250 | logger.info("paired new device {}".format(module_id)) 251 | self._dump_config() 252 | 253 | def set_name(self, id, name): 254 | logger.info("renaming device {} to {}".format(id, name)) 255 | self.config['devices'] = [device for device in self.config['devices'] if device['id'].lower() != id.lower()] 256 | self.config['devices'].append({'id': id, 'name': name}) 257 | self._dump_config() 258 | self._initialize() 259 | self.initialized=1 260 | 261 | 262 | def stop_pair(self): 263 | self.send(duoStopPair) 264 | self.pairing = False 265 | 266 | def stop_unpair(self): 267 | self.send(duoStopUnpair) 268 | self.unpairing = False 269 | 270 | def pair(self, timeout=10): 271 | self.send(duoStartPair) 272 | threading.Timer(timeout, self.stop_pair).start() 273 | self.pairing = True 274 | 275 | def status_request(self): 276 | self.send(duoStatusRequest.lower()) 277 | 278 | 279 | def unpair(self, timeout=10): 280 | self.send(duoStartUnpair) 281 | threading.Timer(10, self.stop_unpair).start() 282 | self.unpairing = True 283 | 284 | def remote(self, code, timeout=10): 285 | self.send(duoRemotePair.replace('yyyyyy', code)) 286 | threading.Timer(timeout, self.stop_pair).start() 287 | self.pairing = True 288 | 289 | def test_callback(self, arg): 290 | self.duofern_parser.parse(arg) 291 | 292 | 293 | def one_time_callback(protocol, _message, name, future): 294 | logger.info("{} answer for {}".format(_message, name)) 295 | if not future.cancelled(): 296 | future.set_result(_message) 297 | future.done() 298 | protocol.callback = None 299 | 300 | 301 | async def send_and_await_reply(protocol, message, message_identifier): 302 | future = asyncio.Future() 303 | protocol.callback = lambda message: one_time_callback(protocol, message, message_identifier, future) 304 | protocol.send(message) 305 | try: 306 | result = await future 307 | logger.info("got reply {}".format(result)) 308 | except asyncio.CancelledError: 309 | logger.info("future was cancelled waiting for reply") 310 | 311 | 312 | class DuofernStickAsync(DuofernStick, asyncio.Protocol): 313 | def __init__(self, loop=None, *args, **kwargs): 314 | super().__init__(*args, **kwargs) 315 | self.duofern_parser.asyncio = True 316 | self.initialization_step = 0 317 | self.loop = loop 318 | self.write_queue = asyncio.Queue() 319 | self._ready = asyncio.Event() 320 | self.transport = None 321 | self.buffer = bytearray(b'') 322 | 323 | self.last_packet = 0.0 324 | self.callback = None 325 | 326 | if loop == None: 327 | loop = asyncio.get_event_loop() 328 | 329 | self.send_loop = asyncio.ensure_future(self._send_messages(), loop=loop) 330 | 331 | self.available = asyncio.Future() 332 | 333 | # DuofernStick.__init__(self, device, system_code, config_file_json, duofern_parser) 334 | 335 | # self.serial_connection = serial.Serial(self.port, baudrate=115200, timeout=1) 336 | # self.running = False 337 | 338 | def command(self, *args, **kwargs): 339 | if self.recording: 340 | with open(self.record_filename, "a") as recorder: 341 | recorder.write("sending_command {} {}\n".format(args, kwargs)) 342 | recorder.flush() 343 | self.duofern_parser.set(*args, **kwargs) 344 | 345 | async def add_serial_and_send(self, msg): 346 | message = msg.replace("zzzzzz", "6f" + self.system_code) 347 | logger.info("sending {}".format(message)) 348 | self.send(message) 349 | logger.info("added {} to write queue".format(message)) 350 | 351 | def connection_made(self, transport): 352 | self.transport = transport 353 | logger.info('port opened {}') 354 | transport.serial.rts = False 355 | self.buffer = bytearray(b'') 356 | self.last_packet = time.time() 357 | self._ready.set() 358 | 359 | def data_received(self, data): 360 | if self.last_packet + 0.05 < time.time() and not hasattr(self.transport, 'unittesting'): 361 | self.buffer = bytearray(b'') 362 | self.last_packet = time.time() 363 | self.buffer += bytearray(data) 364 | while len(self.buffer) >= 22: 365 | if self.recording: 366 | with open(self.record_filename, "a") as recorder: 367 | recorder.write("received {}\n".format(hex(self.buffer[0:22]))) 368 | recorder.flush() 369 | if not hex(self.buffer[0:22]) == duoACK: 370 | self.send(duoACK) 371 | if hasattr(self, 'callback') and self.callback is not None: 372 | self.callback(hex(self.buffer[0:22])) 373 | elif self.initialized: 374 | self.process_message(hex(self.buffer[0:22])) 375 | self.buffer = self.buffer[22:] 376 | 377 | def pause_writing(self): # pragma: no cover 378 | logger.info('pause writing') 379 | logger.info(self.transport.get_write_buffer_size()) 380 | 381 | def resume_writing(self): # pragma: no cover 382 | logger.info(self.transport.get_write_buffer_size()) 383 | logger.info('resume writing') 384 | 385 | def parse(self, packet): 386 | logger.info(packet) 387 | 388 | def send(self, data, **kwargs): 389 | """ Feed a message to the sender coroutine. """ 390 | tosend = bytearray.fromhex(data) 391 | if self.recording: 392 | with open(self.record_filename, "a") as recorder: 393 | recorder.write("sent {}\n".format(data)) 394 | recorder.flush() 395 | self.write_queue.put_nowait(tosend) 396 | 397 | async def _send_messages(self): 398 | """ Send messages to the server as they become available. """ 399 | await self._ready.wait() 400 | logger.debug("Starting async send loop!") 401 | while True: 402 | try: 403 | logger.info("sending from stack") 404 | await asyncio.sleep(MIN_MESSAGE_INTERVAL_MILLIS/1000.) 405 | data = await self.write_queue.get() 406 | self.transport.write(data) 407 | except asyncio.CancelledError: 408 | logger.info("Got CancelledError, stopping send loop") 409 | break 410 | except Exception as exc: 411 | raise 412 | 413 | def parse_regular(self, packet): 414 | logger.info(packet) 415 | 416 | async def handshake(self): 417 | if not hasattr(self.transport, 'unittesting'): 418 | await asyncio.sleep(2) 419 | logger.info("now handshaking") 420 | await send_and_await_reply(self, duoInit1, "init 1") 421 | await send_and_await_reply(self, duoInit2, "init 2") 422 | await send_and_await_reply(self, duoSetDongle.replace("zzzzzz", "6f" + self.system_code), "SetDongle") 423 | self.send(duoACK) 424 | await send_and_await_reply(self, duoInit3, "init 3") 425 | self.send(duoACK) 426 | if 'devices' in self.config and self.config['devices']: 427 | counter = 0 428 | for device in self.config['devices']: 429 | # devices with id other than 6 characters 430 | # were previously sub-devices of another device with 6 characters 431 | # (i.e. devices representing a single channel) 432 | # but are no longer relevant 433 | if len(device['id']) != 6: 434 | continue 435 | hex_to_write = duoSetPairs.replace('nn', '{:02X}'.format(counter)).replace('yyyyyy', device['id']) 436 | await send_and_await_reply(self, hex_to_write, "SetPairs") 437 | self.send(duoACK) 438 | counter += 1 439 | self.duofern_parser.add_device(device['id'], device['name']) 440 | 441 | await send_and_await_reply(self, duoInitEnd, "duoInitEnd") 442 | self.send(duoACK) 443 | await send_and_await_reply(self, duoStatusRequest, "duoInitEnd") 444 | self.send(duoACK) 445 | self.available.set_result(True) 446 | self.initialized = True 447 | 448 | 449 | @dataclass 450 | class WaitingMessage: 451 | """Class for keeping track of an item in inventory.""" 452 | message: str 453 | next: datetime.datetime 454 | retries: int = 5 455 | 456 | class DuofernStickThreaded(DuofernStick, threading.Thread): 457 | def __init__(self, serial_port=None, *args, **kwargs): 458 | super().__init__(*args, **kwargs) 459 | 460 | if serial_port is None: 461 | try: 462 | self.port = serial.tools.list_ports.comports()[0].device 463 | except IndexError: 464 | raise DuofernException( 465 | "No serial port configured and unable to autodetect device. Did you plug in your stick?") 466 | logger.debug("no serial port set, autodetected {} for duofern".format(self.port)) 467 | else: 468 | self.port = serial_port 469 | 470 | # DuofernStick.__init__(self, device, system_code, config_file_json, duofern_parser) 471 | self.serial_connection = serial.Serial(self.port, baudrate=115200, timeout=1) 472 | self.running = False 473 | self.last_send = datetime.datetime.now() 474 | 475 | self.rewrite_queue = Queue() 476 | self.unacknowledged: Dict[str,WaitingMessage] = {} 477 | 478 | def _read_answer(self, some_string): # ReadAnswer 479 | """read an answer...""" 480 | logger.debug("should read {}".format(some_string)) 481 | self.serial_connection.timeout = 1 482 | response = bytearray(self.serial_connection.read(22)) 483 | if len(response) < 22: 484 | raise DuofernTimeoutException 485 | logger.debug("response {}".format(hex(response))) 486 | 487 | if self.recording: 488 | with open(self.record_filename, "a") as recorder: 489 | recorder.write("received {}\n".format(hex(response))) 490 | 491 | return hex(response) 492 | 493 | def _initialize(self): # DoInit 494 | for i in range(0, 4): 495 | self._simple_write(duoInit1) 496 | try: 497 | self._read_answer("INIT1") 498 | except DuofernTimeoutException: # pragma: no cover 499 | continue 500 | 501 | self._simple_write(duoInit2) 502 | try: 503 | self._read_answer("INIT2") 504 | except DuofernTimeoutException: # pragma: no cover 505 | continue 506 | 507 | buf = duoSetDongle.replace("zzzzzz", "6f" + self.system_code) 508 | self._simple_write(buf) 509 | try: 510 | self._read_answer("SetDongle") 511 | except DuofernTimeoutException: # pragma: no cover 512 | continue 513 | 514 | self._simple_write(duoACK) 515 | self._simple_write(duoInit3) 516 | try: 517 | self._read_answer("INIT3") 518 | 519 | except DuofernTimeoutException: # pragma: no cover 520 | continue 521 | self._simple_write(duoACK) 522 | 523 | if "devices" in self.config: 524 | counter = 0 525 | for device in self.config['devices']: 526 | # devices with id other than 6 characters 527 | # were previously sub-devices of another device with 6 characters 528 | # (i.e. devices representing a single channel) 529 | # but are no longer relevant 530 | if len(device['id']) != 6: 531 | continue 532 | 533 | hex_to_write = duoSetPairs.replace('nn', '{:02X}'.format(counter)).replace('yyyyyy', device['id']) 534 | self._simple_write(hex_to_write) 535 | try: 536 | self._read_answer("SetPairs") 537 | except DuofernTimeoutException: # pragma: no cover 538 | continue 539 | self._simple_write(duoACK) 540 | counter += 1 541 | self.duofern_parser.add_device(device['id'], device['name']) 542 | 543 | # my counter = 0 544 | # foreach (@pairs){ 545 | # buf = duoSetPairs 546 | # my chex .= sprintf "%02x", counter 547 | # buf =~ s/nn/chex/ 548 | # buf =~ s/yyyyyy/_/ 549 | # self._simple_write(buf) 550 | # (err, buf) = self._read_answer("SetPairs") 551 | # next if(err) 552 | # self._simple_write(duoACK) 553 | # counter++ 554 | # } 555 | 556 | self._simple_write(duoInitEnd) 557 | try: 558 | self._read_answer("INIT3") # look @ original? 559 | except DuofernTimeoutException: # pragma: no cover 560 | return False 561 | self._simple_write(duoACK) 562 | 563 | self._simple_write(duoStatusRequest) 564 | try: 565 | self._read_answer("statusRequest") 566 | except DuofernTimeoutException: # pragma: no cover 567 | continue 568 | self._simple_write(duoACK) 569 | 570 | # readingsSingleUpdate(hash, "state", "Initialized", 1) 571 | return True 572 | 573 | raise DuofernTimeoutException("Initialization failed ") 574 | 575 | # DUOFERNSTICK_SimpleWrite(@) 576 | @refresh_serial_connection 577 | def _simple_write(self, string_to_write): # SimpleWrite 578 | """Just write data""" 579 | self.last_send = datetime.datetime.now() 580 | logger.debug("writing {}".format(string_to_write)) 581 | hex_to_write = string_to_write.replace(" ", '') 582 | if self.recording: 583 | with open(self.record_filename, "a") as recorder: 584 | recorder.write("sent {}\n".format(hex_to_write)) 585 | 586 | data_to_write = bytearray.fromhex(hex_to_write) 587 | if not self.serial_connection.isOpen(): 588 | self.serial_connection.open() 589 | self.serial_connection.write(data_to_write) 590 | 591 | def handle_write_queue(self): 592 | try: 593 | tosend = self.write_queue.get(block=False, timeout=None) 594 | logger.debug("sending {} from write queue, {} msgs left in queue".format(tosend, self.write_queue.qsize())) 595 | self._simple_write(tosend) 596 | self.unacknowledged[tosend[-14:-2]] = WaitingMessage(tosend, datetime.datetime.now()+datetime.timedelta(seconds=random.uniform(*RESEND_SECONDS))) 597 | except Empty: 598 | pass 599 | 600 | def handle_rewrite_queue(self): 601 | try: 602 | tosend = self.rewrite_queue.get(block=False, timeout=None) 603 | logger.info("SENDING {} from REwrite queue, {} msgs left in queue".format(tosend, self.rewrite_queue.qsize())) 604 | self._simple_write(tosend) 605 | except Empty: 606 | pass 607 | 608 | def command(self, *args, **kwargs): 609 | if self.recording: 610 | with open(self.record_filename, "a") as recorder: 611 | recorder.write("sending_command {} {}\n".format(args,kwargs)) 612 | 613 | self.duofern_parser.set(*args, **kwargs) 614 | 615 | def add_serial_and_send(self, msg): 616 | message = msg.replace("zzzzzz", "6f" + self.system_code) 617 | logger.debug("sending {}".format(message)) 618 | self.send(message) 619 | 620 | def run(self): 621 | self.running = True 622 | self._initialize() 623 | last_resend_check = datetime.datetime.now() 624 | last_periodic_update = datetime.datetime.now() 625 | toggle = False 626 | while self.running: 627 | toggle = not toggle 628 | 629 | self.serial_connection.timeout = .05 630 | if not self.serial_connection.isOpen(): 631 | self.serial_connection.open() 632 | try: 633 | in_data = hex(self.serial_connection.read(22)) 634 | except TypeError: 635 | continue 636 | if len(in_data) == 44: 637 | try: 638 | self.process_message(in_data) 639 | except Exception as exc: 640 | logger.exception(exc) 641 | if in_data != duoACK: 642 | self._simple_write(duoACK) 643 | self.serial_connection.timeout = 1 644 | if not self.write_queue.empty() or not self.rewrite_queue.empty() and ( 645 | (datetime.datetime.now() - self.last_send) >= datetime.timedelta(milliseconds=MIN_MESSAGE_INTERVAL_MILLIS)): 646 | if toggle and self.rewrite_queue: 647 | self.handle_rewrite_queue() 648 | else: 649 | self.handle_write_queue() 650 | 651 | if self.updating_interval and datetime.datetime.now() - last_periodic_update > datetime.timedelta(seconds=self.updating_interval): 652 | last_periodic_update = datetime.datetime.now() 653 | self.status_request() 654 | 655 | if datetime.datetime.now() - last_resend_check > datetime.timedelta(seconds=0.1): 656 | self.handle_resends() 657 | last_resend_check = datetime.datetime.now() 658 | 659 | def handle_resends(self): 660 | done = set() 661 | t = datetime.datetime.now() 662 | for k in self.unacknowledged.keys(): 663 | if self.unacknowledged[k].retries == 0: 664 | done.add(k) 665 | logger.info(f"{self.unacknowledged[k]} was never acknowledged, gave up after 5 retries") 666 | elif self.unacknowledged[k].next < t: 667 | self.unacknowledged[k].next = t + datetime.timedelta(seconds=random.uniform(*RESEND_SECONDS)) 668 | self.unacknowledged[k].retries -= 1 669 | self.rewrite_queue.put(self.unacknowledged[k].message) 670 | for d in done: 671 | del self.unacknowledged[d] 672 | 673 | 674 | def stop(self): 675 | self.running = False 676 | self.serial_connection.close() 677 | 678 | def pair(self, timeout=10): 679 | super(DuofernStickThreaded, self).pair(timeout) 680 | threading.Timer(timeout, self.stop_pair).start() 681 | 682 | def unpair(self, timeout=10): 683 | super(DuofernStickThreaded, self).unpair(timeout) 684 | threading.Timer(timeout, self.stop_unpair).start() 685 | 686 | def send(self, msg, **kwargs): 687 | logger.debug("sending {}".format(msg)) 688 | self.write_queue.put_nowait(msg) 689 | logger.debug("added {} to write queue".format(msg)) 690 | return 691 | -------------------------------------------------------------------------------- /pyduofern/exceptions.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | # python interface for dufoern usb stick 3 | # Copyright (C) 2017 Paul Görgen 4 | 5 | # This program is free software; you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation; either version 2 of the License, or 8 | # (at your option) any later version. 9 | 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program; if not, write to the Free Software Foundation, 17 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 18 | 19 | class DuofernException(Exception): 20 | pass 21 | 22 | 23 | class DuofernTimeoutException(DuofernException): 24 | pass 25 | -------------------------------------------------------------------------------- /pyserial_asyncio_experiments.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import signal 4 | import time 5 | 6 | import serial_asyncio 7 | 8 | from pyduofern.duofern_stick import duoInit1, duoInit2, duoInit3, duoACK, duoSetDongle 9 | 10 | logger = logging.getLogger(__name__) 11 | logging.basicConfig(format='pyduofern/%(module)s(%(lineno)d): %(levelname)s %(message)s', level=logging.DEBUG) 12 | 13 | 14 | # Idee: sleep then flush buffer for resync 15 | 16 | # asyncio with sending stuff 17 | # https://stackoverflow.com/questions/30937042/asyncio-persisent-client-protocol-class-using-queue 18 | 19 | class Output(asyncio.Protocol): 20 | def __init__(self, loop): 21 | self.loop = loop 22 | self.send_queue = asyncio.Queue() 23 | self._ready = asyncio.Event() 24 | self.transport = None 25 | self.buffer = None 26 | self.last_packet = 0.0 27 | self.callback = None 28 | self.send_loop = asyncio.ensure_future(self._send_messages()) 29 | 30 | def connection_made(self, transport): 31 | self.transport = transport 32 | logger.info('port opened {}') 33 | transport.serial.rts = False 34 | self.buffer = bytearray(b'') 35 | self.last_packet = time.time() 36 | self._ready.set() 37 | 38 | def data_received(self, data): 39 | if self.last_packet + 0.05 < time.time(): 40 | self.buffer = bytearray(b'') 41 | self.last_packet = time.time() 42 | self.buffer += bytearray(data) 43 | while len(self.buffer) >= 20: 44 | if hasattr(self, 'callback') and self.callback is not None: 45 | self.callback(self.buffer[0:20]) 46 | else: 47 | self.parse(self.buffer[0:20]) 48 | self.buffer = self.buffer[20:] 49 | 50 | def pause_writing(self): 51 | logger.info('pause writing') 52 | logger.info(self.transport.get_write_buffer_size()) 53 | 54 | def resume_writing(self): 55 | logger.info(self.transport.get_write_buffer_size()) 56 | logger.info('resume writing') 57 | 58 | def parse(self, packet): 59 | logger.info(packet) 60 | 61 | @asyncio.coroutine 62 | def send_message(self, data): 63 | """ Feed a message to the sender coroutine. """ 64 | await self.send_queue.put(data) 65 | 66 | @asyncio.coroutine 67 | def _send_messages(self): 68 | """ Send messages to the server as they become available. """ 69 | await self._ready.wait() 70 | logger.debug("Starting async send loop!") 71 | while True: 72 | try: 73 | data = await self.send_queue.get() 74 | self.transport.write(data) 75 | except asyncio.CancelledError: 76 | logger.info("Got CancelledError, stopping send loop") 77 | break 78 | logger.debug("sending {}".format(data)) 79 | 80 | def connection_lost(self, exc): 81 | print('The server closed the connection') 82 | print('Stop the event loop') 83 | 84 | self.loop.stop() 85 | 86 | 87 | loop = asyncio.get_event_loop() 88 | coro = serial_asyncio.create_serial_connection(loop, lambda: Output(loop), '/dev/ttyUSB0', baudrate=115200) 89 | 90 | running = True 91 | 92 | 93 | def one_time_callback(protocol, _message, name, future): 94 | logger.info("{} answer for {}".format(_message, name)) 95 | if not future.cancelled(): 96 | future.set_result(_message) 97 | protocol.callback = None 98 | 99 | @asyncio.coroutine 100 | def send_and_await_reply(protocol, message, message_identifier): 101 | future = asyncio.Future() 102 | protocol.callback = lambda message: one_time_callback(protocol, message, message_identifier, future) 103 | await protocol.send_message(message.encode("utf-8")) 104 | try: 105 | result = await future 106 | logger.info("got reply {}".format(result)) 107 | except asyncio.CancelledError: 108 | logger.info("future was cancelled waiting for reply") 109 | 110 | 111 | @asyncio.coroutine 112 | def handshake(protocol): 113 | await asyncio.sleep(2) 114 | HANDSHAKE = [(duoInit1, "INIT1"), 115 | (duoInit2, "INIT2"), 116 | (duoSetDongle.replace("zzzzzz", "6f" + "affe"), "SetDongle"), 117 | (duoACK), 118 | (duoInit3, "INIT3")] 119 | await send_and_await_reply(protocol, duoInit1, "init 1") 120 | await send_and_await_reply(protocol, duoInit2, "init 2") 121 | await send_and_await_reply(protocol, duoSetDongle.replace("zzzzzz", "6f" + "affe"), "SetDongle") 122 | await protocol.send_message(duoACK.encode("utf-8")) 123 | await send_and_await_reply(protocol, duoInit3, "init 3") 124 | await protocol.send_message(duoACK.encode("utf-8")) 125 | logger.info(self.config) 126 | if "devices" in self.config: 127 | counter = 0 128 | for device in self.config['devices']: 129 | hex_to_write = duoSetPairs.replace('nn', '{:02X}'.format(counter)).replace('yyyyyy', device['id']) 130 | await send_and_await_reply(protocol, hex_to_write, "SetPairs") 131 | await protocol.send_message(duoACK.encode("utf-8")) 132 | counter += 1 133 | self.duofern_parser.add_device(device['id'], device['name']) 134 | 135 | await send_and_await_reply(protocol, duoInitEnd, "duoInitEnd") 136 | await protocol.send_message(duoACK.encode("utf-8")) 137 | await send_and_await_reply(protocol, duoStatusRequest, "duoInitEnd") 138 | await protocol.send_message(duoACK.encode("utf-8")) 139 | 140 | 141 | f, proto = loop.run_until_complete(coro) 142 | print("fuckin f: {}".format(f)) 143 | print("fuckin proto: {}".format(proto)) 144 | 145 | 146 | def cancelall(): 147 | print('Stopping') 148 | f.close() 149 | for task in asyncio.Task.all_tasks(): 150 | task.cancel() 151 | 152 | 153 | try: 154 | initialization = asyncio.ensure_future(handshake(proto)) 155 | init = asyncio.wait(initialization) 156 | logger.info("loop forever") 157 | loop.add_signal_handler(signal.SIGINT, cancelall) 158 | 159 | loop.run_forever() 160 | except KeyboardInterrupt: 161 | print('Closing connection') 162 | initialization.cancel() 163 | finally: 164 | loop.close() 165 | -------------------------------------------------------------------------------- /scripts/duofern_cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # coding=utf-8 3 | # python interface for dufoern usb stick 4 | # Copyright (C) 2017 Paul Görgen 5 | # 6 | # This program is free software; you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation; in version 2 of the license 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program; if not, write to the Free Software Foundation, 17 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 18 | 19 | 20 | import argparse 21 | import logging 22 | import re 23 | import time 24 | from cmd import Cmd 25 | 26 | from pyduofern.duofern_stick import DuofernStickThreaded 27 | 28 | parser = argparse.ArgumentParser(epilog="use at your own risk") 29 | 30 | parser.add_argument('--configfile', help='location of system config file', default=None, metavar="CONFIGFILE") 31 | 32 | parser.add_argument('--code', help='set 4-digit hex code for the system (warning, always use same code for once-paired ' 33 | 'devices) best chose something on the first run and write it down.', 34 | default=None, metavar="SYSTEMCODE") 35 | 36 | parser.add_argument('--device', 37 | help='path to serial port created by duofern stick, defaults to first found serial port, typically ' 38 | 'something like /dev/ttyUSB0 or /dev/duofernstick if you use the provided udev rules file', 39 | default=None) 40 | 41 | parser.add_argument('--pair', action='store_true', 42 | help='Stick will wait for pairing for {} seconds. Afterwards look in the config file for the paired ' 43 | 'device name or call this program again to list the newly found device. If devices that were ' 44 | 'paired with this SYSTEMCODE are found while waiting for pairing, these are also added to' 45 | 'CONFIGFILE', 46 | default=False) 47 | 48 | parser.add_argument('--unpair', action='store_true', 49 | help='Stick will wait for pairing for {} seconds. Afterwards look in the config file for the paired ' 50 | 'device name or call this program again to list the newly found device. If devices that were ' 51 | 'paired with this SYSTEMCODE are found while waiting for pairing, these are also added to' 52 | 'CONFIGFILE', 53 | default=False) 54 | 55 | parser.add_argument('--remote', 56 | help='Pair by code. Added devices are also added to CONFIGFILE', 57 | metavar='DEVICE_ID', default=None) 58 | 59 | parser.add_argument('--pairtime', help='time to wait for pairing requests', metavar="SECONDS", default=60, type=int) 60 | 61 | parser.add_argument('--refresh', action='store_true', 62 | help='just sit and listen for devices that were already paired with the current system code but ' 63 | 'were lost from the config file', default=False) 64 | parser.add_argument('--refreshtime', help='time to spend refreshing', 65 | metavar="SECONDS", default=60, type=int) 66 | 67 | parser.add_argument('--set_name', help='Set name for a device.', nargs=2, default=None, 68 | metavar=("DEVICE_ID", "DEVICE_NAME")) 69 | 70 | parser.add_argument('--debug', help='enable verbose logging', action='store_true', default=False) 71 | 72 | parser.add_argument('--up', help='pull up the selected rollershutter / blinds', metavar="NAME", nargs='+', default=None) 73 | parser.add_argument('--down', help='roll down the selected rollershutter / blinds', metavar="NAME", nargs='+', 74 | default=None) 75 | parser.add_argument('--stop', help='stop the selected rollershutter / blinds', metavar="NAME", nargs='+', default=None) 76 | 77 | parser.add_argument('--on', help='switch on (for "Steckdosenaktor")', metavar="NAME", nargs='+', default=None) 78 | parser.add_argument('--off', help='switch off (for "Steckdosenaktor")', metavar="NAME", nargs='+', 79 | default=None) 80 | parser.add_argument('--stairwell_on', help='switch on stairwell (for "Steckdosenaktor")', metavar="NAME", nargs='+', 81 | default=None) 82 | parser.add_argument('--stairwell_off', help='switch on stairwell (for "Steckdosenaktor")', metavar="NAME", nargs='+', 83 | default=None) 84 | parser.add_argument('--position', help='move shutter NAME to position POSITION', metavar=('POSITION', 'NAME'), nargs=2, 85 | default=None, type=str) 86 | 87 | 88 | def splitargs(func): 89 | def wrapper(*args, **kwargs): 90 | func(args[0], args[1].split(" ")) 91 | 92 | return wrapper 93 | 94 | 95 | def ids_for_names(func): 96 | def wrapper(*args, **kwargs): 97 | id_dict = {device['id']: device['name'] for device in args[0].stick.config['devices'] 98 | if device['name'] in args[1]} 99 | func(args[0], id_dict) 100 | 101 | return wrapper 102 | 103 | 104 | class DuofernCLI(Cmd): 105 | def __init__(self, serial_port=None, system_code=None, config_file=None, *args, **kwargs): 106 | super().__init__(*args, **kwargs) 107 | self.stick = DuofernStickThreaded(serial_port=args.device, system_code=args.code, 108 | config_file_json=args.configfile) 109 | self.stick._initialize() 110 | self.stick.start() 111 | self.prompt = "duofern> " 112 | 113 | def emptyline(self): 114 | pass 115 | 116 | def do_pair(self, args): 117 | """ 118 | Usage: 119 | pair 120 | 121 | Start pairing mode. Pass a timeout in seconds as . 122 | Will return after the timeout if no devices start pairing within the given timeout. 123 | 124 | Example: 125 | duofern> pair 10 126 | 127 | """ 128 | timeout = 10 129 | if len(args) != 0: 130 | try: 131 | timeout = int(args[0]) 132 | except: 133 | print("Please use an integer number to indicate TIMEOUT in seconds") 134 | print("Starting pairing mode... waiting {} seconds".format(int(timeout))) 135 | self.stick.pair(timeout=timeout) 136 | time.sleep(args.pairtime + 0.5) 137 | self.stick.sync_devices() 138 | print("Pairing done, Config file updated.") 139 | 140 | def do_unpair(self, args): 141 | """ 142 | Usage: 143 | unpair 144 | 145 | Start pairing mode. Pass a timeout in seconds as . 146 | Will return after the timeout if no devices start pairing within the given timeout. 147 | 148 | Example: 149 | duofern> unpair 10 150 | 151 | """ 152 | timeout = 10 153 | if len(args) != 0: 154 | try: 155 | timeout = int(args[0]) 156 | except: 157 | print("Please use an integer number to indicate TIMEOUT in seconds") 158 | print("Starting pairing mode... waiting {} seconds".format(int(timeout))) 159 | self.stick.unpair(timeout=timeout) 160 | time.sleep(args.pairtime + 0.5) 161 | self.stick.sync_devices() 162 | print("Pairing done, Config file updated.") 163 | 164 | def do_remote(self, args): 165 | code = args[0][0:6] 166 | timeout = int(args[1]) 167 | self.stick.remote(code, timeout) 168 | time.sleep(args.pairtime + 0.5) 169 | self.stick.sync_devices() 170 | print("Pairing done, Config file updated.") 171 | 172 | @splitargs 173 | @ids_for_names 174 | def do_up(self, blinds): 175 | """ 176 | Usage: 177 | up [ ] 178 | 179 | Lift one or several shutters. Accepts a list of shutter names sepatated by space. 180 | 181 | Example: 182 | duofern> up Livingroom 183 | duofern> up Livingroom Kitchen 184 | """ 185 | for blind_id in blinds: 186 | print("lifting {}".format(blinds[blind_id])) 187 | self.stick.command(blind_id, "up") 188 | 189 | @splitargs 190 | @ids_for_names 191 | def do_down(self, blinds): 192 | """ 193 | Usage: 194 | up [ ...] 195 | 196 | Lower one or several shutters. Accepts a list of shutter names sepatated by space. 197 | 198 | Example: 199 | duofern> up Livingroom 200 | duofern> up Livingroom Kitchen 201 | """ 202 | for blind_id in blinds: 203 | print("lowering {}".format(blinds[blind_id])) 204 | self.stick.command(blind_id, "down") 205 | 206 | @splitargs 207 | def do_rename(self, args): 208 | """ 209 | Usage: 210 | rename 211 | 212 | Rename an actor. Write changes to config file when done. 213 | 214 | Example: 215 | duofern> rename 13f897 kitchen_west 216 | """ 217 | id = [device['id'] for device in self.stick.config['devices'] if device['name'] == args[0]] 218 | if len(id) == 0: 219 | print("Please enter a valid device name for renaming.") 220 | self.stick.set_name(id[0], args[1]) 221 | print("Set name for {} to {}".format(id[0], args[0])) 222 | 223 | def refresh(self, args): 224 | """ 225 | Usage: 226 | refresh 227 | 228 | Refresh config file with current changes. 229 | 230 | example: 231 | duofern> refresh 232 | """ 233 | self.stick.sync_devices() 234 | 235 | @splitargs 236 | @ids_for_names 237 | def do_on(self, blinds): 238 | """ 239 | Usage: 240 | off [ ] 241 | 242 | Switch on one or several switch actors. Accepts a list of actor names. 243 | 244 | Example: 245 | duofern> off Livingroom 246 | duofern> off Livingroom Kitchen 247 | """ 248 | for blind_id in blinds: 249 | print("lifting {}".format(blinds[blind_id])) 250 | self.stick.command(blind_id, "up") 251 | 252 | @splitargs 253 | @ids_for_names 254 | def do_off(self, blinds): 255 | """ 256 | Usage: 257 | off [ ] 258 | 259 | Switch off one or several switch actors. Accepts a list of actor names. 260 | 261 | Example: 262 | duofern> off Livingroom 263 | duofern> off Livingroom Kitchen 264 | """ 265 | for blind_id in blinds: 266 | print("lifting {}".format(blinds[blind_id])) 267 | self.stick.command(blind_id, "up") 268 | 269 | 270 | if __name__ == "__main__": 271 | args = parser.parse_args() 272 | 273 | # print(args.up) 274 | if args.debug: 275 | logging.basicConfig(format=' %(asctime)s: %(message)s', level=logging.DEBUG) 276 | else: 277 | logging.basicConfig(format=' %(asctime)s: %(message)s', level=logging.INFO) 278 | 279 | if args.code is not None: 280 | assert len(args.code) == 4, "System code must be a 4 digit hex code" 281 | try: 282 | bytearray.fromhex(args.code) 283 | except: 284 | print("System code must be a 4 digit hex code") 285 | exit(1) 286 | 287 | 288 | def logit(*args, **kwargs): 289 | print(args) 290 | 291 | 292 | stick = DuofernStickThreaded(serial_port=args.device, system_code=args.code, config_file_json=args.configfile, 293 | changes_callback=logit) 294 | stick.duofern_parser.changes_callback("changes callback", it="works") 295 | 296 | if args.set_name is not None: 297 | assert len(args.set_name[0]) == 6 and re.match("^[0-9a-f]+$", args.set_name[0], re.IGNORECASE), \ 298 | "id for renaming must be a valid 6 digit hex ID not {}".format(args.set_name[0]) 299 | stick.set_name(args.set_name[0], args.set_name[1]) 300 | stick.sync_devices() 301 | 302 | print("\nThe following devices are configured:\n") 303 | print("\n".join( 304 | ["id: {:6} name: {}".format(device['id'], device['name']) for device in stick.config['devices']])) 305 | print("\n") 306 | 307 | if args.pair: 308 | print("entering pairing mode") 309 | stick._initialize() 310 | stick.start() 311 | stick.pair(timeout=args.pairtime) 312 | time.sleep(args.pairtime + 0.5) 313 | stick.sync_devices() 314 | print("""""") 315 | 316 | if args.unpair: 317 | print("entering pairing mode") 318 | stick._initialize() 319 | stick.start() 320 | stick.unpair(timeout=args.pairtime) 321 | time.sleep(args.pairtime + 0.5) 322 | stick.sync_devices() 323 | print("""""") 324 | 325 | if args.remote: 326 | print("entering remote pairing mode") 327 | stick._initialize() 328 | stick.start() 329 | stick.pair(timeout=args.pairtime) 330 | stick.remote(code=args.remote, timeout=args.pairtime) 331 | time.sleep(args.pairtime + 0.5) 332 | stick.sync_devices() 333 | print("""""") 334 | 335 | if args.refresh: 336 | stick._initialize() 337 | stick.start() 338 | time.sleep(args.refreshtime + 0.5) 339 | stick.sync_devices() 340 | 341 | if args.up: 342 | stick._initialize() 343 | stick.start() 344 | time.sleep(1) 345 | ids = [device['id'] for device in stick.config['devices'] if device['name'] in args.up] 346 | for blind_id in ids: 347 | stick.command(blind_id, "up") 348 | time.sleep(0.5) 349 | stick.command(blind_id, "up") 350 | time.sleep(0.5) 351 | stick.command(blind_id, "up") 352 | time.sleep(2) 353 | 354 | if args.down: 355 | stick._initialize() 356 | stick.start() 357 | time.sleep(1) 358 | ids = [device['id'] for device in stick.config['devices'] if device['name'] in args.down] 359 | for blind_id in ids: 360 | stick.command(blind_id, "down") 361 | time.sleep(0.5) 362 | stick.command(blind_id, "down") 363 | time.sleep(0.5) 364 | stick.command(blind_id, "down") 365 | time.sleep(2) 366 | 367 | if args.stop: 368 | stick._initialize() 369 | stick.start() 370 | time.sleep(1) 371 | ids = [device['id'] for device in stick.config['devices'] if device['name'] in args.stop] 372 | for blind_id in ids: 373 | stick.command(blind_id, "stop") 374 | time.sleep(0.5) 375 | stick.command(blind_id, "stop") 376 | time.sleep(0.5) 377 | stick.command(blind_id, "stop") 378 | time.sleep(2) 379 | 380 | if args.on: 381 | stick._initialize() 382 | stick.start() 383 | time.sleep(1) 384 | ids = [device['id'] for device in stick.config['devices'] if device['name'] in args.on] 385 | for blind_id in ids: 386 | stick.command(blind_id, "on") 387 | time.sleep(2) 388 | 389 | if args.off: 390 | stick._initialize() 391 | stick.start() 392 | time.sleep(1) 393 | ids = [device['id'] for device in stick.config['devices'] if device['name'] in args.off] 394 | for blind_id in ids: 395 | stick.command(blind_id, "off") 396 | time.sleep(2) 397 | 398 | if args.stairwell_on: 399 | stick._initialize() 400 | stick.start() 401 | time.sleep(1) 402 | ids = [device['id'] for device in stick.config['devices'] if device['name'] in args.stairwell_on] 403 | for blind_id in ids: 404 | stick.command(blind_id, "stairwellTime", 10) 405 | time.sleep(0.5) 406 | stick.command(blind_id, "stairwellFunction", "on") 407 | time.sleep(0.5) 408 | stick.command(blind_id, "on") 409 | time.sleep(2) 410 | 411 | if args.stairwell_off: 412 | stick._initialize() 413 | stick.start() 414 | time.sleep(1) 415 | ids = [device['id'] for device in stick.config['devices'] if device['name'] in args.stairwell_off] 416 | for blind_id in ids: 417 | stick.command(blind_id, "stairwellFunction", "off") 418 | time.sleep(0.5) 419 | stick.command(blind_id, "on") 420 | time.sleep(2) 421 | 422 | if args.position is not None: 423 | stick._initialize() 424 | stick.start() 425 | time.sleep(1) 426 | ids = [device['id'] for device in stick.config['devices'] if device['name'] in args.position[1:]] 427 | for blind_id in ids: 428 | stick.command(blind_id, "position", 100 - int(args.position[0])) 429 | time.sleep(0.5) 430 | stick.command(blind_id, "position", 100 - int(args.position[0])) 431 | time.sleep(0.5) 432 | stick.command(blind_id, "position", 100 - int(args.position[0])) 433 | time.sleep(2) 434 | 435 | stick.stop() 436 | time.sleep(1) 437 | try: 438 | stick.join() 439 | except RuntimeError: 440 | pass 441 | -------------------------------------------------------------------------------- /scripts/duofern_mqtt.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import configargparse 3 | import logging 4 | import json 5 | import sys 6 | import aiohttp.client_exceptions 7 | 8 | from asyncio_mqtt import Client 9 | import asyncio_mqtt.error 10 | 11 | from distutils.util import strtobool 12 | 13 | from pyess.aio_ess import ESS 14 | from pyess.ess import autodetect_ess 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | # The callback for when the client receives a CONNACK response from the server. 20 | def on_connect(client, userdata, flags, rc): 21 | print("Connected with result code " + str(rc)) 22 | 23 | # Subscribing in on_connect() means that if we lose the connection and 24 | # reconnect then subscriptions will be renewed. 25 | client.subscribe("$SYS/#") 26 | 27 | 28 | # The callback for when a PUBLISH message is received from the server. 29 | def on_message(client, userdata, msg): 30 | print(msg.topic + " " + str(msg.payload)) 31 | 32 | 33 | async def recursive_publish_dict(mqtt_client, publish_root, dictionary): 34 | for key, value in dictionary.items(): 35 | if isinstance(value, dict): 36 | await recursive_publish_dict(mqtt_client, f"{publish_root}/{key}", value) 37 | else: 38 | try: 39 | await mqtt_client.publish(f"{publish_root}/{key}", value) 40 | except (asyncio_mqtt.error.MqttCodeError, TimeoutError): # pragma: no cover 41 | logger.warning("Lost MQTT connection to mqtt errorcode or timeout, restarting", exc_info=True) 42 | raise 43 | except: # pragma: no cover 44 | logger.exception(f"Lost MQTT connection to unexpected error code, restarting", exc_info=True) 45 | raise 46 | 47 | 48 | async def send_loop(ess, mqtt_client=None, graphite_client=None, once=False, interval_seconds=10, common_divisor=1): 49 | logger.info("starting send loop") 50 | i = 0 51 | while True: 52 | if not once: 53 | await asyncio.sleep(1) 54 | home = await ess.get_state("home") 55 | await recursive_publish_dict(mqtt_client, "ess/home", home) 56 | 57 | if i % common_divisor == 0: 58 | common = await ess.get_state("common") 59 | await recursive_publish_dict(mqtt_client, "ess/common", common) 60 | 61 | i += 1 62 | if once: 63 | break 64 | await asyncio.sleep(interval_seconds - 1) 65 | 66 | 67 | def prepare_description(sensor): 68 | description = {"name": sensor, "state_topic": sensor} 69 | if "power" in sensor: 70 | description["device_class"] = "power" 71 | description["unit_of_measurement"] = "W" 72 | if "enegy" in sensor or "energy" in sensor: # typo in ess json 73 | description["unit_of_measuremnt"] = "Wh" 74 | description["icon"] = "mdi:gauge" 75 | if "soc" in sensor: 76 | description["device_class"] = "battery" 77 | if "current" in sensor: 78 | description["unit_of_measurement"] = "A" 79 | description["icon"] = "mdi:gauge" 80 | if "voltage" in sensor: 81 | description["unit_of_measurement"] = "V" 82 | description["icon"] = "mdi:gauge" 83 | return description 84 | 85 | 86 | async def announce_loop(client, sensors=None): 87 | if sensors is None: 88 | return 89 | 90 | for sensor in sensors: 91 | try: 92 | await client.publish(f"homeassistant/sensor/{sensor.replace('/', '')}/config", 93 | json.dumps(prepare_description(sensor)), retain=True, qos=2) 94 | except (asyncio_mqtt.error.MqttCodeError, TimeoutError): # pragma: no cover 95 | logger.warning("Lost MQTT connection to mqtt errorcode in announce loop, send loop will exit") 96 | except: # pragma: no cover 97 | logger.exception(f"Lost MQTT connection to unexpected error code, restarting", exc_info=True) 98 | raise 99 | 100 | 101 | def main(arguments=None): 102 | loop = asyncio.get_event_loop() 103 | asyncio.run(_main(arguments)) 104 | # .run(_main, arguments) 105 | 106 | 107 | async def _main( arguments=None): 108 | loop = asyncio.get_event_loop() 109 | parser = configargparse.ArgumentParser(prog='essmqtt', description='Mqtt connector for pyess', 110 | add_config_file_help=True, 111 | default_config_files=['/etc/essmqtt.conf', '~/.essmqtt.conf'], 112 | args_for_setting_config_path=["--config_file"], ) 113 | 114 | parser.add_argument( 115 | '--loglevel', default='info', help='Log level', 116 | choices=['debug', 'info', 'warning', 'error', 'critical'], 117 | ) 118 | 119 | parser.add_argument("--ess_password", default=None, help="password (required for everything but get_password)") 120 | parser.add_argument("--mqtt_server", default="192.168.1.220", help="mqtt server") 121 | parser.add_argument("--mqtt_port", default=1883, type=int, help="mqtt port") 122 | parser.add_argument("--mqtt_password", default=None, help="mqtt password") 123 | parser.add_argument("--mqtt_user", default=None, help="mqtt user") 124 | parser.add_argument("--ess_host", default=None, help="hostname or IP of mqtt host (discover via mdns if not set)") 125 | parser.add_argument("--once", default=False, type=bool, help="no loop, only one pass") 126 | parser.add_argument("--common_divisor", default=1, type=int, 127 | help="multiply interval_seconds for values below 'common' by this factor") 128 | parser.add_argument("--interval_seconds", default=10, type=int, help="update interval (default: 10 seconds)") 129 | parser.add_argument("--hass_autoconfig_sensors", default=None, help="comma-separated list of sensors to advertise" 130 | "for homassistant autconfig") 131 | 132 | args = parser.parse_args(arguments) 133 | if args.ess_host is None: 134 | ip, name = await loop.run_in_executor(None, autodetect_ess) 135 | else: 136 | ip, name = args.ess_host, args.ess_host 137 | 138 | logging.basicConfig(level=args.loglevel.upper()) 139 | 140 | ess = await ESS.create(name, args.ess_password, ip) 141 | 142 | async def switch_winter(state: bool): 143 | logger.info(f"switching winter mode to {state}") 144 | if state: 145 | await ess.winter_off() 146 | else: 147 | await ess.winter_on() 148 | 149 | async def switch_fastcharge(state: bool): 150 | logger.info(f"switching fast charge mode to {state}") 151 | if state: 152 | await ess.fastcharge_on() 153 | else: 154 | await ess.fastcharge_off() 155 | 156 | async def switch_active(state: bool): 157 | logger.info("switching ess {}".format("on" if state else "off")) 158 | if state: 159 | await ess.switch_on() 160 | else: 161 | await ess.switch_off() 162 | 163 | async def handle_control(client, control, path): 164 | async with client.filtered_messages(path) as messages: 165 | async for msg in messages: 166 | logger.info(f"control message received {msg}") 167 | try: 168 | state = strtobool(msg.payload.decode()) 169 | await control(state) 170 | except ValueError: 171 | logger.warning(f"ignoring incompatible value {msg} for switching") 172 | 173 | if args.mqtt_server is not None: 174 | while True: 175 | try: 176 | await launch_main_loop(args, ess, handle_control, switch_active, switch_fastcharge, switch_winter) 177 | except (TimeoutError, asyncio_mqtt.error.MqttCodeError, aiohttp.client_exceptions.ClientError, 178 | BrokenPipeError, ConnectionError): 179 | logger.warning("Expected exception while talking go ESS or MQTT server, restarting in 60 seconds." 180 | "Usually this means the server is slow to respond or unreachable.") 181 | await asyncio.sleep(60) 182 | except: 183 | raise 184 | 185 | ess = await ESS.create(name, args.ess_password, ip) 186 | 187 | if args.once: 188 | break 189 | 190 | else: 191 | pass 192 | 193 | 194 | async def launch_main_loop(args, ess, handle_control, switch_active, switch_fastcharge, switch_winter): 195 | async with Client(args.mqtt_server, port=args.mqtt_port, logger=logger, username=args.mqtt_user, 196 | password=args.mqtt_password) as client: 197 | # seems that a leading slash is frowned upon in mqtt, but we keep this for backwards compatibility 198 | await client.subscribe('/ess/control/#') 199 | asyncio.create_task(handle_control(client, switch_winter, "/ess/control/winter_mode")) 200 | asyncio.create_task(handle_control(client, switch_fastcharge, "/ess/control/fastcharge")) 201 | asyncio.create_task(handle_control(client, switch_active, "/ess/control/active")) 202 | 203 | # also subscribe without leading slash for better style 204 | await client.subscribe('ess/control/#') 205 | asyncio.create_task(handle_control(client, switch_winter, "ess/control/winter_mode")) 206 | asyncio.create_task(handle_control(client, switch_fastcharge, "ess/control/fastcharge")) 207 | asyncio.create_task(handle_control(client, switch_active, "ess/control/active")) 208 | if args.hass_autoconfig_sensors is not None: 209 | asyncio.ensure_future( 210 | announce_loop(client, sensors=args.hass_autoconfig_sensors.split(","))) 211 | 212 | await send_loop(ess, client, once=args.once, interval_seconds=args.interval_seconds, 213 | common_divisor=args.common_divisor) 214 | 215 | 216 | if __name__ == "__main__": 217 | main(sys.argv[1:]) 218 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | from setuptools import setup 3 | 4 | from pyduofern import __version__ as version 5 | 6 | test_deps = [ 7 | 'tox', 'pytest<8', 'pytest-asyncio' 8 | ], 9 | 10 | extras = { 11 | 'test': test_deps, 12 | } 13 | 14 | setup(name='pyduofern', 15 | version=version, 16 | description='Library for controlling Rademacher DuoFern actors using python. Requires the Rademacher' 17 | 'Duofern USB Stick Art.-Nr.: 70000093', 18 | classifiers=[ 19 | 'Development Status :: 3 - Alpha', 20 | 'License :: OSI Approved :: GNU General Public License v2 (GPLv2)', 21 | 'Programming Language :: Python :: 3.4', 22 | ], 23 | 24 | url='https://github.com/gluap/pyduofern', 25 | author='Paul Görgen', 26 | author_email='pypi@pgoergen.de', 27 | license='GPL-2.0', 28 | 29 | packages=['pyduofern'], 30 | 31 | install_requires=[ 32 | 'pyserial', 'pyserial-asyncio' 33 | ], 34 | 35 | zip_safe=False, 36 | include_package_data=False, 37 | 38 | tests_require=test_deps, 39 | extras_require=extras, 40 | 41 | scripts=["scripts/duofern_cli.py"], 42 | 43 | long_description=open('README.rst', 'r').read() 44 | ) 45 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gluap/pyduofern/f72f781821389e4ae4ecad4dc94b2c0d565a9f96/tests/__init__.py -------------------------------------------------------------------------------- /tests/files/duofern_recording.json: -------------------------------------------------------------------------------- 1 | { 2 | "recording_dir": "./recording", 3 | "recording": true, 4 | "devices": [ 5 | { 6 | "id": "40ddff", 7 | "name": "blinad1" 8 | }, 9 | { 10 | "id": "40eebb", 11 | "name": "blind2" 12 | }, 13 | { 14 | "id": "46aacc", 15 | "name": "switch1" 16 | } 17 | ], 18 | "system_code": "ffff" 19 | } -------------------------------------------------------------------------------- /tests/replaydata/duofern_record_1528034711.6005006: -------------------------------------------------------------------------------- 1 | sent 01000000000000000000000000000000000000000000 2 | received 81000000000000000000000000000000000000000000 3 | sent 0E000000000000000000000000000000000000000000 4 | received 81000000000000000000000000000000000000000000 5 | sent 0A6fffff000100000000000000000000000000000000 6 | received 81000000000000000000000000000000000000000000 7 | sent 81000000000000000000000000000000000000000000 8 | sent 14140000000000000000000000000000000000000000 9 | received 81000000000000000000000000000000000000000000 10 | sent 81000000000000000000000000000000000000000000 11 | sent 10010000000000000000000000000000000000000000 12 | received 81000000000000000000000000000000000000000000 13 | sent 81000000000000000000000000000000000000000000 14 | sent 0DFF0F400000000000000000000000000000FFFFFF01 15 | received 810000000000000000000000000000000000ffffff01 16 | sent 81000000000000000000000000000000000000000000 17 | sent 81000000000000000000000000000000000000000000 18 | received 0fff0f210d08640000004100110002408cf76fffff01 19 | sent 81000000000000000000000000000000000000000000 20 | received 0fff0f210d08640000004100110001408e436fffff01 21 | sent 81000000000000000000000000000000000000000000 22 | received 0fff0f210d08640000004134110005409b216fffff01 23 | sent 81000000000000000000000000000000000000000000 24 | received 0fff0f210d08640000004133110003408ea26fffff01 25 | sent 81000000000000000000000000000000000000000000 26 | received 0fff0f210d086400000041001100004098826fffff01 27 | sent 81000000000000000000000000000000000000000000 28 | received 0fff0f210d08640000004100110004408e946fffff01 29 | sent 81000000000000000000000000000000000000000000 30 | received 0fff0f210d08640000004130110004408e94ffffff01 31 | sent 81000000000000000000000000000000000000000000 32 | received 0fff0f230f000020501e199e1300ff700fecffffff01 33 | sent 81000000000000000000000000000000000000000000 34 | received 0fff0f230f000020501e198a1300ff700fecffffff01 35 | sent 81000000000000000000000000000000000000000000 36 | sending_command ('409882', 'position', 63) {'channel': None} 37 | sent 0D010707003f0000000000000000006fffff40988200 38 | received 810003cc00000000000000000000006fffff40988200 39 | sent 81000000000000000000000000000000000000000000 40 | received 0fff0f210d0864000000413f110000409882ffffff01 41 | sent 81000000000000000000000000000000000000000000 42 | received 0fff0f210d08640000004100110003408ea2ffffff01 43 | sent 81000000000000000000000000000000000000000000 44 | sending_command ('409b21', 'position', 85) {'channel': None} 45 | sent 0D01070700550000000000000000006fffff409b2100 46 | received 810003cc00000000000000000000006fffff409b2100 47 | sent 81000000000000000000000000000000000000000000 48 | sending_command ('408e94', 'position', 85) {'channel': None} 49 | sent 0D01070700550000000000000000006fffff408e9400 50 | received 810003cc00000000000000000000006fffff408e9400 51 | sent 81000000000000000000000000000000000000000000 52 | received 0fff0f210d08640000004155110005409b21ffffff01 53 | sent 81000000000000000000000000000000000000000000 54 | received 0fff0f210d08640000004155110004408e94ffffff01 55 | sent 81000000000000000000000000000000000000000000 56 | received 0fff0f230f000207502d19001300ff701237ffffff01 57 | -------------------------------------------------------------------------------- /tests/replaydata/duofern_record_1528403726.1207247: -------------------------------------------------------------------------------- 1 | sent 01000000000000000000000000000000000000000000 2 | received 81000000000000000000000000000000000000000000 3 | sent 0E000000000000000000000000000000000000000000 4 | received 81000000000000000000000000000000000000000000 5 | sent 0A6fffff000100000000000000000000000000000000 6 | received 81000000000000000000000000000000000000000000 7 | sent 81000000000000000000000000000000000000000000 8 | sent 14140000000000000000000000000000000000000000 9 | received 81000000000000000000000000000000000000000000 10 | sent 81000000000000000000000000000000000000000000 11 | sent 10010000000000000000000000000000000000000000 12 | received 81000000000000000000000000000000000000000000 13 | sent 81000000000000000000000000000000000000000000 14 | sent 0DFF0F400000000000000000000000000000FFFFFF01 15 | received 81000000000000000000000000000000000000000000 16 | sent 81000000000000000000000000000000000000000000 17 | received 06030100000000000000000000000540988200000000 18 | sent 81000000000000000000000000000000000000000000 19 | received 0fff0f210d08640000004155110003408e946fffff01 20 | sent 81000000000000000000000000000000000000000000 21 | received 0fff0f210d08640000004100110002408cf76fffff01 22 | sent 81000000000000000000000000000000000000000000 23 | received 0fff0f210d08640000004155110003408e94ffffff01 24 | sent 81000000000000000000000000000000000000000000 25 | received 0fff0f210d0864000000415a110004408ea26fffff01 26 | sent 81000000000000000000000000000000000000000000 27 | received 0fff0f210d0864000000414d110001408e436fffff01 28 | sent 81000000000000000000000000000000000000000000 29 | received 0fff0f210d0864000000414d110001408e43ffffff01 30 | sent 81000000000000000000000000000000000000000000 31 | received 0fff0f210d08640000004155110000409b216fffff01 32 | sent 81000000000000000000000000000000000000000000 33 | received 0fff0f210d086400000041441100ff4098826fffff01 34 | sent 81000000000000000000000000000000000000000000 35 | received 0fff0f210d08640000004100110002408cf7ffffff01 36 | sent 81000000000000000000000000000000000000000000 37 | received 0fff0f210d0864000000415a110004408ea2ffffff01 38 | sent 81000000000000000000000000000000000000000000 39 | received 0fff0f210d086400000041441100ff409882ffffff01 40 | sent 81000000000000000000000000000000000000000000 41 | received 0fff0f210d08640000004155110000409b21ffffff01 42 | -------------------------------------------------------------------------------- /tests/replaydata/duofern_record_1528403804.8626764: -------------------------------------------------------------------------------- 1 | sent 01000000000000000000000000000000000000000000 2 | received 81000000000000000000000000000000000000000000 3 | sent 0E000000000000000000000000000000000000000000 4 | received 81000000000000000000000000000000000000000000 5 | sent 0A6fffff000100000000000000000000000000000000 6 | received 81000000000000000000000000000000000000000000 7 | sent 81000000000000000000000000000000000000000000 8 | sent 14140000000000000000000000000000000000000000 9 | received 81000000000000000000000000000000000000000000 10 | sent 81000000000000000000000000000000000000000000 11 | sent 10010000000000000000000000000000000000000000 12 | received 81000000000000000000000000000000000000000000 13 | sent 81000000000000000000000000000000000000000000 14 | sent 0DFF0F400000000000000000000000000000FFFFFF01 15 | received 81000000000000000000000000000000000000000000 16 | sent 81000000000000000000000000000000000000000000 17 | received 06020100000000000000000000000540988200000000 18 | sent 81000000000000000000000000000000000000000000 19 | received 0fff0f210d08640000004144110005409882ffffff01 20 | sent 81000000000000000000000000000000000000000000 21 | received 0fff0f210d08640000004100110002408cf76fffff01 22 | sent 81000000000000000000000000000000000000000000 23 | received 0fff0f210d08640000004155110003408e946fffff01 24 | sent 81000000000000000000000000000000000000000000 25 | received 0fff0f210d0864000000415a110004408ea26fffff01 26 | sent 81000000000000000000000000000000000000000000 27 | received 0fff0f210d0864000000414d110001408e436fffff01 28 | sent 81000000000000000000000000000000000000000000 29 | received 0fff0f210d08640000004141110005409882ffffff01 30 | -------------------------------------------------------------------------------- /tests/replaydata/duofern_record_1578298202.5071442: -------------------------------------------------------------------------------- 1 | sent 01000000000000000000000000000000000000000000 2 | received 81000000000000000000000000000000000000000000 3 | sent 0E000000000000000000000000000000000000000000 4 | received 81000000000000000000000000000000000000000000 5 | sent 0A6fffff000100000000000000000000000000000000 6 | received 81000000000000000000000000000000000000000000 7 | sent 81000000000000000000000000000000000000000000 8 | sent 14140000000000000000000000000000000000000000 9 | received 81000000000000000000000000000000000000000000 10 | sent 81000000000000000000000000000000000000000000 11 | sent 10010000000000000000000000000000000000000000 12 | received 81000000000000000000000000000000000000000000 13 | sent 81000000000000000000000000000000000000000000 14 | sent 0DFF0F400000000000000000000000000000FFFFFF01 15 | received 810000000000000000000000000000000000ffffff01 16 | sent 81000000000000000000000000000000000000000000 17 | sent 81000000000000000000000000000000000000000000 18 | received 0fff0f2207080101070864643300004376c96fffff01 19 | sent 81000000000000000000000000000000000000000000 20 | sending_command ('4376c9', 'off') {'channel': 1} 21 | sent 0D010E0200000000000000000000006fffff4376c900 22 | received 0fff0f2207080101070864003300004376c9ffffff01 23 | sent 81000000000000000000000000000000000000000000 24 | received 810100bb00000000000000000000006fffff4376c900 25 | sent 81000000000000000000000000000000000000000000 26 | sending_command ('4376c9', 'off') {'channel': 2} 27 | sent 0D020E0200000000000000000000006fffff4376c900 28 | received 0fff0f2207080101070800003300004376c9ffffff01 29 | sent 81000000000000000000000000000000000000000000 30 | received 810100bb00000000000000000000006fffff4376c900 31 | sent 81000000000000000000000000000000000000000000 32 | sending_command ('4376c9', 'on') {'channel': 1} 33 | sent 0D010E0300000000000000000000006fffff4376c900 34 | received 810003cc00000000000000000000006fffff4376c900 35 | sent 81000000000000000000000000000000000000000000 36 | received 0fff0f2207080101070800643300004376c9ffffff01 37 | sent 81000000000000000000000000000000000000000000 38 | sending_command ('4376c9', 'on') {'channel': 2} 39 | sent 0D020E0300000000000000000000006fffff4376c900 40 | received 0fff0f2207080101070864643300004376c9ffffff01 41 | sent 81000000000000000000000000000000000000000000 42 | received 0fff0f2207080101070800643300004376c9ffffff01 43 | sent 81000000000000000000000000000000000000000000 44 | received 0fff0f2207080101070800003300004376c9ffffff01 45 | 46 | -------------------------------------------------------------------------------- /tests/replaydata/duofern_record_80op8dbi: -------------------------------------------------------------------------------- 1 | sent 01000000000000000000000000000000000000000000 2 | received 81000000000000000000000000000000000000000000 3 | sent 0E000000000000000000000000000000000000000000 4 | received 81000000000000000000000000000000000000000000 5 | sent 0A6fffff000100000000000000000000000000000000 6 | received 81000000000000000000000000000000000000000000 7 | sent 81000000000000000000000000000000000000000000 8 | sent 14140000000000000000000000000000000000000000 9 | received 81000000000000000000000000000000000000000000 10 | sent 81000000000000000000000000000000000000000000 11 | ?sent 030040ddff0000000000000000000000000000000000 12 | ?received 81000000000000000000000000000000000000000000 13 | ?sent 81000000000000000000000000000000000000000000 14 | ?sent 030140eebb0000000000000000000000000000000000 15 | ?received 81000000000000000000000000000000000000000000 16 | ?sent 81000000000000000000000000000000000000000000 17 | ?sent 030246aacc0000000000000000000000000000000000 18 | ?received 81000000000000000000000000000000000000000000 19 | ?sent 81000000000000000000000000000000000000000000 20 | sent 10010000000000000000000000000000000000000000 21 | received 81000000000000000000000000000000000000000000 22 | sent 81000000000000000000000000000000000000000000 23 | sent 0DFF0F400000000000000000000000000000FFFFFF01 24 | received 81000000000000000000000000000000000000000000 25 | sent 81000000000000000000000000000000000000000000 26 | -------------------------------------------------------------------------------- /tests/replaydata/duofern_record_mg1koayu: -------------------------------------------------------------------------------- 1 | sent 01000000000000000000000000000000000000000000 2 | received 81000000000000000000000000000000000000000000 3 | sent 0E000000000000000000000000000000000000000000 4 | received 81000000000000000000000000000000000000000000 5 | sent 0A6fffff000100000000000000000000000000000000 6 | received 81000000000000000000000000000000000000000000 7 | sent 81000000000000000000000000000000000000000000 8 | sent 14140000000000000000000000000000000000000000 9 | received 81000000000000000000000000000000000000000000 10 | sent 81000000000000000000000000000000000000000000 11 | sent 10010000000000000000000000000000000000000000 12 | received 81000000000000000000000000000000000000000000 13 | sent 81000000000000000000000000000000000000000000 14 | sent 0DFF0F400000000000000000000000000000FFFFFF01 15 | received 81000000000000000000000000000000000000000000 16 | sent 81000000000000000000000000000000000000000000 17 | -------------------------------------------------------------------------------- /tests/test_duofern_stick.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | # python interface for dufoern usb stick 3 | # Copyright (C) 2017 Paul Görgen 4 | # Rough python re-write of the FHEM duofern modules by telekatz, also licensed under GPLv2 5 | # This re-write contains only negligible amounts of original code 6 | # apart from some comments to facilitate translation of the not-yet 7 | # translated parts of the original software. Modification dates are 8 | # documented as submits to the git repository of this code, currently 9 | # maintained at https://github.com/gluap/pyduofern.git 10 | 11 | # This program is free software; you can redistribute it and/or modify 12 | # it under the terms of the GNU General Public License as published by 13 | # the Free Software Foundation; either version 2 of the License, or 14 | # (at your option) any later version. 15 | 16 | # This program is distributed in the hope that it will be useful, 17 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 18 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 19 | # GNU General Public License for more details. 20 | 21 | # You should have received a copy of the GNU General Public License 22 | # along with this program; if not, write to the Free Software Foundation, 23 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 24 | 25 | import logging 26 | import tempfile 27 | import time 28 | import unittest 29 | 30 | logger = logging.getLogger(__name__) 31 | logger.setLevel(logging.INFO) 32 | 33 | from importlib import reload 34 | from unittest.mock import Mock 35 | 36 | import pyduofern.duofern_stick as df 37 | df.MIN_MESSAGE_INTERVAL_MILLIS=0 38 | 39 | 40 | def write_mock(data): 41 | return True 42 | 43 | 44 | def read_mock(data): 45 | return bytearray.fromhex(df.duoACK) 46 | 47 | 48 | class TestInitialize(unittest.TestCase): 49 | def setUp(self): 50 | self.df = reload(df) 51 | self.df.serial.Serial = Mock() 52 | # self.df.serial.tools.list_ports.comports()[0].device = "detecteddevice" 53 | # self.df.serial.Serial.write = write_mock 54 | # self.df.serial.Serial.read = read_mock 55 | 56 | def test_init(self): 57 | test = self.df.DuofernStickThreaded(serial_port="bla", system_code="ffff", config_file_json=tempfile.mktemp()) 58 | test.serial_connection = Mock() 59 | test.serial_connection.read = read_mock 60 | test._initialize() 61 | 62 | def test_run(self): 63 | test = self.df.DuofernStickThreaded(serial_port="bla", system_code="ffff", config_file_json=tempfile.mktemp()) 64 | test.serial_connection = Mock() 65 | test.serial_connection.read = read_mock 66 | test._initialize() 67 | test.start() 68 | time.sleep(0.1) 69 | test.stop() 70 | 71 | def test_init_recording(self): 72 | test = self.df.DuofernStickThreaded(serial_port="bla", system_code="ffff", config_file_json=tempfile.mktemp(), 73 | recording=True) 74 | test.serial_connection = Mock() 75 | test.serial_connection.read = read_mock 76 | test._initialize() 77 | -------------------------------------------------------------------------------- /tests/test_duofern_stick_async.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | # python interface for dufoern usb stick 3 | # Copyright (C) 2017 Paul Görgen 4 | # Rough python re-write of the FHEM duofern modules by telekatz, also licensed under GPLv2 5 | # This re-write contains only negligible amounts of original code 6 | # apart from some comments to facilitate translation of the not-yet 7 | # translated parts of the original software. Modification dates are 8 | # documented as submits to the git repository of this code, currently 9 | # maintained at https://github.com/gluap/pyduofern.git 10 | 11 | # This program is free software; you can redistribute it and/or modify 12 | # it under the terms of the GNU General Public License as published by 13 | # the Free Software Foundation; either version 2 of the License, or 14 | # (at your option) any later version. 15 | 16 | # This program is distributed in the hope that it will be useful, 17 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 18 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 19 | # GNU General Public License for more details. 20 | 21 | # You should have received a copy of the GNU General Public License 22 | # along with this program; if not, write to the Free Software Foundation, 23 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 24 | 25 | import asyncio 26 | import logging 27 | import os 28 | import tempfile 29 | 30 | import pytest 31 | 32 | logger = logging.getLogger(__name__) 33 | logger.setLevel(logging.INFO) 34 | 35 | from pyduofern import DuofernStickAsync, DuofernException, duoACK # import DuofernStickAsync 36 | 37 | 38 | # from pyduofern.duofern_stick import DuofernStickAsync 39 | 40 | 41 | @pytest.fixture( 42 | params=[os.path.join(os.path.abspath(os.path.dirname(__file__)), 'files', 'duofern.json'), 43 | os.path.join(os.path.abspath(os.path.dirname(__file__)), 'files', 'duofern_recording.json'), 44 | tempfile.mktemp()]) 45 | def configfile(request): 46 | return request.param 47 | 48 | 49 | class TransportMock: 50 | def __init__(self, proto): 51 | super(TransportMock).__init__() 52 | self.proto = proto 53 | self.unittesting = True 54 | 55 | def write(self, data): 56 | logger.warning("writing {} detected by mock writer".format(data)) 57 | if data != bytearray.fromhex(duoACK): 58 | self.proto.data_received(bytearray.fromhex(duoACK)) 59 | self.proto._ready.set() 60 | 61 | 62 | @pytest.mark.parametrize(("recording"), [False, True]) 63 | @pytest.mark.asyncio 64 | async def test_init_against_mocked_stick(event_loop,recording, configfile): 65 | proto = DuofernStickAsync(event_loop, system_code="ffff", config_file_json=configfile, 66 | recording=recording) 67 | proto.transport = TransportMock(proto) 68 | proto._ready = asyncio.Event() 69 | 70 | init_ = asyncio.ensure_future(proto.handshake()) 71 | 72 | proto._ready.set() 73 | 74 | 75 | def cb(a): 76 | logging.info(a) 77 | 78 | proto.available.add_done_callback(cb) 79 | await init_ 80 | 81 | 82 | def test_raises_when_run_without_code(): 83 | loop = asyncio.get_event_loop() 84 | 85 | with pytest.raises(DuofernException): 86 | proto = DuofernStickAsync(loop, config_file_json=tempfile.mktemp(), recording=False) 87 | 88 | 89 | def test_raises_when_run_with_wrong_code(): 90 | loop = asyncio.get_event_loop() 91 | 92 | with pytest.raises(AssertionError): 93 | proto = DuofernStickAsync(loop, 94 | config_file_json=os.path.join(os.path.abspath(os.path.dirname(__file__)), 'files', 95 | 'duofern.json'), 96 | recording=False, system_code="faaf") 97 | 98 | 99 | def test_raises_when_run_with_long_code(): 100 | loop = asyncio.get_event_loop() 101 | 102 | with pytest.raises(AssertionError): 103 | proto = DuofernStickAsync(config_file_json=tempfile.mktemp(), recording=False, system_code="faaaf") 104 | -------------------------------------------------------------------------------- /tests/test_replay.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | # python interface for dufoern usb stick 3 | # Copyright (C) 2017 Paul Görgen 4 | # Rough python re-write of the FHEM duofern modules by telekatz, also licensed under GPLv2 5 | # This re-write contains only negligible amounts of original code 6 | # apart from some comments to facilitate translation of the not-yet 7 | # translated parts of the original software. Modification dates are 8 | # documented as submits to the git repository of this code, currently 9 | # maintained at https://github.com/gluap/pyduofern.git 10 | 11 | # This program is free software; you can redistribute it and/or modify 12 | # it under the terms of the GNU General Public License as published by 13 | # the Free Software Foundation; either version 2 of the License, or 14 | # (at your option) any later version. 15 | 16 | # This program is distributed in the hope that it will be useful, 17 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 18 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 19 | # GNU General Public License for more details. 20 | 21 | # You should have received a copy of the GNU General Public License 22 | # along with this program; if not, write to the Free Software Foundation, 23 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 24 | 25 | import asyncio 26 | import logging 27 | import os 28 | import re 29 | import tempfile 30 | import traceback 31 | import time 32 | import sys 33 | from ast import literal_eval 34 | 35 | import pytest 36 | 37 | from pyduofern.duofern_stick import hex 38 | 39 | # pytestmark = pytest.mark.asyncio 40 | 41 | logger = logging.getLogger(__name__) 42 | logger.setLevel(logging.INFO) 43 | 44 | from pyduofern.duofern_stick import DuofernStickAsync 45 | 46 | 47 | # @pytest.fixture # (scope="function", params=[True, False]) 48 | # def looproto(event_loop): 49 | # loop = event_loop 50 | 51 | # proto = 52 | # return loop, proto 53 | 54 | 55 | def list_replays(): 56 | replaydir = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'replaydata') 57 | files = os.listdir(replaydir) 58 | return [os.path.join(replaydir, f) for f in files] 59 | 60 | 61 | class TransportMock: 62 | def __init__(self, proto, replayfile): 63 | super(TransportMock).__init__() 64 | self.proto = proto 65 | self.unittesting = True 66 | self.replay = self.readin(replayfile) 67 | self.finished_actions = [] 68 | self.receiveloop = asyncio.ensure_future(self.receive_loop()) 69 | pass 70 | 71 | @classmethod 72 | def readin(cls, replayfile): 73 | f = open(replayfile, 'r') 74 | lines = f.readlines() 75 | lines = ["!" + l for l in lines if l[0] != "?"] 76 | actions = [l.split(" ") for l in lines] 77 | actions.reverse() 78 | return actions 79 | 80 | def next_line(self): 81 | return self.replay.pop() 82 | 83 | def next_is_received(self): 84 | if not self.replay: return False 85 | return self.replay[-1][0][1:] == "received" 86 | 87 | def next_is_action(self): 88 | if not self.replay: return False 89 | return self.replay[-1][0][1:] == "sending_command" 90 | 91 | def next_optional(self): 92 | if not self.replay: return False 93 | return self.replay[-1][0][0] == "?" 94 | 95 | def write(self, data): 96 | optional = self.next_optional() 97 | result = self.check_if_next_matches(data) 98 | if (result != "OK") and not optional: 99 | self.finished_actions.append(result) # pragma: no cover 100 | else: 101 | self.finished_actions.append("OK") 102 | 103 | # await asyncio.ensure_future(self.receive()) 104 | 105 | async def receive_loop(self): 106 | while self.replay: 107 | await asyncio.sleep(0.01) 108 | if self.next_is_received(): 109 | line = self.next_line()[1].strip() 110 | try: 111 | if self.proto is None: 112 | print("lol") 113 | self.proto.data_received(bytearray.fromhex(line)) 114 | self.finished_actions.append("OK") 115 | except Exception as exc: # pragma: no cover 116 | traceback_str = ''.join(traceback.format_tb(exc.__traceback__)) 117 | self.finished_actions.append(f"EXCEPTION WHILE REPLAYING RECIEVED MESSAGE {exc}, {traceback_str}") 118 | logging.exception("exception I found!", exc_info=True) 119 | 120 | async def actions(self): 121 | while self.next_is_action(): 122 | await asyncio.sleep(0.01) 123 | command_args = " ".join(self.next_line()[1:]) 124 | args_and_kwargs = re.match(r"(\([^\)]+\)).*({[^}]+})", command_args) 125 | args, kwargs = args_and_kwargs.groups() 126 | self.proto.command(*literal_eval(args),**literal_eval(kwargs)) 127 | await asyncio.sleep(0.01) 128 | 129 | def check_if_next_matches(self, data): 130 | logger.warning("writing {} detected by mock writer".format(data)) 131 | msg = "" 132 | if self.next_is_received(): 133 | msg += "recording states next command was send but we received something" # pragma: no cover 134 | line = self.next_line()[1] 135 | if bytearray.fromhex(line.strip()) != data: 136 | return msg + "\nrecording states we sent {} but we are sending {}".format(line, 137 | hex(data)) # pragma: no cover 138 | else: 139 | return "OK" 140 | 141 | 142 | @pytest.mark.parametrize('replayfile', list_replays()) 143 | @pytest.mark.asyncio 144 | async def test_init_against_mocked_stick(event_loop, replayfile): 145 | def bla(*args,**kwargs): 146 | pass 147 | proto = DuofernStickAsync(event_loop, system_code="ffff", config_file_json=tempfile.mktemp(), recording=False, changes_callback=bla) 148 | proto.transport = TransportMock(proto, replayfile) 149 | proto._ready = asyncio.Event() 150 | 151 | init_ = asyncio.ensure_future(proto.handshake()) 152 | 153 | proto._ready.set() 154 | 155 | await init_ 156 | assert ["OK"] * len( 157 | proto.transport.finished_actions) == proto.transport.finished_actions, "some sends did not match" \ 158 | "the recording" 159 | 160 | await asyncio.wait_for(proto.available, 1) 161 | 162 | # if proto.transport.next_is_sent(): 163 | # proto.transport.write(bytearray.fromhex(proto.transport.replay[-1][1].strip())) 164 | start_time = time.time() 165 | async def feedback_loop(): 166 | while proto.transport.replay: 167 | await asyncio.sleep(0) 168 | if time.time() - start_time > 3 and sys.gettrace() is None: 169 | raise TimeoutError("Mock test should not take longer than 3 seconds, asynchronous loop must be stuck" 170 | "Be aware this is not raised in debug mode.") 171 | 172 | if proto.transport.next_is_action(): 173 | asyncio.ensure_future(proto.transport.actions()) 174 | print("bla") 175 | 176 | await feedback_loop() 177 | proto.transport.receiveloop.cancel() 178 | 179 | proto.send_loop.cancel() 180 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist= 3 | 310 4 | 311 5 | 6 | [testenv] 7 | deps = 8 | pytest 9 | pytest-asyncio 10 | pyserial 11 | commands = py.test {posargs} 12 | 13 | [pytest] 14 | 15 | [gh] 16 | python= 17 | 3.11 = py311 18 | 3.10 = py310 --------------------------------------------------------------------------------