├── .envrc ├── .github └── workflows │ ├── push.yml │ └── tag.yml ├── .gitignore ├── .isort.cfg ├── Dockerfile ├── LICENCE ├── README.rst ├── docs └── RFLink Protocol Reference.txt ├── flake.lock ├── flake.nix ├── pyproject.toml ├── rflink ├── __init__.py ├── __main__.py ├── parser.py ├── protocol.py └── py.typed ├── rflinkproxy ├── __init__.py └── __main__.py ├── setup.cfg ├── setup.py ├── test.py ├── tests ├── .keep ├── protocol_samples.txt ├── test_cli.py ├── test_parse.py ├── test_protocol.py └── test_proxy.py └── tox.ini /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Push 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | fail-fast: false 17 | max-parallel: 5 18 | matrix: 19 | python-version: 20 | - 3.9 21 | - "3.10" 22 | - 3.11 23 | - 3.12 24 | 25 | steps: 26 | - uses: actions/checkout@v1 27 | 28 | - name: Set up Python ${{ matrix.python-version }} 29 | uses: actions/setup-python@v2 30 | with: 31 | python-version: ${{ matrix.python-version }} 32 | 33 | - name: Install dependencies 34 | run: | 35 | python -m pip install --upgrade pip 36 | pip install tox tox-gh-actions 37 | 38 | - name: Test with tox 39 | run: tox 40 | -------------------------------------------------------------------------------- /.github/workflows/tag.yml: -------------------------------------------------------------------------------- 1 | name: Tag 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | max-parallel: 5 14 | matrix: 15 | python-version: 16 | - 3.9 17 | - "3.10" 18 | - 3.11 19 | - 3.12 20 | 21 | steps: 22 | - uses: actions/checkout@v1 23 | 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v2 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install tox tox-gh-actions 33 | 34 | - name: Test with tox 35 | run: tox 36 | 37 | release: 38 | runs-on: ubuntu-latest 39 | 40 | needs: test 41 | 42 | steps: 43 | - uses: actions/checkout@master 44 | 45 | - name: Set up Python 3.11 46 | uses: actions/setup-python@v1 47 | with: 48 | python-version: 3.11 49 | 50 | - name: Install pypa/build 51 | run: python -m pip install build --user 52 | 53 | - name: Build a binary wheel and a source tarball 54 | run: >- 55 | python -m build --sdist --wheel --outdir dist/ . 56 | 57 | - name: Publish distribution to PyPI 58 | uses: pypa/gh-action-pypi-publish@master 59 | with: 60 | password: ${{ secrets.pypi_password }} 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # virtualenv 53 | .venv/ 54 | venv/ 55 | ENV/ 56 | .env/ 57 | 58 | # direnv 59 | .direnv 60 | 61 | .mypy_cache/ 62 | 63 | # Devenv 64 | .devenv* 65 | devenv.local.nix 66 | 67 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | multi_line_output=3 3 | known_first_party=rflink,rflinkproxy 4 | default_section=THIRDPARTY 5 | # black compatibility 6 | include_trailing_comma=True 7 | force_grid_wrap=0 8 | use_parentheses=True 9 | line_length=88 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # docker build -t python-rflink . 2 | # sudo docker run -it --device="/dev/ttyACM0:/dev/ttyACM0" python-rflink rflink 3 | 4 | FROM python:3.7.3-slim 5 | 6 | ARG DEBIAN_FRONTEND=noninteractive 7 | RUN /usr/bin/apt-get update \ 8 | && /usr/local/bin/pip install --no-cache-dir rflink \ 9 | && rm -rf /var/lib/apt/lists/* 10 | 11 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Johan Bloemberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Python RFlink library 2 | ===================== 3 | 4 | .. image:: https://github.com/aequitas/python-rflink/workflows/Push/badge.svg 5 | :target: https://github.com/aequitas/python-rflink/actions?query=workflow%3APush 6 | 7 | .. image:: https://img.shields.io/pypi/v/rflink.svg 8 | :target: https://pypi.python.org/pypi/rflink 9 | 10 | .. image:: https://img.shields.io/pypi/pyversions/rflink.svg 11 | :target: https://pypi.python.org/pypi/rflink 12 | 13 | .. image:: https://api.codeclimate.com/v1/badges/a99a88d28ad37a79dbf6/maintainability 14 | :target: https://codeclimate.com/github/codeclimate/codeclimate/maintainability 15 | :alt: Maintainability 16 | 17 | .. image:: https://api.codeclimate.com/v1/badges/a99a88d28ad37a79dbf6/test_coverage 18 | :target: https://codeclimate.com/github/codeclimate/codeclimate/test_coverage 19 | :alt: Test Coverage 20 | 21 | .. image:: https://img.shields.io/requires/github/aequitas/python-rflink.svg 22 | :target: https://requires.io/github/aequitas/python-rflink/requirements/ 23 | 24 | .. image:: https://img.shields.io/badge/Cyberveiligheid-97%25-yellow.svg 25 | :target: https://eurocyber.nl 26 | 27 | Library and CLI tools for interacting with RFlink 433MHz transceiver. 28 | 29 | https://www.rflink.nl/ 30 | 31 | Requirements 32 | ------------ 33 | 34 | - Python 3.6 (or higher) 35 | 36 | Description 37 | ----------- 38 | 39 | This package is created mainly as a library for the Home assistant Rflink component implementation. A CLI has been created mainly for debugging purposes but may be extended in the future for more real-world application if needed. 40 | 41 | The package also provides a CLI utility which allows a single RFLink hardware to be shared by multiple clients, e.g. Home assistant + Domoticz or multiple Home assistant instances. 42 | 43 | Installation 44 | ------------ 45 | 46 | .. code-block:: bash 47 | 48 | $ pip install rflink 49 | 50 | Usage of RFLink debug CLI 51 | ------------------------- 52 | 53 | .. code-block:: 54 | 55 | $ rflink -h 56 | Command line interface for rflink library. 57 | 58 | Usage: 59 | rflink [-v | -vv] [options] 60 | rflink [-v | -vv] [options] [--repeat ] (on | off | allon | alloff) 61 | rflink (-h | --help) 62 | rflink --version 63 | 64 | Options: 65 | -p --port= Serial port to connect to [default: /dev/ttyACM0], 66 | or TCP port in TCP mode. 67 | --baud= Serial baud rate [default: 57600]. 68 | --host= TCP mode, connect to host instead of serial port. 69 | --repeat= How often to repeat a command [default: 1]. 70 | -m= How to handle incoming packets [default: event]. 71 | --ignore= List of device ids to ignore, wildcards supported. 72 | -h --help Show this screen. 73 | -v Increase verbosity 74 | --version Show version. 75 | 76 | Intercept and display Rflink packets: 77 | 78 | .. code-block:: 79 | 80 | $ rflink 81 | rflink Nodo RadioFrequencyLink RFLink Gateway V1.1 R45 82 | newkaku_00000001_4 off 83 | newkaku_00000001_3 on 84 | alectov1_0334_temp 7.4 °C 85 | alectov1_0334_bat low 86 | alectov1_0334_hum 26 % 87 | 88 | Turn a device on or off: 89 | 90 | .. code-block:: 91 | 92 | $ rflink on newkaku_0000_1 93 | $ rflink off newkaku_0000_1 94 | 95 | Use of TCP mode instead of serial port (eg: ESP8266 serial bridge): 96 | 97 | .. code-block:: 98 | 99 | $ rflink --host 1.2.3.4 --port 1234 100 | 101 | Debug logging is shown in verbose mode for debugging: 102 | 103 | .. code-block:: 104 | 105 | $ rflink -vv 106 | DEBUG:asyncio:Using selector: EpollSelector 107 | DEBUG:rflink.protocol:connected 108 | DEBUG:rflink.protocol:received data: 20;00;Nodo RadioFrequen 109 | DEBUG:rflink.protocol:received data: cyLink - RFLink Gateway 110 | DEBUG:rflink.protocol:received data: V1.1 - R45; 111 | DEBUG:rflink.protocol:got packet: 20;00;Nodo RadioFrequencyLink - RFLink Gateway V1.1 - R45; 112 | DEBUG:rflink.protocol:decoded packet: {'revision': '45', 'node': 'gateway', 'version': '1.1', 'protocol': 'unknown', 'firmware': 'RFLink Gateway', 'hardware': 'Nodo RadioFrequencyLink'} 113 | DEBUG:rflink.protocol:got event: {'version': '1.1', 'revision': '45', 'firmware': 'RFLink Gateway', 'hardware': 'Nodo RadioFrequencyLink', 'id': 'rflink'} 114 | rflink Nodo RadioFrequencyLink RFLink Gateway V1.1 R45 115 | DEBUG:rflink.protocol:received data: 2 116 | DEBUG:rflink.protocol:received data: 0;01;NewKaku;ID=00000001 117 | DEBUG:rflink.protocol:received data: ;SWITCH=4;CMD=OFF; 118 | DEBUG:rflink.protocol:got packet: 20;01;NewKaku;ID=00000001;SWITCH=4;CMD=OFF; 119 | DEBUG:rflink.protocol:decoded packet: {'id': '00000001', 'protocol': 'newkaku', 'command': 'off', 'switch': '4', 'node': 'gateway'} 120 | DEBUG:rflink.protocol:got event: {'id': 'newkaku_00000001_4', 'command': 'off'} 121 | newkaku_00000001_4 off 122 | 123 | Usage of RFLinkProxy CLI 124 | ------------------------ 125 | 126 | .. code-block:: 127 | 128 | $ rflinkproxy -h 129 | Command line interface for rflink proxy. 130 | 131 | Usage: 132 | rflinkproxy [-v | -vv] [options] 133 | rflinkproxy (-h | --help) 134 | rflinkproxy --version 135 | 136 | Options: 137 | --listenport= Port to listen on 138 | --port= Serial port to connect to [default: /dev/ttyACM0], 139 | or TCP port in TCP mode. 140 | --baud= Serial baud rate [default: 57600]. 141 | --host= TCP mode, connect to host instead of serial port. 142 | --repeat= How often to repeat a command [default: 1]. 143 | -h --help Show this screen. 144 | -v Increase verbosity 145 | --version Show version. 146 | 147 | Share RFLink connected to serial port /dev/ttyACM1, 148 | the proxy will listen on port 2345: 149 | 150 | .. code-block:: 151 | 152 | $ rflinkproxy --port /dev/ttyACM0 --listenport 2345 153 | 154 | Share TCP mode RFLink instead of serial port (eg: ESP8266 serial bridge), 155 | the proxy will listen on port 2345: 156 | 157 | .. code-block:: 158 | 159 | $ rflinkproxy --host 1.2.3.4 --port 1234 --listenport 2345 160 | 161 | Debug logging is shown in verbose mode for debugging: 162 | 163 | .. code-block:: 164 | 165 | $ rflinkproxy -vv --host 1.2.3.4 --port 1234 --listenport 2345 166 | DEBUG:asyncio:Using selector: EpollSelector 167 | INFO:rflinkproxy.__main__:Serving on ('0.0.0.0', 2345) 168 | INFO:rflinkproxy.__main__:Initiating Rflink connection 169 | DEBUG:rflink.protocol:connected 170 | INFO:rflinkproxy.__main__:Connected to Rflink 171 | INFO:rflinkproxy.__main__:Incoming connection from: ::1:63293 172 | DEBUG:rflinkproxy.__main__:got packet: 20;00;Xiron;ID=4001;TEMP=00f1;HUM=38;BAT=LOW; 173 | DEBUG:rflinkproxy.__main__:decoded packet: {'node': 'gateway', 'protocol': 'xiron', 'id': '4001', 'temperature': 24.1, 'temperature_unit': '°C', 'humidity': 38, 'humidity_unit': '%', 'battery': 'low'} 174 | INFO:rflinkproxy.__main__:forwarding packet 20;00;Xiron;ID=4001;TEMP=00f1;HUM=38;BAT=LOW; to clients 175 | DEBUG:rflinkproxy.__main__:got packet: 20;00;NewKaku;ID=013373f6;SWITCH=10;CMD=ON; 176 | DEBUG:rflinkproxy.__main__:decoded packet: {'node': 'gateway', 'protocol': 'newkaku', 'id': '013373f6', 'switch': '10', 'command': 'on'} 177 | INFO:rflinkproxy.__main__:forwarding packet 20;00;NewKaku;ID=013373f6;SWITCH=10;CMD=ON; to clients 178 | DEBUG:rflinkproxy.__main__:got packet: 20;00;Auriol V2;ID=D101;TEMP=006f;BAT=OK; 179 | DEBUG:rflinkproxy.__main__:decoded packet: {'node': 'gateway', 'protocol': 'auriol v2', 'id': 'd101', 'temperature': 11.1, 'temperature_unit': '°C', 'battery': 'ok'} 180 | INFO:rflinkproxy.__main__:forwarding packet 20;00;Auriol V2;ID=D101;TEMP=006f;BAT=OK; to clients 181 | -------------------------------------------------------------------------------- /docs/RFLink Protocol Reference.txt: -------------------------------------------------------------------------------- 1 | RFLink Gateway Commands and Data fields 2 | 3 | The use of this protocol is free for both server and client sides of the implementation 4 | Data is send serial (via USB) as "text" at a speed of 57600 baud (N,8,1) 5 | It is sufficient to open a port and listen to the data. 6 | Each text line contains information that has been received via RF signals. 7 | 8 | 9 | Packet structure - Received data from RF: 10 | ----------------------------------------- 11 | 20;02;Name;ID=9999;LABEL=data; 12 | 13 | 20 => Node number 20 means from the RFLink Gateway to the master, 10 means from the master to the RFLink Gateway 14 | Node number 11 means from the master to the master (Echo command - creation of devices), see below for explanation 15 | ; => field separator 16 | 02 => packet counter (goes from 00-FF) 17 | NAME => Device / Protocol name (can be used to display in applications etc.) 18 | ID=9999 => Device ID (often a rolling code and/or device channel number) (Hexadecimal) 19 | LABEL=data => Data fields, contains the field type and value for that field, can be present multiple times per device. 20 | 21 | 22 | Data Fields: (LABEL=data) 23 | ------------ 24 | ID=9999 => Device ID (often a rolling code and/or device channel number) (Hexadecimal) 25 | SWITCH=A16 => House/Unit code like A1, P2, B16 or a button number etc. 26 | CMD=ON => Command (ON/OFF/ALLON/ALLOFF) Additional for Milight: DISCO+/DISCO-/MODE0 - MODE8 27 | SET_LEVEL=15 => Direct dimming level setting value (decimal value: 0-15) 28 | TEMP=9999 => Temperature (hexadecimal), high bit contains negative sign, needs division by 10 (0xC0 = 192 decimal = 19.2 degrees) 29 | HUM=99 => Humidity (decimal value: 0-100 to indicate relative humidity in %) 30 | BARO=9999 => Barometric pressure (hexadecimal) 31 | HSTATUS=99 => 0=Normal, 1=Comfortable, 2=Dry, 3=Wet 32 | BFORECAST=99 => 0=No Info/Unknown, 1=Sunny, 2=Partly Cloudy, 3=Cloudy, 4=Rain 33 | UV=9999 => UV intensity (hexadecimal) 34 | LUX=9999 => Light intensity (hexadecimal) 35 | BAT=OK => Battery status indicator (OK/LOW) 36 | RAIN=1234 => Total rain in mm. (hexadecimal) 0x8d = 141 decimal = 14.1 mm (needs division by 10) 37 | RAINRATE=1234 => Rain rate in mm. (hexadecimal) 0x8d = 141 decimal = 14.1 mm (needs division by 10) 38 | RAINTOT=1234 => Total rain (per 24 hours) in mm. (hexadecimal) 0x8d = 141 decimal = 14.1 mm (needs division by 10) 39 | Only reported by a very limited number of sensors 40 | WINSP=9999 => Wind speed in km. p/h (hexadecimal) needs division by 10 41 | AWINSP=9999 => Average Wind speed in km. p/h (hexadecimal) needs division by 10 42 | WINGS=9999 => Wind Gust in km. p/h (hexadecimal) 43 | WINDIR=0123 => Wind direction (integer value from 0-15) reflecting 0-360 degrees in 22.5 degree steps 44 | WINCHL => wind chill (hexadecimal, see TEMP) 45 | WINTMP=1234 => Wind meter temperature reading (hexadecimal, see TEMP) 46 | CHIME=123 => Chime/Doorbell melody number 47 | SMOKEALERT=ON => ON/OFF 48 | PIR=ON => ON/OFF 49 | CO2=1234 => CO2 air quality 50 | SOUND=1234 => Noise level 51 | KWATT=9999 => KWatt (hexadecimal) 52 | WATT=9999 => Watt (hexadecimal) 53 | CURRENT=1234 => Current phase 1 54 | CURRENT2=1234 => Current phase 2 (CM113) 55 | CURRENT3=1234 => Current phase 3 (CM113) 56 | DIST=1234 => Distance 57 | METER=1234 => Meter values (water/electricity etc.) 58 | VOLT=1234 => Voltage 59 | RGBW=9999 => Milight: provides 1 byte color and 1 byte brightness value 60 | 61 | Sample data of received RF packets: 62 | ----------------------------------- 63 | 20;2D;UPM/Esic;ID=0001;TEMP=00cf;HUM=16;BAT=OK; 64 | 20;6A;UPM/Esic;ID=1002;WINSP=0041;WINDIR=0001;BAT=OK; 65 | 20;08;UPM/Esic;ID=1003;RAIN=0010;BAT=OK; 66 | 20;31;Mebus;ID=c201;TEMP=00cf; 67 | 20;32;Auriol;ID=008f;TEMP=00d3;BAT=OK; 68 | 20;A2;Auriol V2;ID=008f;TEMP=00a3;BAT=LOW; 69 | 20;33;Cresta;ID=3001;TEMP=00b0;HUM=50; 70 | 20;0C;Cresta;ID=8001;RAIN=001c; 71 | 20;47;Cresta;ID=8001;WINDIR=0002;WINSP=0060;WINGS=0088;WINCHL=b0; 72 | 20;47;Cresta;ID=8001;TEMP=00b0;UV=00d0; 73 | 20;36;Alecto V1;ID=ec02;TEMP=00d1;HUM=14; 74 | 20;07;Mebus;ID=ea01;TEMP=0017; 75 | 20;3D;Alecto V1;ID=2000;TEMP=0011;HUM=61; 76 | 20;26;Alecto V1;ID=0086;RAIN=02ac; 77 | 20;30;Alecto V1;ID=0020;WINSP=0068; 78 | 20;16;Alecto V1;ID=0020;WINSP=0020; 79 | 20;17;Alecto V1;ID=0020;WINDIR=0002;WINGS=0088; 80 | 20;36;Alecto V1;ID=0020;WINDIR=0002;WINGS=0040; 81 | 20;74;Oregon TempHygro;ID=0ACC;TEMP=00be;HUM=40;BAT=OK; 82 | 20;b3;Oregon TempHygro;ID=1a2d;TEMP=00dd;HUM=43;BAT=OK; 83 | 20;e5;Oregon BTHR;ID=5a6d;TEMP=00be;HUM=40;BARO=03d7;BAT=OK; 84 | 20;46;Oregon Rain;ID=2a1d;RAIN=0012;RAINTOT=0012;BAT=OK; 85 | 20;83;Oregon Rain2;ID=2a19;RAIN=002a;RAINTOT=0054;BAT=OK; 86 | 20;32;Oregon Wind;ID=1a89;WINDIR=0015;WINSP=0068;AWINSP=0050;BAT=OK; 87 | 20;4a;Oregon Wind2;ID=3a0d;WINDIR=0002;WINSP=0040;AWINSP=005a;BAT=OK; 88 | 20;ba;Oregon UVN128/138;ID=ea7c;UV=0030;BAT=OK; 89 | 20;AF;SelectPlus;ID=1bb4;CHIME=01; 90 | 20;FC;Plieger York;ID=dd01;CHIME=02; 91 | 20;47;Byron SX;ID=a66a;CHIME=09; 92 | 20;12;Pir;ID=aa66;PIR=ON; 93 | 20;63;SmokeAlert;ID=123456;SMOKEALERT=ON; 94 | 20;06;Kaku;ID=41;SWITCH=1;CMD=ON; 95 | 20;0C;Kaku;ID=41;SWITCH=2;CMD=OFF; 96 | 20;0D;Kaku;ID=41;SWITCH=2;CMD=ON; 97 | 20;46;Kaku;ID=44;SWITCH=4;CMD=OFF; 98 | 20;E0;NewKaku;ID=cac142;SWITCH=1;CMD=ALLOFF; 99 | 20;3B;NewKaku;ID=cac142;SWITCH=3;CMD=OFF; 100 | 20;0B;NewKaku;ID=000005;SWITCH=2;CMD=ON; 101 | 20;0E;NewKaku;ID=000005;SWITCH=2;CMD=OFF; 102 | 20;12;NewKaku;ID=000002;SWITCH=2;CMD=OFF; 103 | 20;1E;NewKaku;ID=00000a;SWITCH=2;CMD=OFF; 104 | 20;1F;NewKaku;ID=00000a;SWITCH=2;CMD=ON; 105 | 20;01;NewKaku;ID=000007;SWITCH=2;CMD=OFF; 106 | 20;04;NewKaku;ID=000007;SWITCH=2;CMD=ON; 107 | 20;04;NewKaku;ID=000007;SWITCH=2;CMD=SET_LEVEL=14; 108 | 20;0C;HomeEasy;ID=7900b200;SWITCH=0b;CMD=ALLON; 109 | 20;AD;FA500;ID=0d00b900;SWITCH=0001;CMD=UNKOWN; 110 | 20;AE;FA500;ID=0a01;SWITCH=0a01;CMD=OFF; 111 | 20;03;Eurodomest;ID=03696b;SWITCH=00;CMD=OFF; 112 | 20;04;Eurodomest;ID=03696b;SWITCH=07;CMD=ALLOFF; 113 | 20;41;Conrad RSL2;ID=010002;SWITCH=03;CMD=ON; 114 | 20;47;Blyss;ID=ff98;SWITCH=A1;CMD=ON; 115 | 20;73;Kambrook;ID=010203;SWITCH=A1;CMD=OFF; 116 | 20;39;RTS;ID=1a602a;SWITCH=01;CMD=DOWN; 117 | 20;01;MiLightv1;ID=F746;SWITCH=00;RGBW=3c00;CMD=ON; 118 | 20;05;MySensors;ID=04;TEMP=00d8; 119 | 20;06;MySensors;ID=04;HUM=50; 120 | 121 | Note that for sensors that only report values like temperature, only the data and the ID are required. 122 | Name labels can be thrown away or used for cosmetic purposes. 123 | 124 | For switches, the protocol name has to be stored and re-used on the transmission side. 125 | Thus, when a remote control is used to control a device data like below will be send from the RFLink Gateway over USB: 126 | 20;3B;NewKaku;ID=cac142;SWITCH=3;CMD=OFF; 127 | When the state of this switch needs to be changed the following command has to be send: 128 | 10;NewKaku;0cac142;3;ON; 129 | The name label (here "NewKaku") is used to tell the RFLink Gateway what protocol it has to use for the RF broadcast. 130 | 131 | 132 | Special Control Commands - Send: 133 | -------------------------------- 134 | 10;REBOOT; => Reboot RFLink Gateway hardware 135 | 10;PING; => a "keep alive" function. Is replied with: 20;99;PONG; 136 | 10;VERSION; => Version and build indicator. Is replied with: 20;99;"RFLink Gateway software version"; 137 | 10;RFDEBUG=ON; => ON/OFF to Enable/Disable showing of RF packets. Is replied with: 20;99;RFDEBUG="state"; 138 | 10;RFUDEBUG=ON; => ON/OFF to Enable/Disable showing of undecoded RF packets. Is replied with: 20;99;RFUDEBUG="state"; 139 | 10;QRFDEBUG=ON; => ON/OFF to Enable/Disable showing of undecoded RF packets. Is replied with: 20;99;QRFDEBUG="state"; 140 | QRFDEBUG is a faster version of RFUDEBUG but all pulse times are shown in hexadecimal and need to be multiplied by 30 141 | 10;RTSCLEAN; => Clean Rolling code table stored in internal EEPROM 142 | 10;RTSRECCLEAN=9 => Clean Rolling code record number (value from 0 - 15) 143 | 10;RTSSHOW; => Show Rolling code table stored in internal EEPROM 144 | 10;STATUS; => Reports the status of the various modules that can be enabled/disabled 145 | 20;B5;STATUS;setRF433=ON;NodoNRF=OFF;MilightNRF=ON;setLivingColors=ON;setAnsluta=OFF;setGPIO=OFF; 146 | 10;setRF433=ON => enable/disable scanning of received 433/868 mhz signals 147 | 10;setGPIO=ON => enable/disable scanning of IOPIN changes 148 | 10;setBLE=ON => enable/disable scanning of Bluetooth LE signals, requires an NRF24L01 wired for Milight use, not together with Milight/MySensors 149 | 10;setMySensors=ON => enable/disable scanning of MySensors signals, requires an NRF24L01 wired for Milight use, not together with Milight/BLE 150 | 10;setMilight=ON => enable/disable scanning of Milight RF signals, requires an NRF24L01 wired for Milight use, not together with BLE/MySensors 151 | 10;setNodoNRF=ON => enable/disable scanning of Nodo NRF signals, requires an NRF24L01 wired for Nodo use 152 | 10;setANSLUTA=ON => enable/disable scanning of Ikea Ansluta signals, requires a CC2500, not together with Livingcolors 153 | 10;setLIVINGCOLORS=O => enable/disable scanning of Living Colors Gen1 signals, requires a CC2500, not together with Ansluta 154 | 10;LicoClean; => Erase the Living colors lamp address table 155 | 10;LicoShow; => Show the Living colors lamp address table 156 | 10;EEPClean; => Clean all settings in EEPROM 157 | 158 | 159 | Packet structure - To Send data via RF: 160 | --------------------------------------- 161 | 10;Protocol Name;device address,button number;action; 162 | 163 | Sample data of transmitted RF packets: 164 | -------------------------------------- 165 | 10;Kaku;00004d;1;OFF; => Kaku/ARC protocol;address;action (ON/OFF) 166 | 10;AB400D;00004d;1;OFF; => Sartano protocol;address;action (ON/OFF) 167 | 10;Impuls;00004d;1;OFF; => Impuls protocol;address;action (ON/OFF) 168 | 10;NewKaku;00c142;1;ON; => Newkaku/AC protocol;address (24 bits);button number (hexadecimal 0x0-0x0f);action (ON/OFF/ALLON/ALLOFF/15 - 1 to 15 for direct dim level) 169 | 10;NewKaku;128ac4d;1;OFF; => Newkaku/AC protocol;address (28 bits);button number (hexadecimal 0x0-0x0f);action (ON/OFF/ALLON/ALLOFF/15 - 1 to 15 for direct dim level) 170 | 10;Eurodomest;123456;01;ON; => Eurodomest protocol;address;button number;action (ON/OFF/ALLON/ALLOFF) 171 | 10;Blyss;ff98;A1;OFF; => Blyss protocol;address;button;action (ON/OFF/ALLON/ALLOFF) 172 | 10;Conrad;ff0607;1;OFF; => Conrad RSL protocol, address, button number, action (ON/OFF/ALLON/ALLOFF) 173 | 10;Kambrook;050325;a1;ON; => Kambrook protocol, address, unit/button number, action (ON/OFF) 174 | 10;X10;000041;1;OFF; => X10 protocol;address;action (ON/OFF) 175 | 10;HomeConfort;01b523;D3;ON;=> HomeConfort protocol;address;action (ON/OFF) 176 | 10;FA500;001b523;D3;ON; => Flamingo protocol;address;action (ON/OFF) 177 | 10;Powerfix;000080;0;ON; => Powerfix/Quigg/Chacon protocol;address;action (ON/OFF) 178 | 10;Ikea Koppla;000080;0;ON; => Koppla protocol;address;action (ON/OFF) 179 | 10;HomeEasy;7900b100;3;ON; => Home Easy protocol;address;action (ON/OFF/ALLON/ALLOFF) 180 | 10;EV1527;000080;0;ON; => EV1527 protocol;address;device 0x00-0x0f,action ON/OFF 181 | 10;Chuango;000080;2;ON; => Chuango Protocol;address;action (ON/OFF/ALLON/ALLOFF) 182 | 10;Selectplus;001c33; => SelectPlus protocol;address 183 | 10;Byron;112233;01;OFF; => Dyron SX protocol;address;ringtone 184 | 10;DELTRONIC;001c33; => Deltronic protocol;address 185 | 10;BYRON;00009F;01;ON; => Byron protocol;address;chime number,command 186 | 10;FA20RF;67f570;1;ON; => Flamingo FA20RF / FA21 / KD101 protocol, address, button number, action (ON/OFF/ALLON/ALLOFF) 187 | 10;MERTIK;64;UP; => Mertik protocol, address, command 188 | 10;RTS;1a602a;0;ON; => RTS protocol, address, command (zero is unused for now) 189 | 10;RTS;1b602b;0123;PAIR; => Pairing for RTS rolling code: RTS protocol, address, rolling code number, PAIR command 190 | 10;RTS;1b602b;0123;0;PAIR; => Extended Pairing for RTS rolling code: RTS protocol, address, rolling code number, eeprom record number, PAIR command 191 | 10;MiLightv1;F746;00;3c00;ON; => Milight v1 protocol;address;button/unit number;color & brightness;command (ON/OFF/ALLON/ALLOFF/DISCO+/DISCO-/MODE0 - MODE8 192 | 10;MiLightv1;F746;01;34BC;PAIR; => Milight v1 protocol;address;button/unit number;color & brightness -not relevant-; PAIR command 193 | 10;MiLightv1;F746;01;34BC;UNPAIR; => Milight v1 protocol;address;button/unit number;color & brightness -not relevant-; UNPAIR command 194 | 10;MiLightv1;F746;01;34BC;BRIGHT; => Milight v1 protocol;address;button/unit number;color & brightness; Set brightness 195 | 10;MiLightv1;F746;01;34BC;COLOR; => Milight v1 protocol;address;button/unit number;color & brightness; Set color 196 | 197 | Device creation using the Echo command (Node 11): 198 | ------------------------------------------------- 199 | RFlink auto detects remote control signals and home automation software should create the device automatically. 200 | However, sometimes the original remote control is not available anymore (broken/lost/etc.) 201 | For this purpose Node 11 commands can be used from the home automation software. 202 | The user can input a command as below: 203 | 204 | 11;20;0B;NewKaku;ID=000005;SWITCH=2;CMD=ON; => 11; is the required node info it can be followed by any custom data which will be echoed 205 | 206 | RFlink will reply with 207 | 20;D3;OK; => Notifying that the command has been received 208 | 20;D4;NewKaku;ID=000005;SWITCH=2;CMD=ON; => sending the data "as if" a remote control button was pressed. 209 | 210 | The home automation software can then process the received command as any other RFlink command. 211 | 212 | More Information: 213 | ----------------- 214 | If you have any questions about the protocol or require assistance with implementing the protocol on home automation software 215 | then just send a mail to frankzirrone@gmail.com 216 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1735563628, 6 | "narHash": "sha256-OnSAY7XDSx7CtDoqNh8jwVwh4xNL/2HaJxGjryLWzX8=", 7 | "owner": "NixOS", 8 | "repo": "nixpkgs", 9 | "rev": "b134951a4c9f3c995fd7be05f3243f8ecd65d798", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "id": "nixpkgs", 14 | "type": "indirect" 15 | } 16 | }, 17 | "root": { 18 | "inputs": { 19 | "nixpkgs": "nixpkgs", 20 | "utils": "utils" 21 | } 22 | }, 23 | "systems": { 24 | "locked": { 25 | "lastModified": 1681028828, 26 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 27 | "owner": "nix-systems", 28 | "repo": "default", 29 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 30 | "type": "github" 31 | }, 32 | "original": { 33 | "owner": "nix-systems", 34 | "repo": "default", 35 | "type": "github" 36 | } 37 | }, 38 | "utils": { 39 | "inputs": { 40 | "systems": "systems" 41 | }, 42 | "locked": { 43 | "lastModified": 1731533236, 44 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 45 | "owner": "numtide", 46 | "repo": "flake-utils", 47 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 48 | "type": "github" 49 | }, 50 | "original": { 51 | "owner": "numtide", 52 | "repo": "flake-utils", 53 | "type": "github" 54 | } 55 | } 56 | }, 57 | "root": "root", 58 | "version": 7 59 | } 60 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | utils.url = "github:numtide/flake-utils"; 4 | }; 5 | outputs = { self, nixpkgs, utils }: utils.lib.eachDefaultSystem (system: 6 | let 7 | pkgs = nixpkgs.legacyPackages.${system}; 8 | in 9 | { 10 | devShell = pkgs.mkShell { 11 | buildInputs = with pkgs; [ 12 | python3Packages.tox 13 | python39 14 | python310 15 | python311 16 | python312 17 | ]; 18 | }; 19 | } 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | target-version = ["py34", "py35", "py36", "py37", "py38"] 3 | 4 | [tool.pyright] 5 | venvPath = "." 6 | venv = ".tox/py311" 7 | -------------------------------------------------------------------------------- /rflink/__init__.py: -------------------------------------------------------------------------------- 1 | # noqa 2 | -------------------------------------------------------------------------------- /rflink/__main__.py: -------------------------------------------------------------------------------- 1 | """Command line interface for rflink library. 2 | 3 | Usage: 4 | rflink [-v | -vv] [options] 5 | rflink [-v | -vv] [options] [--repeat ] (on|off|allon|alloff|up|down|stop|pair) 6 | rflink (-h | --help) 7 | rflink --version 8 | 9 | Options: 10 | -p --port= Serial port to connect to [default: /dev/ttyACM0], 11 | or TCP port in TCP mode. 12 | --baud= Serial baud rate [default: 57600]. 13 | --host= TCP mode, connect to host instead of serial port. 14 | --repeat= How often to repeat a command [default: 1]. 15 | --keepalive= Enable TCP Keepalive IDLE timer in seconds. 16 | -m= How to handle incoming packets [default: event]. 17 | --ignore= List of device ids to ignore, wildcards supported. 18 | -h --help Show this screen. 19 | -v Increase verbosity 20 | --version Show version. 21 | """ 22 | 23 | import asyncio 24 | import logging 25 | import sys 26 | from importlib.metadata import version 27 | from typing import Dict, Optional, Sequence, Type # noqa: unused-import 28 | 29 | from docopt import docopt 30 | 31 | from .protocol import ( # noqa: unused-import 32 | CommandSerialization, 33 | EventHandling, 34 | InverterProtocol, 35 | PacketHandling, 36 | ProtocolBase, 37 | RepeaterProtocol, 38 | RflinkProtocol, 39 | create_rflink_connection, 40 | ) 41 | 42 | PROTOCOLS = { 43 | "command": RflinkProtocol, 44 | "event": EventHandling, 45 | "print": PacketHandling, 46 | "invert": InverterProtocol, 47 | "repeat": RepeaterProtocol, 48 | } # type: Dict[str, Type[ProtocolBase]] 49 | 50 | ALL_COMMANDS = ["on", "off", "allon", "alloff", "up", "down", "stop", "pair"] 51 | 52 | 53 | def main( 54 | argv: Sequence[str] = sys.argv[1:], loop: Optional[asyncio.AbstractEventLoop] = None 55 | ) -> None: 56 | """Parse argument and setup main program loop.""" 57 | args = docopt(__doc__, argv=argv, version=version("rflink")) 58 | 59 | level = logging.ERROR 60 | if args["-v"]: 61 | level = logging.INFO 62 | if args["-v"] == 2: 63 | level = logging.DEBUG 64 | logging.basicConfig(level=level) 65 | 66 | if not loop: 67 | loop = asyncio.get_event_loop() 68 | 69 | if args["--ignore"]: 70 | ignore = args["--ignore"].split(",") 71 | else: 72 | ignore = [] 73 | 74 | command = next((c for c in ALL_COMMANDS if args[c] is True), None) 75 | 76 | if command: 77 | protocol_type = PROTOCOLS["command"] 78 | else: 79 | protocol_type = PROTOCOLS[args["-m"]] 80 | 81 | keepalive = None 82 | if type(args["--keepalive"]) is str: 83 | keepalive = int(args["--keepalive"]) 84 | 85 | conn = create_rflink_connection( 86 | protocol=protocol_type, 87 | host=args["--host"], 88 | port=args["--port"], 89 | baud=args["--baud"], 90 | loop=loop, 91 | ignore=ignore, 92 | keepalive=keepalive, 93 | ) 94 | 95 | transport, protocol = loop.run_until_complete(conn) 96 | 97 | try: 98 | if command: 99 | assert isinstance(protocol, CommandSerialization) 100 | for _ in range(int(args["--repeat"])): 101 | loop.run_until_complete( 102 | protocol.send_command_ack(args[""], command) 103 | ) 104 | else: 105 | loop.run_forever() 106 | except KeyboardInterrupt: 107 | # cleanup connection 108 | transport.close() 109 | loop.run_forever() 110 | finally: 111 | loop.close() 112 | 113 | 114 | if __name__ == "__main__": 115 | # execute only if run as a script 116 | main() 117 | -------------------------------------------------------------------------------- /rflink/parser.py: -------------------------------------------------------------------------------- 1 | """Parsers.""" 2 | 3 | # ./.homeassistant/deps/lib/python/site-packages/rflink/parser.py 4 | # /Library/Frameworks/Python.framework/Versions/3.6//lib/python3.6/site-packages/rflink/parser.py 5 | 6 | import logging 7 | import re 8 | import time 9 | from enum import Enum 10 | from typing import Any, Callable, DefaultDict, Dict, Generator, cast 11 | 12 | log = logging.getLogger(__name__) 13 | 14 | UNKNOWN = "unknown" 15 | SWITCH_COMMAND_TEMPLATE = "{node};{protocol};{id};{switch};{command};" 16 | PACKET_ID_SEP = "_" 17 | 18 | # contruct regex to validate packets before parsing 19 | DELIM = ";" 20 | SEQUENCE = "[0-9a-zA-Z]{2}" 21 | PROTOCOL = "[^;]{3,}" 22 | ADDRESS = "[0-9a-zA-Z]+" 23 | BUTTON = "[0-9a-zA-Z]+" 24 | VALUE = "[0-9a-zA-Z]+" 25 | COMMAND = "[0-9a-zA-Z]+" 26 | CONTROL_COMMAND = "[A-Z]+(=[A-Z0-9]+)?" 27 | DATA = "[a-zA-Z0-9;=_]+" 28 | DEBUG_DATA_RTS = "[a-zA-Z0-9;=_ ]+" 29 | DEBUG_DATA = "[a-zA-Z0-9,;=_()]+" 30 | RESPONSES = "OK" 31 | VERSION = r"[0-9a-zA-Z \.-]+" 32 | DEBUG = "DEBUG" 33 | MESSAGE = r"[0-9a-zA-Z \._-]+" 34 | 35 | # 10;NewKaku;0cac142;3;ON; 36 | PACKET_COMMAND = DELIM.join(["10", PROTOCOL, ADDRESS, BUTTON, COMMAND]) 37 | # 10;MiLightv1;F746;00;3c00;ON; 38 | PACKET_COMMAND2 = DELIM.join(["10", PROTOCOL, ADDRESS, BUTTON, VALUE, COMMAND]) 39 | # 10;MERTIK;64;UP; 40 | PACKET_COMMAND3 = DELIM.join(["10", PROTOCOL, ADDRESS, COMMAND]) 41 | # 10;DELTRONIC;001c33; 42 | PACKET_COMMAND4 = DELIM.join(["10", PROTOCOL, ADDRESS]) 43 | 44 | # 10;REBOOT;/10;RTSRECCLEAN=9; 45 | PACKET_CONTROL = DELIM.join(["10", CONTROL_COMMAND]) 46 | 47 | # 20;D3;OK; 48 | PACKET_RESPONSE = DELIM.join(["20", SEQUENCE, RESPONSES]) 49 | # 20;06;NewKaku;ID=008440e6;SWITCH=a;CMD=OFF; 50 | PACKET_DEVICE = DELIM.join(["20", SEQUENCE, PROTOCOL, DATA]) 51 | # 20;00;Nodo RadioFrequencyLink - RFLink Gateway V1.1 - R46; 52 | PACKET_VERSION = DELIM.join(["20", SEQUENCE, VERSION]) 53 | # 20;00;Internal Pullup on RF-in disabled 54 | PACKET_INFO = DELIM.join(["20", SEQUENCE, MESSAGE]) 55 | 56 | # 20;75;DEBUG;Pulses=90;Pulses(uSec)=1200,2760,120... 57 | PACKET_DEBUG = DELIM.join(["20", SEQUENCE, DEBUG, DEBUG_DATA]) 58 | 59 | # 20;01;RFUDEBUG=OFF; 60 | PACKET_RFDEBUGN = DELIM.join(["20", SEQUENCE, "RFDEBUG=ON"]) 61 | PACKET_RFDEBUGF = DELIM.join(["20", SEQUENCE, "RFDEBUG=OFF"]) 62 | PACKET_RFUDEBUGN = DELIM.join(["20", SEQUENCE, "RFUDEBUG=ON"]) 63 | PACKET_RFUDEBUGF = DELIM.join(["20", SEQUENCE, "RFUDEBUG=OFF"]) 64 | PACKET_QRFDEBUGN = DELIM.join(["20", SEQUENCE, "QRFDEBUG=ON"]) 65 | PACKET_QRFDEBUGF = DELIM.join(["20", SEQUENCE, "QRFDEBUG=OFF"]) 66 | 67 | PACKET_GPIOON = DELIM.join(["20", SEQUENCE, "setGPIO=ON"]) 68 | PACKET_GPIOOFF = DELIM.join(["20", SEQUENCE, "setGPIO=OFF"]) 69 | 70 | # 20;84;Debug;RTS P1;a63f33003cf000665a5a; 71 | PACKET_DEBUGRTS = DELIM.join(["20", SEQUENCE, "Debug", DEBUG_DATA_RTS]) 72 | 73 | # 11;20;0B;NewKaku;ID=000005;SWITCH=2;CMD=ON; 74 | PACKET_DEVICE_CREATE = "11;" + PACKET_DEVICE 75 | 76 | PACKET_HEADER_RE = ( 77 | "^(" 78 | + "|".join( 79 | [ 80 | PACKET_VERSION, 81 | PACKET_DEVICE_CREATE, 82 | PACKET_RESPONSE, 83 | PACKET_DEVICE, 84 | PACKET_COMMAND, 85 | PACKET_COMMAND2, 86 | PACKET_COMMAND3, 87 | PACKET_COMMAND4, 88 | PACKET_CONTROL, 89 | PACKET_DEBUG, 90 | PACKET_INFO, 91 | PACKET_RFDEBUGN, 92 | PACKET_RFUDEBUGN, 93 | PACKET_RFDEBUGF, 94 | PACKET_RFUDEBUGF, 95 | PACKET_QRFDEBUGN, 96 | PACKET_QRFDEBUGF, 97 | PACKET_GPIOOFF, 98 | PACKET_GPIOON, 99 | PACKET_DEBUGRTS, 100 | ] 101 | ) 102 | + ");$" 103 | ) 104 | packet_header_re = re.compile(PACKET_HEADER_RE) 105 | 106 | PacketType = Dict[str, Any] 107 | 108 | 109 | class PacketHeader(Enum): 110 | """Packet source identification.""" 111 | 112 | master = "10" 113 | echo = "11" 114 | gateway = "20" 115 | 116 | 117 | PACKET_FIELDS = { 118 | "awinsp": "average_windspeed", 119 | "baro": "barometric_pressure", 120 | "bat": "battery", 121 | "bforecast": "weather_forecast", 122 | "chime": "doorbell_melody", 123 | "cmd": "command", 124 | "co2": "co2_air_quality", 125 | "current": "current_phase_1", 126 | "current2": "current_phase_2", 127 | "current3": "current_phase_3", 128 | "dist": "distance", 129 | "fw": "firmware", 130 | "hstatus": "humidity_status", 131 | "hum": "humidity", 132 | "hw": "hardware", 133 | "kwatt": "kilowatt", 134 | "lux": "light_intensity", 135 | "meter": "meter_value", 136 | "rain": "total_rain", 137 | "rainrate": "rain_rate", 138 | "raintot": "total_rain", 139 | "rev": "revision", 140 | "sound": "noise_level", 141 | "temp": "temperature", 142 | "uv": "uv_intensity", 143 | "ver": "version", 144 | "volt": "voltage", 145 | "watt": "watt", 146 | "winchl": "windchill", 147 | "wind": "windspeed", 148 | "windir": "winddirection", 149 | "wings": "windgusts", 150 | "winsp": "windspeed", 151 | "wintmp": "windtemp", 152 | } 153 | 154 | UNITS = { 155 | "awinsp": "km/h", 156 | # depends on sensor 157 | "baro": None, 158 | "bat": None, 159 | "bforecast": None, 160 | "chime": None, 161 | "cmd": None, 162 | "co2": None, 163 | "current": "A", 164 | "current2": "A", 165 | "current3": "A", 166 | # depends on sensor 167 | "dist": None, 168 | "fw": None, 169 | "hstatus": None, 170 | "hum": "%", 171 | "hw": None, 172 | "kwatt": "kW", 173 | "lux": "lux", 174 | # depends on sensor 175 | "meter": None, 176 | "rain": "mm", 177 | "rainrate": "mm", 178 | "raintot": "mm", 179 | "rev": None, 180 | # unknown, might be dB? 181 | "sound": None, 182 | # might be °F, but default to something 183 | "temp": "°C", 184 | "uv": None, 185 | "ver": None, 186 | "volt": "v", 187 | "watt": "w", 188 | "winchl": "°C", 189 | "wind": "km/h", 190 | "windir": "°", 191 | "wings": "km/h", 192 | "winsp": "km/h", 193 | "wintmp": "°C", 194 | } 195 | 196 | HSTATUS_LOOKUP = { 197 | "0": "normal", 198 | "1": "comfortable", 199 | "2": "dry", 200 | "3": "wet", 201 | } 202 | BFORECAST_LOOKUP = { 203 | "0": "no_info", 204 | "1": "sunny", 205 | "2": "partly_cloudy", 206 | "3": "cloudy", 207 | "4": "rain", 208 | } 209 | 210 | 211 | def signed_to_float(hex: str) -> float: 212 | """Convert signed hexadecimal to floating value.""" 213 | if int(hex, 16) & 0x8000: 214 | return -(int(hex, 16) & 0x7FFF) / 10 215 | else: 216 | return int(hex, 16) / 10 217 | 218 | 219 | VALUE_TRANSLATION = cast( 220 | Dict[str, Callable[[str], str]], 221 | { 222 | "awinsp": lambda hex: int(hex, 16) / 10, 223 | "baro": lambda hex: int(hex, 16), 224 | "bforecast": lambda x: BFORECAST_LOOKUP.get(x, "Unknown"), 225 | "chime": int, 226 | "co2": int, 227 | "current": int, 228 | "current2": int, 229 | "current3": int, 230 | "dist": int, 231 | "hstatus": lambda x: HSTATUS_LOOKUP.get(x, "Unknown"), 232 | "hum": int, 233 | "kwatt": lambda hex: int(hex, 16), 234 | "lux": lambda hex: int(hex, 16), 235 | "meter": int, 236 | "rain": lambda hex: int(hex, 16) / 10, 237 | "rainrate": lambda hex: int(hex, 16) / 10, 238 | "raintot": lambda hex: int(hex, 16) / 10, 239 | "sound": int, 240 | "temp": signed_to_float, 241 | "uv": lambda hex: int(hex, 16), 242 | "volt": int, 243 | "watt": lambda hex: int(hex, 16), 244 | "winchl": signed_to_float, 245 | "windir": lambda windir: int(windir) * 22.5, 246 | "wings": lambda hex: int(hex, 16) / 10, 247 | "winsp": lambda hex: int(hex, 16) / 10, 248 | "wintmp": signed_to_float, 249 | }, 250 | ) 251 | 252 | 253 | BANNER_RE = ( 254 | r"(?P[a-zA-Z\s]+) - (?P[a-zA-Z\s]+) " 255 | r"V(?P[0-9\.]+) - R(?P[0-9\.]+)" 256 | ) 257 | 258 | 259 | def valid_packet(packet: str) -> bool: 260 | """Verify if packet is valid. 261 | 262 | >>> valid_packet('20;08;UPM/Esic;ID=1003;RAIN=0010;BAT=OK;') 263 | True 264 | >>> # invalid packet due to leftovers in serial buffer 265 | >>> valid_packet('20;00;N20;00;Nodo RadioFrequencyLink - RFLink Gateway V1.1 - R45') 266 | False 267 | """ 268 | return bool(packet_header_re.match(packet)) 269 | 270 | 271 | def decode_packet(packet: str) -> PacketType: 272 | """Break packet down into primitives, and do basic interpretation. 273 | 274 | >>> decode_packet('20;06;Kaku;ID=41;SWITCH=1;CMD=ON;') == { 275 | ... 'node': 'gateway', 276 | ... 'protocol': 'kaku', 277 | ... 'id': '000041', 278 | ... 'switch': '1', 279 | ... 'command': 'on', 280 | ... } 281 | True 282 | """ 283 | node_id, _, protocol, attrs = packet.split(DELIM, 3) 284 | 285 | data = cast(PacketType, {"node": PacketHeader(node_id).name}) 286 | 287 | # make exception for version response 288 | data["protocol"] = UNKNOWN 289 | if "=" in protocol: 290 | attrs = protocol + DELIM + attrs 291 | 292 | # no attributes but instead the welcome banner 293 | elif "RFLink Gateway" in protocol: 294 | data.update(parse_banner(protocol)) 295 | 296 | elif protocol == "PONG": 297 | data["ping"] = protocol.lower() 298 | 299 | # debug response 300 | elif protocol.lower() == "debug": 301 | data["protocol"] = protocol.lower() 302 | if attrs.startswith("RTS P1"): 303 | data["rts_p1"] = attrs.strip(DELIM).split(DELIM)[1] 304 | else: 305 | data["tm"] = packet[3:5] 306 | 307 | # failure response 308 | elif protocol == "CMD UNKNOWN": 309 | data["response"] = "command_unknown" 310 | data["ok"] = False 311 | 312 | # ok response 313 | elif protocol == "OK": 314 | data["ok"] = True 315 | 316 | # generic message from gateway 317 | elif node_id == "20" and not attrs: 318 | data["message"] = protocol 319 | 320 | # its a regular packet 321 | else: 322 | data["protocol"] = protocol.lower() 323 | 324 | # convert key=value pairs where needed 325 | for attr in filter(None, attrs.strip(DELIM).split(DELIM)): 326 | if "=" not in attr: 327 | continue 328 | key, value = attr.lower().split("=", 1) 329 | if key in VALUE_TRANSLATION: 330 | try: 331 | value = VALUE_TRANSLATION[key](value) 332 | except ValueError: 333 | log.warning( 334 | "Could not convert attr '%s' value '%s' to expected type '%s'", 335 | key, 336 | value, 337 | VALUE_TRANSLATION[key].__name__, 338 | ) 339 | continue 340 | name = PACKET_FIELDS.get(key, key) 341 | data[name] = value 342 | unit = UNITS.get(key, None) 343 | 344 | if unit: 345 | data[name + "_unit"] = unit 346 | 347 | # correct KaKu device address 348 | if data.get("protocol", "") == "kaku" and len(data["id"]) != 6: 349 | data["id"] = "0000" + data["id"] 350 | 351 | return data 352 | 353 | 354 | def parse_banner(banner: str) -> Dict[str, str]: 355 | """Extract hardware/firmware name and version from banner.""" 356 | match = re.match(BANNER_RE, banner) 357 | return match.groupdict() if match else {} 358 | 359 | 360 | def encode_packet(packet: PacketType) -> str: 361 | """Construct packet string from packet dictionary. 362 | 363 | >>> encode_packet({ 364 | ... 'protocol': 'newkaku', 365 | ... 'id': '000001', 366 | ... 'switch': '01', 367 | ... 'command': 'on', 368 | ... }) 369 | '10;newkaku;000001;01;on;' 370 | """ 371 | if packet["protocol"] == "rfdebug": 372 | return "10;RFDEBUG=%s;" % packet["command"] 373 | elif packet["protocol"] == "rfudebug": 374 | return "10;RFUDEBUG=%s;" % packet["command"] 375 | elif packet["protocol"] == "qrfdebug": 376 | return "10;QRFDEBUG=%s;" % packet["command"] 377 | else: 378 | return SWITCH_COMMAND_TEMPLATE.format(node=PacketHeader.master.value, **packet) 379 | 380 | 381 | # create lookup table of not easy to reverse protocol names 382 | translate_protocols = [ 383 | "Ikea Koppla", 384 | "Alecto V1", 385 | "Alecto V2", 386 | "UPM/Esic", 387 | "Oregon TempHygro", 388 | "Oregon TempHygro", 389 | "Oregon BTHR", 390 | "Oregon Rain", 391 | "Oregon Rain2", 392 | "Oregon Wind", 393 | "Oregon Wind2", 394 | "Oregon UVN128/138", 395 | "Plieger York", 396 | "Byron SX", 397 | "CAME-TOP432", 398 | ] 399 | 400 | 401 | class TranslationsDict(DefaultDict[str, str]): 402 | """Generate translations for Rflink protocols to serializable names.""" 403 | 404 | def __missing__(self, key: str) -> str: 405 | """If translation does not exist yet add it and its reverse.""" 406 | value = re.sub(r"[^a-z0-9_]+", "", key.lower()) 407 | self[key.lower()] = value 408 | self[value] = key.lower() 409 | return value 410 | 411 | 412 | protocol_translations = TranslationsDict(None) 413 | [protocol_translations[protocol] for protocol in translate_protocols] 414 | 415 | 416 | def serialize_packet_id(packet: PacketType) -> str: 417 | """Serialize packet identifiers into one reversible string. 418 | 419 | >>> serialize_packet_id({ 420 | ... 'protocol': 'newkaku', 421 | ... 'id': '000001', 422 | ... 'switch': '01', 423 | ... 'command': 'on', 424 | ... }) 425 | 'newkaku_000001_01' 426 | >>> serialize_packet_id({ 427 | ... 'protocol': 'ikea koppla', 428 | ... 'id': '000080', 429 | ... 'switch': '0', 430 | ... 'command': 'on', 431 | ... }) 432 | 'ikeakoppla_000080_0' 433 | >>> # unserializeable protocol name without explicit entry 434 | >>> # in translation table should be properly serialized 435 | >>> serialize_packet_id({ 436 | ... 'protocol': 'alecto v4', 437 | ... 'id': '000080', 438 | ... 'switch': '0', 439 | ... 'command': 'on', 440 | ... }) 441 | 'alectov4_000080_0' 442 | """ 443 | # translate protocol into something reversible 444 | protocol = protocol_translations[packet["protocol"]] 445 | 446 | if protocol == UNKNOWN: 447 | protocol = "rflink" 448 | 449 | return "_".join( 450 | filter(None, [protocol, packet.get("id", None), packet.get("switch", None)]) 451 | ) 452 | 453 | 454 | def deserialize_packet_id(packet_id: str) -> Dict[str, str]: 455 | r"""Turn a packet id into individual packet components. 456 | 457 | >>> deserialize_packet_id('newkaku_000001_01') == { 458 | ... 'protocol': 'newkaku', 459 | ... 'id': '000001', 460 | ... 'switch': '01', 461 | ... } 462 | True 463 | >>> deserialize_packet_id('ikeakoppla_000080_0') == { 464 | ... 'protocol': 'ikea koppla', 465 | ... 'id': '000080', 466 | ... 'switch': '0', 467 | ... } 468 | True 469 | >>> deserialize_packet_id('dooya_v4_6d5f8e00_3f') == { 470 | ... 'protocol': 'dooya_v4', 471 | ... 'id': '6d5f8e00', 472 | ... 'switch': '3f', 473 | ... } 474 | True 475 | >>> deserialize_packet_id('mertik_gv60_038527_13') == { 476 | ... 'protocol': 'mertik_gv60', 477 | ... 'id': '038527', 478 | ... 'switch': '13', 479 | ... } 480 | True 481 | """ 482 | if packet_id == "rflink": 483 | return {"protocol": UNKNOWN} 484 | 485 | # Protocol names can contain underscores themselves (like: dooya_v4), using rsplit to 486 | # prevent parsing issues with these kind of packets. 487 | protocol, *id_switch = packet_id.rsplit(PACKET_ID_SEP, 2) 488 | 489 | packet_identifiers = { 490 | # lookup the reverse translation of the protocol in the translation 491 | # table, fallback to protocol. If this is an unserializable protocol 492 | # name, it has not been serialized before and is not in the 493 | # translate_protocols table this will result in an invalid command. 494 | "protocol": protocol_translations.get(protocol, protocol), 495 | } 496 | if id_switch: 497 | packet_identifiers["id"] = id_switch[0] 498 | if len(id_switch) > 1: 499 | packet_identifiers["switch"] = id_switch[1] 500 | 501 | return packet_identifiers 502 | 503 | 504 | def packet_events(packet: PacketType) -> Generator[PacketType, None, None]: 505 | """Return list of all events in the packet. 506 | 507 | >>> x = list(packet_events({ 508 | ... 'protocol': 'alecto v1', 509 | ... 'id': 'ec02', 510 | ... 'temperature': 1.0, 511 | ... 'temperature_unit': '°C', 512 | ... 'humidity': 10, 513 | ... 'humidity_unit': '%', 514 | ... })) 515 | >>> assert { 516 | ... 'id': 'alectov1_ec02_temp', 517 | ... 'sensor': 'temperature', 518 | ... 'value': 1.0, 519 | ... 'unit': '°C', 520 | ... } in x 521 | >>> assert { 522 | ... 'id': 'alectov1_ec02_hum', 523 | ... 'sensor': 'humidity', 524 | ... 'value': 10, 525 | ... 'unit': '%', 526 | ... } in x 527 | >>> y = list(packet_events({ 528 | ... 'protocol': 'newkaku', 529 | ... 'id': '000001', 530 | ... 'switch': '01', 531 | ... 'command': 'on', 532 | ... })) 533 | >>> assert {'id': 'newkaku_000001_01', 'command': 'on'} in y 534 | """ 535 | field_abbrev = { 536 | v: k 537 | for k, v in sorted( 538 | PACKET_FIELDS.items(), key=lambda x: (x[1], x[0]), reverse=True 539 | ) 540 | } 541 | 542 | packet_id = serialize_packet_id(packet) 543 | events = {f: v for f, v in packet.items() if f in field_abbrev} 544 | if "command" in events or "version" in events: 545 | # switch events only have one event in each packet 546 | yield dict(id=packet_id, **events) 547 | else: 548 | if packet_id == "debug": 549 | yield { 550 | "id": "raw", 551 | "value": packet.get("pulses(usec)"), 552 | "tm": packet.get("tm"), 553 | "pulses": packet.get("pulses"), 554 | } 555 | else: 556 | # sensors can have multiple 557 | for sensor, value in events.items(): 558 | unit = packet.get(sensor + "_unit", None) 559 | yield { 560 | "id": packet_id + PACKET_ID_SEP + field_abbrev[sensor], 561 | "sensor": sensor, 562 | "value": value, 563 | "unit": unit, 564 | } 565 | 566 | if packet_id != "rflink": 567 | yield { 568 | "id": packet_id + PACKET_ID_SEP + "update_time", 569 | "sensor": "update_time", 570 | "value": round(time.time()), 571 | "unit": "s", 572 | } 573 | -------------------------------------------------------------------------------- /rflink/protocol.py: -------------------------------------------------------------------------------- 1 | """Asyncio protocol implementation of RFlink.""" 2 | 3 | # ./.homeassistant/deps/lib/python/site-packages/rflink/protocol.py 4 | # /Library/Frameworks/Python.framework/Versions/3.6//lib/python3.6/site-packages/rflink/protocol.py 5 | 6 | import asyncio 7 | import concurrent 8 | import logging 9 | import socket 10 | from datetime import timedelta 11 | from fnmatch import fnmatchcase 12 | from functools import partial 13 | from typing import ( 14 | TYPE_CHECKING, 15 | Any, 16 | Callable, 17 | Optional, 18 | Sequence, 19 | Type, 20 | Union, 21 | cast, 22 | overload, 23 | ) 24 | 25 | from serial_asyncio_fast import create_serial_connection 26 | 27 | from .parser import ( 28 | PacketType, 29 | decode_packet, 30 | deserialize_packet_id, 31 | encode_packet, 32 | packet_events, 33 | valid_packet, 34 | ) 35 | 36 | if TYPE_CHECKING: 37 | from typing import Coroutine # not available in 3.4 38 | 39 | 40 | log = logging.getLogger(__name__) 41 | rflink_log = None 42 | 43 | TIMEOUT = timedelta(seconds=5) 44 | DEFAULT_TCP_KEEPALIVE_INTERVAL = 20 45 | DEFAULT_TCP_KEEPALIVE_COUNT = 3 46 | 47 | 48 | class ProtocolBase(asyncio.Protocol): 49 | """Manage low level rflink protocol.""" 50 | 51 | transport = None # type: asyncio.BaseTransport 52 | keepalive = None # type: Optional[int] 53 | 54 | def __init__( 55 | self, 56 | loop: Optional[asyncio.AbstractEventLoop] = None, 57 | disconnect_callback: Optional[Callable[[Optional[Exception]], None]] = None, 58 | keepalive: Optional[int] = None, 59 | **kwargs: Any, 60 | ) -> None: 61 | """Initialize class.""" 62 | if loop: 63 | self.loop = loop 64 | else: 65 | self.loop = asyncio.get_event_loop() 66 | self.packet = "" 67 | self.buffer = "" 68 | self.packet_callback = None # type: Optional[Callable[[PacketType], None]] 69 | self.disconnect_callback = disconnect_callback 70 | self.keepalive = keepalive 71 | 72 | def connection_made(self, transport: asyncio.BaseTransport) -> None: 73 | """Just logging for now.""" 74 | self.transport = transport 75 | log.debug("connected") 76 | sock = transport.get_extra_info("socket") 77 | if self.keepalive is not None and socket is not None: 78 | log.debug( 79 | "applying TCP KEEPALIVE settings: IDLE={}/INTVL={}/CNT={}".format( 80 | self.keepalive, 81 | DEFAULT_TCP_KEEPALIVE_INTERVAL, 82 | DEFAULT_TCP_KEEPALIVE_COUNT, 83 | ) 84 | ) 85 | if hasattr(socket, "SO_KEEPALIVE"): 86 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) 87 | if hasattr(socket, "TCP_KEEPIDLE"): 88 | sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, self.keepalive) 89 | if hasattr(socket, "TCP_KEEPINTVL"): 90 | sock.setsockopt( 91 | socket.IPPROTO_TCP, 92 | socket.TCP_KEEPINTVL, 93 | DEFAULT_TCP_KEEPALIVE_INTERVAL, 94 | ) 95 | if hasattr(socket, "TCP_KEEPCNT"): 96 | sock.setsockopt( 97 | socket.IPPROTO_TCP, socket.TCP_KEEPCNT, DEFAULT_TCP_KEEPALIVE_COUNT 98 | ) 99 | 100 | def data_received(self, data: bytes) -> None: 101 | """Add incoming data to buffer.""" 102 | try: 103 | decoded_data = data.decode() 104 | except UnicodeDecodeError: 105 | invalid_data = data.decode(errors="replace") 106 | log.warning("Error during decode of data, invalid data: %s", invalid_data) 107 | else: 108 | log.debug("received data: %s", decoded_data.strip()) 109 | self.buffer += decoded_data 110 | self.handle_lines() 111 | 112 | def handle_lines(self) -> None: 113 | """Assemble incoming data into per-line packets.""" 114 | while "\r\n" in self.buffer: 115 | line, self.buffer = self.buffer.split("\r\n", 1) 116 | if valid_packet(line): 117 | self.handle_raw_packet(line) 118 | else: 119 | log.warning("dropping invalid data: %s", line) 120 | 121 | def handle_raw_packet(self, raw_packet: str) -> None: 122 | """Handle one raw incoming packet.""" 123 | raise NotImplementedError() 124 | 125 | def send_raw_packet(self, packet: str) -> None: 126 | """Encode and put packet string onto write buffer.""" 127 | data = packet + "\r\n" 128 | log.debug("writing data: %s", repr(data)) 129 | # type ignore: transport from create_connection is documented to be 130 | # implementation specific bidirectional, even though typed as 131 | # BaseTransport 132 | self.transport.write(data.encode()) # type: ignore 133 | 134 | def log_all(self, file: Optional[str]) -> None: 135 | """Log all data received from RFLink to file.""" 136 | global rflink_log 137 | if file is None: 138 | rflink_log = None 139 | else: 140 | log.debug("logging to: %s", file) 141 | rflink_log = open(file, "a") 142 | 143 | def connection_lost(self, exc: Optional[Exception]) -> None: 144 | """Log when connection is closed, if needed call callback.""" 145 | if exc: 146 | log.exception("disconnected due to exception") 147 | else: 148 | log.info("disconnected because of close/abort.") 149 | if self.disconnect_callback: 150 | self.disconnect_callback(exc) 151 | 152 | 153 | class PacketHandling(ProtocolBase): 154 | """Handle translating rflink packets to/from python primitives.""" 155 | 156 | def __init__( 157 | self, 158 | *args: Any, 159 | packet_callback: Optional[Callable[[PacketType], None]] = None, 160 | **kwargs: Any, 161 | ) -> None: 162 | """Add packethandling specific initialization. 163 | 164 | packet_callback: called with every complete/valid packet 165 | received. 166 | """ 167 | super().__init__(*args, **kwargs) 168 | if packet_callback: 169 | self.packet_callback = packet_callback 170 | 171 | def handle_raw_packet(self, raw_packet: str) -> None: 172 | """Parse raw packet string into packet dict.""" 173 | log.debug("got packet: %s", raw_packet) 174 | if rflink_log: 175 | print(raw_packet, file=rflink_log) 176 | rflink_log.flush() 177 | packet = None # type: Optional[PacketType] 178 | try: 179 | packet = decode_packet(raw_packet) 180 | except BaseException: 181 | log.exception("failed to parse packet data: %s", raw_packet) 182 | 183 | log.debug("decoded packet: %s", packet) 184 | 185 | if packet: 186 | if "ok" in packet: 187 | # handle response packets internally 188 | log.debug("command response: %s", packet) 189 | self.handle_response_packet(packet) 190 | else: 191 | self.handle_packet(packet) 192 | else: 193 | log.warning("no valid packet") 194 | 195 | def handle_packet(self, packet: PacketType) -> None: 196 | """Process incoming packet dict and optionally call callback.""" 197 | if self.packet_callback: 198 | # forward to callback 199 | self.packet_callback(packet) 200 | else: 201 | print("packet", packet) 202 | 203 | def handle_response_packet(self, packet: PacketType) -> None: 204 | """Handle response packet.""" 205 | raise NotImplementedError() 206 | 207 | def send_packet(self, fields: PacketType) -> None: 208 | """Concat fields and send packet to gateway.""" 209 | self.send_raw_packet(encode_packet(fields)) 210 | 211 | def send_command(self, device_id: str, action: str) -> None: 212 | """Send device command to rflink gateway.""" 213 | command = deserialize_packet_id(device_id) 214 | command["command"] = action 215 | log.debug("sending command: %s", command) 216 | self.send_packet(command) 217 | 218 | 219 | class CommandSerialization(PacketHandling): 220 | """Logic for ensuring asynchronous commands are sent in order.""" 221 | 222 | def __init__( 223 | self, 224 | *args: Any, 225 | packet_callback: Optional[Callable[[PacketType], None]] = None, 226 | **kwargs: Any, 227 | ) -> None: 228 | """Add packethandling specific initialization.""" 229 | super().__init__(*args, **kwargs) 230 | if packet_callback: 231 | self.packet_callback = packet_callback 232 | self._command_ack = asyncio.Event() 233 | self._ready_to_send = asyncio.Lock() 234 | 235 | def handle_response_packet(self, packet: PacketType) -> None: 236 | """Handle response packet.""" 237 | self._last_ack = packet 238 | self._command_ack.set() 239 | 240 | async def send_command_ack(self, device_id: str, action: str) -> "bool | None": 241 | """Send command, wait for gateway to repond with acknowledgment.""" 242 | # serialize commands 243 | await self._ready_to_send.acquire() 244 | acknowledgement = None 245 | try: 246 | self._command_ack.clear() 247 | self.send_command(device_id, action) 248 | 249 | log.debug("waiting for acknowledgement") 250 | try: 251 | await asyncio.wait_for(self._command_ack.wait(), TIMEOUT.seconds) 252 | log.debug("packet acknowledged") 253 | except concurrent.futures._base.TimeoutError: 254 | acknowledgement = False 255 | log.warning("acknowledge timeout") 256 | else: 257 | acknowledgement = cast(bool, self._last_ack.get("ok", False)) 258 | finally: 259 | # allow next command 260 | self._ready_to_send.release() 261 | 262 | return acknowledgement 263 | 264 | 265 | class EventHandling(PacketHandling): 266 | """Breaks up packets into individual events with ids'. 267 | 268 | Most packets represent a single event (light on, measured 269 | temperature), but some contain multiple events (temperature and 270 | humidity). This class adds logic to convert packets into individual 271 | events each with their own id based on packet details (protocol, 272 | switch, etc). 273 | """ 274 | 275 | def __init__( 276 | self, 277 | *args: Any, 278 | event_callback: Optional[Callable[[PacketType], None]] = None, 279 | ignore: Optional[Sequence[str]] = None, 280 | **kwargs: Any, 281 | ) -> None: 282 | """Add eventhandling specific initialization.""" 283 | super().__init__(*args, **kwargs) 284 | self.event_callback = event_callback 285 | # suppress printing of packets 286 | if not kwargs.get("packet_callback"): 287 | self.packet_callback = lambda x: None 288 | if ignore: 289 | log.debug("ignoring: %s", ignore) 290 | self.ignore = ignore 291 | else: 292 | self.ignore = [] 293 | 294 | def _handle_packet(self, packet: PacketType) -> None: 295 | """Event specific packet handling logic. 296 | 297 | Break packet into events and fires configured event callback or 298 | nicely prints events for console. 299 | """ 300 | events = packet_events(packet) 301 | 302 | for event in events: 303 | if self.ignore_event(event["id"]): 304 | log.debug("ignoring event with id: %s", event) 305 | continue 306 | log.debug("got event: %s", event) 307 | if self.event_callback: 308 | self.event_callback(event) 309 | else: 310 | self.handle_event(event) 311 | 312 | def handle_event(self, event: PacketType) -> None: 313 | """Handle of incoming event (print).""" 314 | string = "{id:<32} " 315 | if "command" in event: 316 | string += "{command}" 317 | elif "version" in event: 318 | if "hardware" in event: 319 | string += "{hardware} {firmware} " 320 | string += "V{version} R{revision}" 321 | else: 322 | string += "{value}" 323 | if event.get("unit"): 324 | string += " {unit}" 325 | 326 | print(string.format(**event)) 327 | 328 | def handle_packet(self, packet: PacketType) -> None: 329 | """Apply event specific handling and pass on to packet handling.""" 330 | self._handle_packet(packet) 331 | super().handle_packet(packet) 332 | 333 | def ignore_event(self, event_id: str) -> bool: 334 | """Verify event id against list of events to ignore. 335 | 336 | >>> e = EventHandling(ignore=[ 337 | ... 'test1_00', 338 | ... 'test2_*', 339 | ... ]) 340 | >>> e.ignore_event('test1_00') 341 | True 342 | >>> e.ignore_event('test2_00') 343 | True 344 | >>> e.ignore_event('test3_00') 345 | False 346 | """ 347 | for ignore in self.ignore: 348 | if fnmatchcase(event_id, ignore): 349 | return True 350 | return False 351 | 352 | 353 | class RflinkProtocol(CommandSerialization, EventHandling): 354 | """Combine preferred abstractions that form complete Rflink interface.""" 355 | 356 | 357 | class InverterProtocol(RflinkProtocol): 358 | """Invert switch commands received and send them out.""" 359 | 360 | def handle_event(self, event: PacketType) -> None: 361 | """Handle incoming packet from rflink gateway.""" 362 | if event.get("command"): 363 | if event["command"] == "on": 364 | cmd = "off" 365 | else: 366 | cmd = "on" 367 | 368 | task = self.send_command_ack(event["id"], cmd) 369 | self.loop.create_task(task) 370 | 371 | 372 | class RepeaterProtocol(RflinkProtocol): 373 | """Repeat switch commands received.""" 374 | 375 | def handle_event(self, packet: PacketType) -> None: 376 | """Handle incoming packet from rflink gateway.""" 377 | if packet.get("command"): 378 | task = self.send_command_ack(packet["id"], packet["command"]) 379 | self.loop.create_task(task) 380 | 381 | 382 | @overload 383 | def create_rflink_connection( 384 | port: int, 385 | host: str, 386 | baud: int = 57600, 387 | keepalive: Optional[int] = None, 388 | protocol: Type[ProtocolBase] = RflinkProtocol, 389 | packet_callback: Optional[Callable[[PacketType], None]] = None, 390 | event_callback: Optional[Callable[[PacketType], None]] = None, 391 | disconnect_callback: Optional[Callable[[Optional[Exception]], None]] = None, 392 | ignore: Optional[Sequence[str]] = None, 393 | loop: Optional[asyncio.AbstractEventLoop] = None, 394 | ) -> "Coroutine[Any, Any, tuple[asyncio.Transport, asyncio.Protocol]]": 395 | """Create Rflink manager class, returns transport coroutine.""" 396 | ... 397 | 398 | 399 | @overload 400 | def create_rflink_connection( 401 | port: str, 402 | host: None = None, 403 | baud: int = 57600, 404 | keepalive: None = None, 405 | protocol: Type[ProtocolBase] = RflinkProtocol, 406 | packet_callback: Optional[Callable[[PacketType], None]] = None, 407 | event_callback: Optional[Callable[[PacketType], None]] = None, 408 | disconnect_callback: Optional[Callable[[Optional[Exception]], None]] = None, 409 | ignore: Optional[Sequence[str]] = None, 410 | loop: Optional[asyncio.AbstractEventLoop] = None, 411 | ) -> "Coroutine[Any, Any, tuple[asyncio.Transport, asyncio.Protocol]]": 412 | """Create Rflink manager class, returns transport coroutine.""" 413 | ... 414 | 415 | 416 | def create_rflink_connection( 417 | port: Union[None, str, int] = None, 418 | host: Optional[str] = None, 419 | baud: int = 57600, 420 | keepalive: Optional[int] = None, 421 | protocol: Type[ProtocolBase] = RflinkProtocol, 422 | packet_callback: Optional[Callable[[PacketType], None]] = None, 423 | event_callback: Optional[Callable[[PacketType], None]] = None, 424 | disconnect_callback: Optional[Callable[[Optional[Exception]], None]] = None, 425 | ignore: Optional[Sequence[str]] = None, 426 | loop: Optional[asyncio.AbstractEventLoop] = None, 427 | ) -> "Coroutine[Any, Any, tuple[asyncio.Transport, asyncio.Protocol]]": 428 | """Create Rflink manager class, returns transport coroutine.""" 429 | if loop is None: 430 | loop = asyncio.get_event_loop() 431 | # use default protocol if not specified 432 | protocol_factory = partial( 433 | protocol, 434 | loop=loop, 435 | packet_callback=packet_callback, 436 | event_callback=event_callback, 437 | disconnect_callback=disconnect_callback, 438 | ignore=ignore if ignore else [], 439 | keepalive=keepalive, 440 | ) 441 | 442 | conn: Coroutine[Any, Any, tuple[asyncio.Transport, asyncio.Protocol]] 443 | 444 | # setup serial connection if no transport specified 445 | if host: 446 | conn = loop.create_connection(protocol_factory, host, cast(int, port)) 447 | else: 448 | conn = create_serial_connection(loop, protocol_factory, str(port), baud) 449 | 450 | return conn 451 | -------------------------------------------------------------------------------- /rflink/py.typed: -------------------------------------------------------------------------------- 1 | # See PEP 561 2 | -------------------------------------------------------------------------------- /rflinkproxy/__init__.py: -------------------------------------------------------------------------------- 1 | # noqa 2 | -------------------------------------------------------------------------------- /rflinkproxy/__main__.py: -------------------------------------------------------------------------------- 1 | """Command line interface for rflink proxy. 2 | 3 | Usage: 4 | rflinkproxy [-v | -vv] [options] 5 | rflinkproxy (-h | --help) 6 | rflinkproxy --version 7 | 8 | Options: 9 | --listenport= Port to listen on [default: 1337] 10 | --port= Serial port to connect to [default: /dev/ttyACM0], 11 | or TCP port in TCP mode. 12 | --baud= Serial baud rate [default: 57600]. 13 | --host= TCP mode, connect to host instead of serial port. 14 | --repeat= How often to repeat a command [default: 1]. 15 | -h --help Show this screen. 16 | -v Increase verbosity 17 | --version Show version. 18 | """ 19 | 20 | import asyncio 21 | import logging 22 | import sys 23 | from functools import partial 24 | from importlib.metadata import version 25 | from typing import Any, Callable, Dict, cast 26 | 27 | from docopt import docopt 28 | from serial_asyncio_fast import create_serial_connection 29 | 30 | from rflink.parser import ( 31 | DELIM, 32 | PacketHeader, 33 | decode_packet, 34 | serialize_packet_id, 35 | valid_packet, 36 | ) 37 | from rflink.protocol import RflinkProtocol 38 | 39 | log = logging.getLogger(__name__) 40 | 41 | CRLF = b"\r\n" 42 | DEFAULT_RECONNECT_INTERVAL = 10 43 | DEFAULT_SIGNAL_REPETITIONS = 1 44 | CONNECTION_TIMEOUT = 10 45 | 46 | clients = [] 47 | 48 | 49 | class ProxyProtocol(RflinkProtocol): 50 | """Proxy commands received to multiple clients.""" 51 | 52 | def __init__(self, *args, raw_callback: Callable = None, **kwargs) -> None: 53 | """Add proxy specific initialization.""" 54 | super().__init__(*args, **kwargs) 55 | self.raw_callback = raw_callback 56 | 57 | def handle_raw_packet(self, raw_packet): 58 | """Parse raw packet string into packet dict.""" 59 | log.debug("got packet: %s", raw_packet) 60 | packet = None 61 | try: 62 | packet = decode_packet(raw_packet) 63 | except BaseException: 64 | log.exception("failed to parse packet: %s", packet) 65 | 66 | log.debug("decoded packet: %s", packet) 67 | 68 | if packet: 69 | if "ok" in packet: 70 | # handle response packets internally 71 | log.debug("command response: %s", packet) 72 | self._last_ack = packet 73 | self._command_ack.set() 74 | elif self.raw_callback: 75 | self.raw_callback(raw_packet) 76 | else: 77 | log.warning("no valid packet") 78 | 79 | 80 | def decode_tx_packet(packet: str) -> dict: 81 | """Break packet down into primitives, and do basic interpretation. 82 | 83 | >>> decode_packet('20;06;Kaku;ID=41;SWITCH=1;CMD=ON;') == { 84 | ... 'node': 'gateway', 85 | ... 'protocol': 'kaku', 86 | ... 'id': '000041', 87 | ... 'switch': '1', 88 | ... 'command': 'on', 89 | ... } 90 | True 91 | """ 92 | node_id, protocol, attrs = packet.split(DELIM, 2) 93 | 94 | data = cast(Dict[str, Any], {"node": PacketHeader(node_id).name}) 95 | 96 | data["protocol"] = protocol.lower() 97 | 98 | for i, attr in enumerate(filter(None, attrs.strip(DELIM).split(DELIM))): 99 | if i == 0: 100 | data["id"] = attr 101 | if i == 1: 102 | data["switch"] = attr 103 | if i == 2: 104 | data["command"] = attr 105 | 106 | # correct KaKu device address 107 | if data.get("protocol", "") == "kaku" and len(data["id"]) != 6: 108 | data["id"] = "0000" + data["id"] 109 | 110 | return data 111 | 112 | 113 | class RFLinkProxy: 114 | """Proxy commands received to multiple clients.""" 115 | 116 | def __init__(self, port=None, host=None, baud=57600, loop=None): 117 | """Initialize class.""" 118 | self.port = port 119 | self.host = host 120 | self.baud = baud 121 | self.loop = loop 122 | self.protocol = None 123 | self.transport = None 124 | self.closing = False 125 | 126 | async def handle_raw_tx_packet(self, writer, raw_packet): 127 | """Parse raw packet string into packet dict.""" 128 | peer = writer.get_extra_info("peername") 129 | log.debug(" %s:%s: processing data: %s", peer[0], peer[1], raw_packet) 130 | packet = None 131 | try: 132 | packet = decode_tx_packet(raw_packet) 133 | except BaseException: 134 | log.exception( 135 | " %s:%s: failed to parse packet: %s", peer[0], peer[1], packet 136 | ) 137 | 138 | log.debug(" %s:%s: decoded packet: %s", peer[0], peer[1], packet) 139 | if self.protocol and packet: 140 | if ";PING;" not in raw_packet: 141 | log.info( 142 | " %s:%s: forwarding packet %s to RFLink", 143 | peer[0], 144 | peer[1], 145 | raw_packet, 146 | ) 147 | else: 148 | log.debug( 149 | " %s:%s: forwarding packet %s to RFLink", 150 | peer[0], 151 | peer[1], 152 | raw_packet, 153 | ) 154 | await self.forward_packet(writer, packet, raw_packet) 155 | else: 156 | log.warning(" %s:%s: no valid packet %s", peer[0], peer[1], packet) 157 | 158 | async def forward_packet(self, writer, packet, raw_packet): 159 | """Forward packet from client to RFLink.""" 160 | peer = writer.get_extra_info("peername") 161 | log.debug(" %s:%s: forwarding data: %s", peer[0], peer[1], packet) 162 | if "command" in packet: 163 | packet_id = serialize_packet_id(packet) 164 | command = packet["command"] 165 | ack = await self.protocol.send_command_ack(packet_id, command) 166 | if ack: 167 | writer.write("20;00;OK;".encode() + CRLF) 168 | for _ in range(DEFAULT_SIGNAL_REPETITIONS - 1): 169 | await self.protocol.send_command_ack(packet_id, command) 170 | else: 171 | self.protocol.send_raw_packet(raw_packet) 172 | 173 | async def client_connected_callback(self, reader, writer): 174 | """Handle connected client.""" 175 | peer = writer.get_extra_info("peername") 176 | clients.append((reader, writer, peer)) 177 | log.info("Incoming connection from: %s:%s", peer[0], peer[1]) 178 | try: 179 | while True: 180 | data = await reader.readline() 181 | if not data: 182 | break 183 | try: 184 | line = data.decode().strip() 185 | except UnicodeDecodeError: 186 | line = "\x00" 187 | 188 | # Workaround for domoticz issue #2816 189 | if line[-1] != DELIM: 190 | line = line + DELIM 191 | 192 | if valid_packet(line): 193 | await self.handle_raw_tx_packet(writer, line) 194 | else: 195 | log.warning( 196 | " %s:%s: dropping invalid data: '%s'", peer[0], peer[1], line 197 | ) 198 | pass 199 | except ConnectionResetError: 200 | pass 201 | except Exception as e: 202 | log.exception(e) 203 | 204 | log.info("Disconnected from: %s:%s", peer[0], peer[1]) 205 | writer.close() 206 | clients.remove((reader, writer, peer)) 207 | 208 | def raw_callback(self, raw_packet): 209 | """Send data to all connected clients.""" 210 | if ";PONG;" not in raw_packet: 211 | log.info("forwarding packet %s to clients", raw_packet) 212 | else: 213 | log.debug("forwarding packet %s to clients", raw_packet) 214 | writers = [i[1] for i in list(clients)] 215 | for writer in writers: 216 | writer.write(str(raw_packet).encode() + CRLF) 217 | 218 | def reconnect(self, exc=None): 219 | """Schedule reconnect after connection has been unexpectedly lost.""" 220 | # Reset protocol binding before starting reconnect 221 | self.protocol = None 222 | 223 | if not self.closing: 224 | log.warning("disconnected from Rflink, reconnecting") 225 | self.loop.create_task(self.connect()) 226 | 227 | async def connect(self): 228 | """Set up connection and hook it into HA for reconnect/shutdown.""" 229 | import serial 230 | 231 | log.info("Initiating Rflink connection") 232 | 233 | # Rflink create_rflink_connection decides based on the value of host 234 | # (string or None) if serial or tcp mode should be used 235 | 236 | # Setup protocol 237 | protocol = partial( 238 | ProxyProtocol, 239 | disconnect_callback=self.reconnect, 240 | raw_callback=self.raw_callback, 241 | loop=self.loop, 242 | ) 243 | 244 | # Initiate serial/tcp connection to Rflink gateway 245 | if self.host: 246 | connection = self.loop.create_connection(protocol, self.host, self.port) 247 | else: 248 | connection = create_serial_connection( 249 | self.loop, protocol, self.port, self.baud 250 | ) 251 | 252 | try: 253 | if sys.version_info >= (3, 11): 254 | async with asyncio.timeout(CONNECTION_TIMEOUT): 255 | self.transport, self.protocol = await connection 256 | else: 257 | import async_timeout 258 | 259 | async with async_timeout.timeout(CONNECTION_TIMEOUT): 260 | self.transport, self.protocol = await connection 261 | 262 | except ( 263 | serial.serialutil.SerialException, 264 | ConnectionRefusedError, 265 | TimeoutError, 266 | OSError, 267 | asyncio.TimeoutError, 268 | ) as exc: 269 | reconnect_interval = DEFAULT_RECONNECT_INTERVAL 270 | log.error( 271 | "Error connecting to Rflink, reconnecting in %s", reconnect_interval 272 | ) 273 | 274 | self.loop.call_later(reconnect_interval, self.reconnect, exc) 275 | return 276 | 277 | log.info("Connected to Rflink") 278 | 279 | 280 | def main(argv=sys.argv[1:], loop=None): 281 | """Parse argument and setup main program loop.""" 282 | args = docopt(__doc__, argv=argv, version=version("rflink")) 283 | 284 | level = logging.ERROR 285 | if args["-v"]: 286 | level = logging.INFO 287 | if args["-v"] == 2: 288 | level = logging.DEBUG 289 | logging.basicConfig(level=level) 290 | 291 | if not loop: 292 | loop = asyncio.get_event_loop() 293 | 294 | host = args["--host"] 295 | port = args["--port"] 296 | baud = args["--baud"] 297 | listenport = args["--listenport"] 298 | 299 | proxy = RFLinkProxy(port=port, host=host, baud=baud, loop=loop) 300 | 301 | server_coro = asyncio.start_server( 302 | proxy.client_connected_callback, 303 | host="", 304 | port=listenport, 305 | ) 306 | 307 | server = loop.run_until_complete(server_coro) 308 | addr = server.sockets[0].getsockname() 309 | log.info("Serving on %s", addr) 310 | 311 | conn_coro = proxy.connect() 312 | loop.run_until_complete(conn_coro) 313 | 314 | proxy.closing = False 315 | try: 316 | loop.run_forever() 317 | except KeyboardInterrupt: 318 | proxy.closing = True 319 | 320 | # cleanup server 321 | server.close() 322 | loop.run_until_complete(server.wait_closed()) 323 | 324 | # cleanup server connections 325 | writers = [i[1] for i in list(clients)] 326 | for writer in writers: 327 | writer.close() 328 | if sys.version_info >= (3, 7): 329 | loop.run_until_complete(writer.wait_closed()) 330 | 331 | # cleanup RFLink connection 332 | proxy.transport.close() 333 | 334 | finally: 335 | loop.close() 336 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [pylama] 2 | linters = pydocstyle,pycodestyle,pyflakes 3 | ignore = D213,E128,D203,D418 4 | 5 | [pycodestyle] 6 | max_line_length = 100 7 | 8 | [mypy] 9 | python_version = 3.11 10 | #strict = True # does not work here, run with --strict 11 | disallow_any_unimported = True 12 | warn_unreachable = True 13 | strict_equality = True 14 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Library and CLI tools for interacting with RFlink 433MHz transceiver.""" 2 | 3 | import sys 4 | from codecs import open 5 | from os import path 6 | from subprocess import check_output 7 | 8 | from setuptools import find_packages, setup 9 | 10 | here = path.abspath(path.dirname(__file__)) 11 | 12 | if sys.version_info < (3, 9): 13 | raise RuntimeError("This package requires at least Python 3.11") 14 | 15 | # Get the long description from the README file 16 | with open(path.join(here, "README.rst"), encoding="utf-8") as f: 17 | long_description = f.read() 18 | 19 | 20 | def version_from_git(): 21 | """Acquire package version form current git tag.""" 22 | return check_output( 23 | ["git", "describe", "--tags", "--abbrev=0"], universal_newlines=True 24 | ).strip() 25 | 26 | 27 | setup( 28 | name="rflink", 29 | version=version_from_git(), 30 | description=__doc__, 31 | long_description=long_description, 32 | # The project's main homepage. 33 | url="https://github.com/aequitas/python-rflink", 34 | # Author details 35 | author="Johan Bloemberg", 36 | author_email="github@ijohan.nl", 37 | # Choose your license 38 | license="MIT", 39 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers 40 | classifiers=[ 41 | # How mature is this project? Common values are 42 | # 3 - Alpha 43 | # 4 - Beta 44 | # 5 - Production/Stable 45 | "Development Status :: 3 - Alpha", 46 | # Indicate who your project is intended for 47 | "Intended Audience :: Developers", 48 | # Pick your license as you wish (should match "license" above) 49 | "License :: OSI Approved :: MIT License", 50 | "Programming Language :: Python :: 2.7", 51 | "Programming Language :: Python :: 3.6", 52 | "Programming Language :: Python :: 3.7", 53 | "Programming Language :: Python :: 3.8", 54 | "Programming Language :: Python :: 3.9", 55 | ], 56 | keywords="rflink 433mhz domotica", 57 | packages=find_packages(exclude=["contrib", "docs", "tests"]), 58 | package_data={"rflink": ["py.typed"]}, 59 | install_requires=[ 60 | "docopt", 61 | "pyserial", 62 | "pyserial-asyncio-fast", 63 | 'typing;python_version<"3.5"', 64 | 'async_timeout;python_version<"3.11"', 65 | ], 66 | # # List additional groups of dependencies here (e.g. development 67 | # # dependencies). You can install these using the following syntax, 68 | # # for example: 69 | # # $ pip install -e .[dev,test] 70 | # extras_require={ 71 | # 'dev': ['check-manifest'], 72 | # 'test': ['coverage'], 73 | # }, 74 | # # If there are data files included in your packages that need to be 75 | # # installed, specify them here. If using Python 2.6 or less, then these 76 | # # have to be included in MANIFEST.in as well. 77 | # package_data={ 78 | # 'sample': ['package_data.dat'], 79 | # }, 80 | # # Although 'package_data' is the preferred approach, in some case you may 81 | # # need to place data files outside of your packages. See: 82 | # # http://docs.python.org/3.4/distutils/setupscript.html#installing-additional-files # noqa 83 | # # In this case, 'data_file' will be installed into '/my_data' 84 | # data_files=[('my_data', ['data/data_file'])], 85 | # To provide executable scripts, use entry points in preference to the 86 | # "scripts" keyword. Entry points provide cross-platform support and allow 87 | # pip to create the appropriate form of executable for the target platform. 88 | entry_points={ 89 | "console_scripts": [ 90 | "rflink=rflink.__main__:main", 91 | "rflinkproxy=rflinkproxy.__main__:main", 92 | ], 93 | }, 94 | project_urls={ 95 | "Release notes": "https://github.com/aequitas/python-rflink/releases", 96 | }, 97 | ) 98 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | import rflink.manager 5 | 6 | logging.basicConfig(level=logging.DEBUG) 7 | 8 | loop = asyncio.new_event_loop() 9 | 10 | m = rflink.manager.create_rflink_connection( 11 | rflink.manager.Inverter, host="hass", port="1234", loop=loop 12 | ) 13 | loop.create_task(m()) 14 | # loop.run_until_complete(m.transport) 15 | loop.run_forever() 16 | -------------------------------------------------------------------------------- /tests/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aequitas/python-rflink/ffdab21f32cc25ae6c9c3a062aafb3a8023174cc/tests/.keep -------------------------------------------------------------------------------- /tests/protocol_samples.txt: -------------------------------------------------------------------------------- 1 | # RFLink Startup communication example 2 | 20;00;Nodo RadioFrequencyLink - RFLink Gateway V1.1 - R46; 3 | # 20;01;MySensors=OFF;NO NRF24L01; 4 | # 20;02;setGPIO=ON; 5 | 20;03;Cresta;ID=8301;WINDIR=0005;WINSP=0000;WINGS=0000;WINTMP=00c3;WINCHL=00c3;BAT=LOW; 6 | 20;04;Cresta;ID=3001;TEMP=00b4;HUM=50;BAT=OK; 7 | 20;05;Cresta;ID=2801;TEMP=00af;HUM=53;BAT=OK; 8 | 20;06;NewKaku;ID=008440e6;SWITCH=a;CMD=OFF; 9 | 20;07;AB400D;ID=41;SWITCH=1;CMD=ON; 10 | 20;08;SilvercrestDB;ID=04d6bb97;SWITCH=1;CMD=ON;CHIME=01; 11 | 12 | # Sample data of received RF packets 13 | 20;2D;UPM/Esic;ID=0001;TEMP=00cf;HUM=16;BAT=OK; 14 | 20;6A;UPM/Esic;ID=1002;WINSP=0041;WINDIR=5A;BAT=OK; 15 | 20;08;UPM/Esic;ID=1003;RAIN=0010;BAT=OK; 16 | 20;31;Mebus;ID=c201;TEMP=00cf; 17 | 20;32;Auriol;ID=008f;TEMP=00d3;BAT=OK; 18 | 20;A2;Auriol V2;ID=008f;TEMP=00a3;BAT=LOW; 19 | 20;33;Cresta;ID=3001;TEMP=00b0;HUM=50; 20 | 20;0C;Cresta;ID=8001;RAIN=001c; 21 | 20;47;Cresta;ID=8001;WINDIR=0002;WINSP=0060;WINGS=0088;WINCHL=b0; 22 | 20;47;Cresta;ID=8001;TEMP=00b0;UV=00d0; 23 | 20;36;Alecto V1;ID=ec02;TEMP=00d1;HUM=14; 24 | 20;07;Mebus;ID=ea01;TEMP=0017; 25 | 20;3D;Alecto V1;ID=2000;TEMP=0011;HUM=61; 26 | 20;26;Alecto V1;ID=0086;RAIN=02ac; 27 | 20;30;Alecto V1;ID=0020;WINSP=0068; 28 | 20;16;Alecto V1;ID=0020;WINSP=0020; 29 | 20;17;Alecto V1;ID=0020;WINDIR=0002;WINGS=0088; 30 | 20;36;Alecto V1;ID=0020;WINDIR=0002;WINGS=0040; 31 | 20;74;Oregon TempHygro;ID=0ACC;TEMP=00be;HUM=40;BAT=OK; 32 | 20;b3;Oregon TempHygro;ID=1a2d;TEMP=00dd;HUM=43;BAT=OK; 33 | 20;e5;Oregon BTHR;ID=5a6d;TEMP=00be;HUM=40;BARO=03d7;BAT=OK; 34 | 20;46;Oregon Rain;ID=2a1d;RAIN=0012;RAINTOT=0012;BAT=OK; 35 | 20;83;Oregon Rain2;ID=2a19;RAIN=002a;RAINTOT=0054;BAT=OK; 36 | 20;32;Oregon Wind;ID=1a89;WDIR=0045;WINSP=0068;AWINSP=0050;BAT=OK; 37 | 20;4a;Oregon Wind2;ID=3a0d;WDIR=0021;WINSP=0040;AWINSP=005a;BAT=OK; 38 | 20;ba;Oregon UVN128/138;ID=ea7c;UV=0030;BAT=OK; 39 | 20;AF;SelectPlus;ID=1bb4;CHIME=01; 40 | 20;FC;Plieger York;ID=dd01;CHIME=02; 41 | 20;47;Byron SX;ID=a66a;CHIME=09; 42 | 20;12;Pir;ID=aa66;PIR=ON; 43 | 20;63;SmokeAlert;ID=123456;SMOKEALERT=ON; 44 | 20;06;Kaku;ID=41;SWITCH=1;CMD=ON; 45 | 20;0C;Kaku;ID=41;SWITCH=2;CMD=OFF; 46 | 20;0D;Kaku;ID=41;SWITCH=2;CMD=ON; 47 | 20;46;Kaku;ID=44;SWITCH=4;CMD=OFF; 48 | 20;E0;NewKaku;ID=cac142;SWITCH=1;CMD=ALLOFF; 49 | 20;3B;NewKaku;ID=cac142;SWITCH=3;CMD=OFF; 50 | 20;0B;NewKaku;ID=000005;SWITCH=2;CMD=ON; 51 | 20;0E;NewKaku;ID=000005;SWITCH=2;CMD=OFF; 52 | 20;12;NewKaku;ID=000002;SWITCH=2;CMD=OFF; 53 | 20;1E;NewKaku;ID=00000a;SWITCH=2;CMD=OFF; 54 | 20;1F;NewKaku;ID=00000a;SWITCH=2;CMD=ON; 55 | 20;01;NewKaku;ID=000007;SWITCH=2;CMD=OFF; 56 | 20;04;NewKaku;ID=000007;SWITCH=2;CMD=ON; 57 | 20;04;NewKaku;ID=000007;SWITCH=2;CMD=SET_LEVEL=14; 58 | 20;0C;HomeEasy;ID=7900b200;SWITCH=0b;CMD=ALLON; 59 | 20;AD;FA500;ID=0d00b900;SWITCH=0001;CMD=UNKOWN; 60 | 20;AE;FA500;ID=0a01;SWITCH=0a01;CMD=OFF; 61 | 20;03;Eurodomest;ID=03696b;SWITCH=00;CMD=OFF; 62 | 20;04;Eurodomest;ID=03696b;SWITCH=07;CMD=ALLOFF; 63 | 20;41;Conrad RSL2;ID=010002;SWITCH=03;CMD=ON; 64 | 20;47;Blyss;ID=ff98;SWITCH=A1;CMD=ON; 65 | 20;73;Kambrook;ID=010203;SWITCH=A1;CMD=OFF; 66 | 20;39;RTS;ID=1a602a;SWITCH=01;CMD=DOWN; 67 | 20;01;MiLightv1;ID=F746;SWITCH=00;RGBW=3c00;CMD=ON; 68 | 20;3B;NewKaku;ID=cac142;SWITCH=3;CMD=OFF; 69 | 70 | # Sample data of transmitted RF packets 71 | 10;Kaku;00004d;1;OFF; 72 | 10;AB400D;00004d;1;OFF; 73 | 10;Impuls;00004d;1;OFF; 74 | 10;NewKaku;00c142;1;ON; 75 | 10;NewKaku;128ac4d;1;OFF; 76 | 10;Eurodomest;123456;01;ON; 77 | 10;Blyss;ff98;A1;OFF; 78 | 10;Conrad;ff0607;1;OFF; 79 | 10;Kambrook;050325;a1;ON; 80 | 10;X10;000041;1;OFF; 81 | # 10;HomeConfort;01b523;D3;ON 82 | 10;HomeConfort;01b523;D3;ON; 83 | 10;FA500;001b523;D3;ON; 84 | 10;Powerfix;000080;0;ON; 85 | 10;Ikea Koppla;000080;0;ON; 86 | 10;HomeEasy;7900b100;3;ON; 87 | 10;EV1527;000080;0;ON; 88 | 10;Chuango;000080;2;ON; 89 | 10;Selectplus;001c33; 90 | 10;Byron;112233;01;OFF; 91 | 10;DELTRONIC;001c33; 92 | 10;BYRON;00009F;01;ON; 93 | 10;FA20RF;67f570;1;ON; 94 | 10;MERTIK;64;UP; 95 | 10;RTS;1a602a;0;ON; 96 | 10;RTS;1b602b;0123;PAIR; 97 | 10;RTS;1b602b;0123;0;PAIR; 98 | 10;MiLightv1;F746;00;3c00;ON; 99 | 10;MiLightv1;F746;01;34BC;PAIR; 100 | 10;MiLightv1;F746;01;34BC;UNPAIR; 101 | 10;MiLightv1;F746;01;34BC;BRIGHT; 102 | 10;MiLightv1;F746;01;34BC;COLOR; 103 | 10;UNITEC;7796;01;ON; 104 | 10;UNITEC;7796;01;PAIR; 105 | 106 | # Special Control Commands - Send 107 | 10;REBOOT; 108 | 10;PING; 109 | 10;VERSION; 110 | 10;RFDEBUG=ON; 111 | 10;RFUDEBUG=ON; 112 | 10;QRFDEBUG=ON; 113 | 10;TRISTATEINVERT; 114 | 10;RTSCLEAN; 115 | #10;RTSRECCLEAN=9 116 | 10;RTSRECCLEAN=9; 117 | 10;RTSSHOW; 118 | 10;RTSINVERT; 119 | 10;RTSLONGTX; 120 | 121 | # Device creation using the Echo command (Node 11) 122 | 11;20;0B;NewKaku;ID=000005;SWITCH=2;CMD=ON; 123 | 20;D3;OK; 124 | 20;D4;NewKaku;ID=000005;SWITCH=2;CMD=ON; 125 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | """Basic testing for CLI.""" 2 | 3 | import asyncio 4 | 5 | from serial_asyncio_fast import SerialTransport 6 | 7 | from rflink.__main__ import main 8 | 9 | 10 | def test_spawns(monkeypatch): 11 | """At least test if the CLI doesn't error on load.""" 12 | loop = asyncio.new_event_loop() 13 | asyncio.set_event_loop(loop) 14 | 15 | # setup task to stop CLI loop 16 | async def stop(): 17 | """Wait and close loop.""" 18 | await asyncio.sleep(0.1) 19 | loop.stop() 20 | 21 | if hasattr(asyncio, "ensure_future"): 22 | ensure_future = asyncio.ensure_future 23 | else: # Deprecated since Python 3.4.4 24 | ensure_future = getattr(asyncio, "async") 25 | ensure_future(stop(), loop=loop) 26 | 27 | # use simulation interface 28 | args = ["--port", "loop://", "-v"] 29 | 30 | # patch to make 'loop://' work with serial_asyncio 31 | monkeypatch.setattr(SerialTransport, "_ensure_reader", lambda self: True) 32 | 33 | # test calling results in the loop close cleanly 34 | assert main(args, loop=loop) is None 35 | -------------------------------------------------------------------------------- /tests/test_parse.py: -------------------------------------------------------------------------------- 1 | """Test parsing of RFlink packets.""" 2 | 3 | import os 4 | 5 | import pytest 6 | 7 | from rflink.parser import ( 8 | PACKET_FIELDS, 9 | UNITS, 10 | VALUE_TRANSLATION, 11 | decode_packet, 12 | deserialize_packet_id, 13 | serialize_packet_id, 14 | valid_packet, 15 | ) 16 | 17 | PROTOCOL_SAMPLES = os.path.join(os.path.dirname(__file__), "protocol_samples.txt") 18 | 19 | 20 | @pytest.mark.parametrize( 21 | "packet,expect", 22 | [ 23 | [ 24 | "20;2D;UPM/Esic;ID=0001;TEMP=00cf;HUM=16;BAT=OK;", 25 | {"humidity": 16, "temperature": 20.7}, 26 | ], 27 | [ 28 | "20;36;Alecto V1;ID=0334;TEMP=800d;HUM=33;BAT=OK;", 29 | {"temperature": -1.3, "temperature_unit": "°C"}, 30 | ], 31 | ["20;08;UPM/Esic;ID=1003;RAIN=0010;BAT=OK;", {"battery": "ok"}], 32 | ["20;46;Kaku;ID=44;SWITCH=4;CMD=OFF;", {"command": "off", "switch": "4"}], 33 | [ 34 | "20;E0;NewKaku;ID=cac142;SWITCH=1;CMD=ALLOFF;", 35 | {"id": "cac142", "protocol": "newkaku"}, 36 | ], 37 | [ 38 | "20;00;Nodo RadioFrequencyLink - RFLink Gateway V1.1 - R45;", 39 | { 40 | "hardware": "Nodo RadioFrequencyLink", 41 | "firmware": "RFLink Gateway", 42 | "version": "1.1", 43 | "revision": "45", 44 | }, 45 | ], 46 | [ 47 | "20;01;VER=1.1;REV=45;BUILD=04;", 48 | {"version": "1.1", "revision": "45", "build": "04"}, 49 | ], 50 | ["20;01;PONG;", {"ping": "pong"}], 51 | [ 52 | ( 53 | "20;02;STATUS;setRF433=ON;setNodoNRF=OFF;setMilight=OFF;" 54 | "setLivingColors=OFF;setAnsluta=OFF;setGPIO=OFF;setBLE=OFF;" 55 | "setMysensors=OFF;" 56 | ), 57 | {"protocol": "status", "setrf433": "on", "setmysensors": "off"}, 58 | ], 59 | ["20;01;CMD UNKNOWN;", {"response": "command_unknown", "ok": False}], 60 | ["20;02;OK;", {"ok": True}], 61 | # no actual examples available, so these are made up from protocol spec 62 | [ 63 | "20;01;mock;ID=0;BFORECAST=1;HSTATUS=0", 64 | {"weather_forecast": "sunny", "humidity_status": "normal"}, 65 | ], 66 | [ 67 | "20;00;Nodo RadioFrequencyLink - RFLink Gateway V1.1 - R45;", 68 | { 69 | "version": "1.1", 70 | "revision": "45", 71 | "hardware": "Nodo RadioFrequencyLink", 72 | "firmware": "RFLink Gateway", 73 | }, 74 | ], 75 | [ 76 | "20;05;RTS;ID=147907;SWITCH=01;CMD=UP;", 77 | {"id": "147907", "switch": "01", "protocol": "rts", "command": "up"}, 78 | ], 79 | [ 80 | "20;00;Internal Pullup on RF-in disabled;", 81 | {"message": "Internal Pullup on RF-in disabled"}, 82 | ], 83 | [ 84 | "20;9A;FA500;ID=0000db9e;SWITCH=01;CMD=SET_LEVEL=2;", 85 | {"command": "set_level=2"}, 86 | ], 87 | [ 88 | "20;84;Debug;RTS P1;a63f33003cf000665a5a;", 89 | {"rts_p1": "a63f33003cf000665a5a"}, 90 | ], 91 | [ 92 | "20;84;DEBUG;RTS P1;a63f33003cf000665a5a;", 93 | {"rts_p1": "a63f33003cf000665a5a"}, 94 | ], 95 | ["20;01;setGPIO=ON;", {"setgpio": "on"}], 96 | ], 97 | ) 98 | def test_packet_parsing(packet, expect): 99 | """Packet should be broken up into their primitives.""" 100 | result = decode_packet(packet) 101 | 102 | for key, value in expect.items(): 103 | assert result[key] == value 104 | 105 | # make sure each packet is serialized without failure 106 | packet_id = serialize_packet_id(result) 107 | 108 | # and deserialize it again 109 | packet_identifiers = deserialize_packet_id(packet_id) 110 | 111 | original = set(result.items()) 112 | transserialized = set(packet_identifiers.items()) 113 | assert transserialized.issubset(original) 114 | 115 | 116 | def test_descriptions(): 117 | """Every value translation should be paired with a description.""" 118 | for key in VALUE_TRANSLATION: 119 | assert key in PACKET_FIELDS 120 | 121 | 122 | def test_units(): 123 | """Every description should have a unit available.""" 124 | for key in PACKET_FIELDS: 125 | assert key in UNITS 126 | 127 | 128 | @pytest.mark.parametrize( 129 | "packet", 130 | [ 131 | line.strip() 132 | for line in open(PROTOCOL_SAMPLES).readlines() 133 | if line.strip() and line[0] != "#" 134 | ], 135 | ) 136 | def test_packet_valiation(packet): 137 | """Verify if packet validation correctly identifies official samples. 138 | 139 | https://www.rflink.nl/protref.php 140 | """ 141 | assert valid_packet(packet) 142 | 143 | 144 | def test_invalid_type(): 145 | """Packet where a value type cannot be converted to expected type should not error.""" 146 | packet = "20;2D;RFX10METER;ID=79;TYPE=10;METER=7ef36;" 147 | 148 | assert decode_packet(packet) == { 149 | "node": "gateway", 150 | "protocol": "rfx10meter", 151 | "id": "79", 152 | "type": "10", 153 | } 154 | 155 | 156 | @pytest.mark.parametrize("device_id", ["dooya_v4_6d5f8e00_3f"]) 157 | def test_underscored(device_id): 158 | """Test parsing device id's that contain underscores.""" 159 | assert deserialize_packet_id(device_id) 160 | -------------------------------------------------------------------------------- /tests/test_protocol.py: -------------------------------------------------------------------------------- 1 | """Test RFlink serial low level and packet parsing protocol.""" 2 | 3 | from unittest.mock import Mock 4 | 5 | import pytest 6 | 7 | from rflink.protocol import EventHandling, PacketHandling 8 | 9 | COMPLETE_PACKET = b"20;E0;NewKaku;ID=cac142;SWITCH=1;CMD=ALLOFF;\r\n" 10 | INCOMPLETE_PART1 = b"20;E0;NewKaku;ID=cac" 11 | INCOMPLETE_PART2 = b"142;SWITCH=1;CMD=ALLOFF;\r\n" 12 | 13 | COMPLETE_PACKET_DICT = { 14 | "id": "cac142", 15 | "node": "gateway", 16 | "protocol": "newkaku", 17 | "command": "alloff", 18 | "switch": "1", 19 | } 20 | 21 | 22 | @pytest.fixture 23 | def protocol(monkeypatch): 24 | """Rflinkprotocol instance with mocked handle_packet.""" 25 | monkeypatch.setattr(PacketHandling, "handle_packet", Mock()) 26 | return PacketHandling(None) 27 | 28 | 29 | @pytest.fixture 30 | def event_protocol(monkeypatch, ignore): 31 | """Rflinkprotocol instance with mocked handle_event.""" 32 | monkeypatch.setattr(EventHandling, "handle_event", Mock()) 33 | return EventHandling(None, ignore=ignore) 34 | 35 | 36 | def test_complete_packet(protocol): 37 | """Protocol should parse and output complete incoming packets.""" 38 | protocol.data_received(COMPLETE_PACKET) 39 | 40 | protocol.handle_packet.assert_called_once_with(COMPLETE_PACKET_DICT) 41 | 42 | 43 | def test_split_packet(protocol): 44 | """Packet should be allowed to arrive in pieces.""" 45 | protocol.data_received(INCOMPLETE_PART1) 46 | protocol.data_received(INCOMPLETE_PART2) 47 | 48 | protocol.handle_packet.assert_called_once_with(COMPLETE_PACKET_DICT) 49 | 50 | 51 | def test_starting_incomplete(protocol): 52 | """An initial incomplete packet should be discarded.""" 53 | protocol.data_received(INCOMPLETE_PART2) 54 | protocol.data_received(INCOMPLETE_PART1) 55 | protocol.data_received(INCOMPLETE_PART2) 56 | 57 | protocol.handle_packet.assert_called_once_with(COMPLETE_PACKET_DICT) 58 | 59 | 60 | def test_multiple_packets(protocol): 61 | """Multiple packets should be parsed.""" 62 | protocol.data_received(COMPLETE_PACKET) 63 | protocol.data_received(COMPLETE_PACKET) 64 | 65 | assert protocol.handle_packet.call_count == 2 66 | protocol.handle_packet.assert_called_with(COMPLETE_PACKET_DICT) 67 | 68 | 69 | @pytest.mark.parametrize( 70 | "ignore,expected", 71 | [ 72 | # Test id is newkaku_cac142_1. 73 | # Ignore matches: 74 | (["newkaku_cac142_1"], 0), 75 | (["newkaku_cac142_1*"], 0), 76 | (["newkaku_cac142_*"], 0), 77 | (["newkaku_cac142_?"], 0), 78 | (["*_cac142_*"], 0), 79 | (["newkaku_*_1"], 0), 80 | # Ignore does not match: 81 | (["newkaku_cac142_2"], 1), 82 | (["newkaku_cac142_1?"], 1), 83 | (["*meh?"], 1), 84 | ([], 1), 85 | ], 86 | ) 87 | def test_ignore(event_protocol, expected): 88 | """Ignore should match as appropriate.""" 89 | event_protocol.data_received(COMPLETE_PACKET) 90 | 91 | assert event_protocol.handle_event.call_count == expected, event_protocol.ignore 92 | -------------------------------------------------------------------------------- /tests/test_proxy.py: -------------------------------------------------------------------------------- 1 | """Basic testing for proxy.""" 2 | 3 | import asyncio 4 | 5 | from serial_asyncio_fast import SerialTransport 6 | 7 | from rflinkproxy.__main__ import main 8 | 9 | 10 | def test_spawns(monkeypatch): 11 | """At least test if the CLI doesn't error on load.""" 12 | loop = asyncio.new_event_loop() 13 | asyncio.set_event_loop(loop) 14 | 15 | # setup task to stop CLI loop 16 | async def stop(): 17 | """Wait and close loop.""" 18 | await asyncio.sleep(0.1) 19 | loop.stop() 20 | 21 | if hasattr(asyncio, "ensure_future"): 22 | ensure_future = asyncio.ensure_future 23 | else: # Deprecated since Python 3.4.4 24 | ensure_future = getattr(asyncio, "async") 25 | ensure_future(stop(), loop=loop) 26 | 27 | # use simulation interface 28 | args = ["--port", "loop://", "-v"] 29 | 30 | # patch to make 'loop://' work with serial_asyncio 31 | monkeypatch.setattr(SerialTransport, "_ensure_reader", lambda self: True) 32 | 33 | # test calling results in the loop close cleanly 34 | assert main(args, loop=loop) is None 35 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py39,py310,py311,py312,lint,typing 3 | skip_missing_interpreters = True 4 | 5 | [gh-actions] 6 | python = 7 | 3.9: py39 8 | 3.10: py310 9 | 3.11: py311, lint 10 | 3.12: py312 11 | 12 | [testenv] 13 | commands = py.test \ 14 | --doctest-modules \ 15 | --cov=rflink \ 16 | --cov=rflinkproxy \ 17 | rflink tests {posargs} 18 | deps = 19 | pytest 20 | pytest-cov 21 | pytest-xdist 22 | usedevelop = True 23 | 24 | [testenv:fix] 25 | commands = 26 | autopep8 --aggressive --in-place --recursive . 27 | isort . 28 | black . 29 | deps = 30 | isort 31 | black 32 | autopep8 33 | 34 | [testenv:lint] 35 | commands = 36 | pylama setup.py rflink rflinkproxy tests 37 | black --check . 38 | deps = 39 | isort 40 | pylama 41 | black 42 | pydocstyle<6 43 | pyflakes<2.5 44 | 45 | [testenv:typing] 46 | commands = mypy --install-types --non-interactive --strict --follow-untyped-imports --ignore-missing-imports rflink 47 | #commands = basedpyright rflink 48 | deps = 49 | types-docopt 50 | mypy 51 | 52 | [testenv:pypy3] 53 | deps = 54 | {[testenv]deps} 55 | asyncio 56 | --------------------------------------------------------------------------------