commit 009484197fcbedfae058e104c46da24272f7648d Author: root Date: Sun Jun 21 18:42:39 2026 +0000 New initial version diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5ce4b4d --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2026 getcom + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a0e5a4 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# enocean2knx-gw + +EnOcean to KNX Gateway \ No newline at end of file diff --git a/enocean/.coveragerc b/enocean/.coveragerc new file mode 100644 index 0000000..4edd7b1 --- /dev/null +++ b/enocean/.coveragerc @@ -0,0 +1,2 @@ +[run] +relative_files = True diff --git a/enocean/.flake8 b/enocean/.flake8 new file mode 100644 index 0000000..f416bbe --- /dev/null +++ b/enocean/.flake8 @@ -0,0 +1,3 @@ +[flake8] +exclude = __init__.py +max_line_length = 120 diff --git a/enocean/.github/workflows/python-package.yml b/enocean/.github/workflows/python-package.yml new file mode 100644 index 0000000..892f484 --- /dev/null +++ b/enocean/.github/workflows/python-package.yml @@ -0,0 +1,53 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Python package + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + test: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.5', '3.6', '3.7', '3.8', '3.9'] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install flake8 nose coverage + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the test if there are Python syntax errors or undefined names + flake8 enocean --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 enocean --count --exit-zero --max-complexity=15 --max-line-length=127 --statistics + - name: Test with nose + run: | + nosetests -s -q --with-coverage --cover-package=enocean + - name: Coveralls + uses: AndreMiras/coveralls-python-action@develop + with: + parallel: true + flag-name: run-${{ matrix.test_number }} + + finish: + needs: test + runs-on: ubuntu-latest + steps: + - name: Coveralls Finished + uses: AndreMiras/coveralls-python-action@develop + with: + parallel-finished: true diff --git a/enocean/.github/workflows/python-publish.yml b/enocean/.github/workflows/python-publish.yml new file mode 100644 index 0000000..4e1ef42 --- /dev/null +++ b/enocean/.github/workflows/python-publish.yml @@ -0,0 +1,31 @@ +# This workflows will upload a Python Package using Twine when a release is created +# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries + +name: Upload Python Package + +on: + release: + types: [created] + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python setup.py sdist bdist_wheel + twine upload dist/* diff --git a/enocean/.gitignore b/enocean/.gitignore new file mode 100644 index 0000000..2bb1c65 --- /dev/null +++ b/enocean/.gitignore @@ -0,0 +1,19 @@ +bin +include +lib +local +share +lib-python +lib_pypy +site-packages +venv +*.pyc + +*.egg-info +build +man +MANIFEST +dist +pip-selfcheck.json + +.vscode/ diff --git a/enocean/LICENSE b/enocean/LICENSE new file mode 100644 index 0000000..ac54baa --- /dev/null +++ b/enocean/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014-2016 Kimmo Huoman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/enocean/README.md b/enocean/README.md new file mode 100644 index 0000000..0395414 --- /dev/null +++ b/enocean/README.md @@ -0,0 +1,27 @@ +# Python EnOcean # + +[![Build Status](https://travis-ci.org/kipe/enocean.svg?branch=master)](https://travis-ci.org/kipe/enocean) +[![Coverage Status](https://coveralls.io/repos/github/kipe/enocean/badge.svg?branch=master)](https://coveralls.io/github/kipe/enocean?branch=master) + +A Python library for reading and controlling [EnOcean](http://www.enocean.com/) devices. + +Started as a part of [Forget Me Not](http://www.element14.com/community/community/design-challenges/forget-me-not) +design challenge @ [element14](http://www.element14.com/). + +## Install ## + +If not installed already, install [pip](https://pypi.python.org/pypi/pip) by running + +`sudo apt-get install python-pip` + +After pip is installed, install the module by running + +`sudo pip install enocean` (or `sudo pip install git+https://github.com/kipe/enocean.git` if you want the "bleeding edge"). + +After this, it's just a matter of running `enocean_example.py` and pressing the +learn button on magnetic contact or temperature switch or pressing the rocker switch. + +You should be displayed with a log of the presses, as well as parsed values +(assuming the sensors are the ones provided in the [EnOcean Starter Kit](https://www.enocean.com/en/enocean_modules/esk-300)). + +The example script can be stopped by pressing `CTRL+C` diff --git a/enocean/SUPPORTED_PROFILES.md b/enocean/SUPPORTED_PROFILES.md new file mode 100644 index 0000000..1ca36d8 --- /dev/null +++ b/enocean/SUPPORTED_PROFILES.md @@ -0,0 +1,723 @@ +# Supported profiles +All profiles (should) correspond to the official [EEP](http://www.enocean-alliance.org/eep/) by EnOcean. + +### RPS Telegram (0xF6) +##### RORG 0xF6 - FUNC 0x01 - TYPE 0x01 - Push Button + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|PB |Status of the push button |enum |0 - Released | +| | | |1 - Pressed | + + + +##### RORG 0xF6 - FUNC 0x02 - TYPE 0x01 - Light and Blind Control - Application Style 1 + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|R1 |Rocker 1st action |enum |0 - Button AI | +| | | |1 - Button AO | +| | | |2 - Button BI | +| | | |3 - Button BO | +|EB |Energy bow |enum |0 - released | +| | | |1 - pressed | +|R2 |Rocker 2nd action |enum |0 - Button AI | +| | | |1 - Button AO | +| | | |2 - Button BI | +| | | |3 - Button BO | +|SA |2nd action |enum |0 - No 2nd action | +| | | |1 - 2nd action valid | +|T21 |T21 |status | | +|NU |NU |status | | + + +##### RORG 0xF6 - FUNC 0x02 - TYPE 0x02 - Light and Blind Control - Application Style 2 + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|R1 |Rocker 1st action |enum |0 - Button AI | +| | | |1 - Button AO | +| | | |2 - Button BI | +| | | |3 - Button BO | +|EB |Energy bow |enum |0 - released | +| | | |1 - pressed | +|R2 |Rocker 2nd action |enum |0 - Button AI | +| | | |1 - Button AO | +| | | |2 - Button BI | +| | | |3 - Button BO | +|SA |2nd action |enum |0 - No 2nd action | +| | | |1 - 2nd action valid | +|T21 |T21 |status | | +|NU |NU |status | | + + + +##### RORG 0xF6 - FUNC 0x05 - TYPE 0x01 - Liquid Leakage Sensor (mechanic harvester) + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|WAS |Water Sensor |enum |0-16 - not specified | +| | | |17 - Water detected | +| | | |18-255 - not specified | +|T21 |T21 |status | | +|NU |NU |status | | + + +##### RORG 0xF6 - FUNC 0x05 - TYPE 0x02 - Smoke Detector + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|SMO |Status of detection and battery |enum |0 - Smoke Alarm OFF | +| | | |16 - Smoke Alarm ON | +| | | |48 - Energy LOW | + + + +##### RORG 0xF6 - FUNC 0x10 - TYPE 0x00 - Window Handle + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|WIN |Window handle |enum |0 - Moved from up to vertical | +| | | |1 - Moved from vertical to up | +| | | |2 - Moved from down to vertical | +| | | |3 - Moved from vertical to down | +|T21 |T21 |status | | +|NU |NU |status | | + + + +### 1BS Telegram (0xD5) +##### RORG 0xD5 - FUNC 0x00 - TYPE 0x01 - Single Input Contact + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|CO |Contact |enum |0 - open | +| | | |1 - closed | + + + +### 4BS Telegram (0xA5) +##### RORG 0xA5 - FUNC 0x02 - TYPE 0x01 - Temperature Sensor Range -40°C to 0°C + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|TMP |Temperature (linear) |value |255.0-0.0 ↔ -40.0-0.0 °C | + + +##### RORG 0xA5 - FUNC 0x02 - TYPE 0x02 - Temperature Sensor Range -30°C to +10°C + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|TMP |Temperature (linear) |value |255.0-0.0 ↔ -30.0-10.0 °C | + + +##### RORG 0xA5 - FUNC 0x02 - TYPE 0x03 - Temperature Sensor Range -20°C to +20°C + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|TMP |Temperature (linear) |value |255.0-0.0 ↔ -20.0-20.0 °C | + + +##### RORG 0xA5 - FUNC 0x02 - TYPE 0x04 - Temperature Sensor Range -10°C to +30°C + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|TMP |Temperature (linear) |value |255.0-0.0 ↔ -10.0-30.0 °C | + + +##### RORG 0xA5 - FUNC 0x02 - TYPE 0x05 - Temperature Sensor Range 0°C to +40°C + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|TMP |Temperature (linear) |value |255.0-0.0 ↔ 0.0-40.0 °C | + + +##### RORG 0xA5 - FUNC 0x02 - TYPE 0x06 - Temperature Sensor Range +10°C to +50°C + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|TMP |Temperature (linear) |value |255.0-0.0 ↔ 10.0-50.0 °C | + + +##### RORG 0xA5 - FUNC 0x02 - TYPE 0x07 - Temperature Sensor Range +20°C to +60°C + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|TMP |Temperature (linear) |value |255.0-0.0 ↔ 20.0-60.0 °C | + + +##### RORG 0xA5 - FUNC 0x02 - TYPE 0x08 - Temperature Sensor Range +30°C to +70°C + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|TMP |Temperature (linear) |value |255.0-0.0 ↔ 30.0-70.0 °C | + + +##### RORG 0xA5 - FUNC 0x02 - TYPE 0x09 - Temperature Sensor Range +40°C to +80°C + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|TMP |Temperature (linear) |value |255.0-0.0 ↔ 40.0-80.0 °C | + + +##### RORG 0xA5 - FUNC 0x02 - TYPE 0x0A - Temperature Sensor Range +50°C to +90°C + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|TMP |Temperature (linear) |value |255.0-0.0 ↔ 50.0-90.0 °C | + + +##### RORG 0xA5 - FUNC 0x02 - TYPE 0x0B - Temperature Sensor Range +60°C to +100°C + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|TMP |Temperature (linear) |value |255.0-0.0 ↔ 60.0-100.0 °C | + + +##### RORG 0xA5 - FUNC 0x02 - TYPE 0x10 - Temperature Sensor Range -60°C to +20°C + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|TMP |Temperature (linear) |value |255.0-0.0 ↔ -60.0-20.0 °C | + + +##### RORG 0xA5 - FUNC 0x02 - TYPE 0x11 - Temperature Sensor Range -50°C to +30°C + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|TMP |Temperature (linear) |value |255.0-0.0 ↔ -50.0-30.0 °C | + + +##### RORG 0xA5 - FUNC 0x02 - TYPE 0x12 - Temperature Sensor Range -40°C to +40°C + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|TMP |Temperature (linear) |value |255.0-0.0 ↔ -40.0-40.0 °C | + + +##### RORG 0xA5 - FUNC 0x02 - TYPE 0x13 - Temperature Sensor Range -30°C to +50°C + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|TMP |Temperature (linear) |value |255.0-0.0 ↔ -30.0-50.0 °C | + + +##### RORG 0xA5 - FUNC 0x02 - TYPE 0x14 - Temperature Sensor Range -20°C to +60°C + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|TMP |Temperature (linear) |value |255.0-0.0 ↔ -20.0-60.0 °C | + + +##### RORG 0xA5 - FUNC 0x02 - TYPE 0x15 - Temperature Sensor Range -10°C to +70°C + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|TMP |Temperature (linear) |value |255.0-0.0 ↔ -10.0-70.0 °C | + + +##### RORG 0xA5 - FUNC 0x02 - TYPE 0x16 - Temperature Sensor Range 0°C to +80°C + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|TMP |Temperature (linear) |value |255.0-0.0 ↔ 0.0-80.0 °C | + + +##### RORG 0xA5 - FUNC 0x02 - TYPE 0x17 - Temperature Sensor Range +10°C to +90°C + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|TMP |Temperature (linear) |value |255.0-0.0 ↔ 10.0-90.0 °C | + + +##### RORG 0xA5 - FUNC 0x02 - TYPE 0x18 - Temperature Sensor Range +20°C to +100°C + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|TMP |Temperature (linear) |value |255.0-0.0 ↔ 20.0-100.0 °C | + + +##### RORG 0xA5 - FUNC 0x02 - TYPE 0x19 - Temperature Sensor Range +30°C to +110°C + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|TMP |Temperature (linear) |value |255.0-0.0 ↔ 30.0-110.0 °C | + + +##### RORG 0xA5 - FUNC 0x02 - TYPE 0x1A - Temperature Sensor Range +40°C to +120°C + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|TMP |Temperature (linear) |value |255.0-0.0 ↔ 40.0-120.0 °C | + + +##### RORG 0xA5 - FUNC 0x02 - TYPE 0x1B - Temperature Sensor Range +50°C to +130°C + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|TMP |Temperature (linear) |value |255.0-0.0 ↔ 50.0-130.0 °C | + + +##### RORG 0xA5 - FUNC 0x02 - TYPE 0x20 - 10 Bit Temperature Sensor Range -10°C to +41.2°C + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|TMP |Temperature (linear) |value |1023.0-0.0 ↔ -10.0-41.2 °C | + + +##### RORG 0xA5 - FUNC 0x02 - TYPE 0x30 - 10 Bit Temperature Sensor Range -40°C to +62.3°C + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|TMP |Temperature (linear) |value |1023.0-0.0 ↔ -40.0-62.3 °C | + + + +##### RORG 0xA5 - FUNC 0x04 - TYPE 0x01 - Range 0°C to +40°C and 0% to 100% + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|HUM |Rel. Humidity (linear) |value |0.0-250.0 ↔ 0.0-100.0 % | +|TMP |Temperature (linear) |value |0.0-250.0 ↔ 0.0-40.0 °C | +|TSN |Availability of the Temperature Sensor |enum |0 - not available | +| | | |1 - available | + + +##### RORG 0xA5 - FUNC 0x04 - TYPE 0x03 - Range -20°C to +60°C 10bit-measurement and 0% to 100% + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|HUM |Rel. Humidity (linear) |value |0.0-255.0 ↔ 0.0-100.0 % | +|TMP |Temperature (linear) |value |0.0-1023.0 ↔ -20.0-60.0 °C | +|TTP |Telegram Type |enum |0 - Heartbeat | +| | | |1 - Event triggered | + + + +##### RORG 0xA5 - FUNC 0x06 - TYPE 0x01 - Range 300lx to 60.000lx + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|SVC |Supply voltage (linear) |value |0.0-255.0 ↔ 0.0-5.1 V | +|ILL2 |Illumination 2 (linear) |value |0.0-255.0 ↔ 300.0-30000.0 lx | +|ILL1 |Illumination 1 (linear) |value |0.0-255.0 ↔ 600.0-60000.0 lx | +|RS |Range select |enum |0 - Range acc. to DB_1 (ILL1) | +| | | |1 - Range acc. to DB_2 (ILL2) | + + +##### RORG 0xA5 - FUNC 0x06 - TYPE 0x02 - Range 0lx to 1.020lx + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|SVC |Supply voltage (linear) |value |0.0-255.0 ↔ 0.0-5.1 V | +|ILL2 |Illumination 2 (linear) |value |0.0-255.0 ↔ 0.0-510.0 lx | +|ILL1 |Illumination 1 (linear) |value |0.0-255.0 ↔ 0.0-1020.0 lx | +|RS |Range select |enum |0 - Range acc. to DB_1 (ILL1) | +| | | |1 - Range acc. to DB_2 (ILL2) | + + + +##### RORG 0xA5 - FUNC 0x07 - TYPE 0x01 - Occupancy with Supply voltage monitor + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|SVC |Supply voltage (OPTIONAL) |value |0.0-250.0 ↔ 0.0-5.0 V | +|PIR |PIR Status |enum |0 - off | +| | | |1 - on | + + + +##### RORG 0xA5 - FUNC 0x08 - TYPE 0x01 - Range 0lx to 510lx, 0°C to +51°C and Occupancy Button + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|SVC |Supply voltage (linear) |value |0.0-255.0 ↔ 0.0-5.1 V | +|ILL |Illumination (linear) |value |0.0-255.0 ↔ 0.0-510.0 lx | +|TMP |Temperature (linear) |value |0.0-255.0 ↔ 0.0-51.0 °C | +|PIRS |PIR Status |enum |0 - PIR on | +| | | |1 - PIR off | +|OCC |Occupancy Button |enum |0 - Button pressed | +| | | |1 - Button released | + + + +##### RORG 0xA5 - FUNC 0x09 - TYPE 0x04 - CO2 Sensor + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|HUM |Rel. Humidity (linear) |value |0.0-200.0 ↔ 0.0-100.0 % | +|Conc |Concentration (linear) |value |0.0-255.0 ↔ 0.0-2550.0 ppm | +|TMP |Temperature (linear) |value |0.0-255.0 ↔ 0.0-51.0 °C | + + +##### RORG 0xA5 - FUNC 0x09 - TYPE 0x05 - VOC Sensor + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|Conc |VOC Concentration |value |0.0-65535.0 ↔ 0.0-65535.0 ppb | +|VOC_ID |VOC Identification |enum |0 - VOCT (total) | +| | | |1 - Formaldehyde | +| | | |2 - Benzene | +| | | |3 - Styrene | +| | | |4 - Toluene | +| | | |5 - Tetrachloroethylene | +| | | |6 - Xylene | +| | | |7 - n-Hexane | +| | | |8 - n-Octane | +| | | |9 - Cyclopentane | +| | | |10 - Methanol | +| | | |11 - Ethanol | +| | | |12 - 1-Pentanol | +| | | |13 - Acetone | +| | | |14 - ethylene Oxide | +| | | |15 - Acetaldehyde ue | +| | | |16 - Acetic Acid | +| | | |17 - Propionice Acid | +| | | |18 - ValericAcid | +| | | |19 - ButyricAcid | +| | | |20 - Ammoniac | +| | | |22 - Hydrogen Sulfide | +| | | |23 - Dimethylsulfide | +| | | |24 - 2-Butanol (butyl Alcohol) | +| | | |25 - 2-Methylpropanol | +| | | |26 - Diethyl ether | +| | | |255 - ozone | + + + +##### RORG 0xA5 - FUNC 0x10 - TYPE 0x03 - Temperature Sensor and Set Point + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|SP |Set Point (linear) |value |0.0-255.0 ↔ 0.0-255.0 % | +|TMP |Temperature (linear) |value |255.0-0.0 ↔ 0.0-40.0 °C | + + +##### RORG 0xA5 - FUNC 0x10 - TYPE 0x05 - Temperature Sensor, Set Point and Occupancy Control + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|SP |Set Point (linear) |value |0.0-255.0 ↔ 0.0-255.0 % | +|TMP |Temperature (linear) |value |255.0-0.0 ↔ 0.0-40.0 °C | +|OCC |Occupancy Button |enum |0 - Button pressed | +| | | |1 - Button released | + + +##### RORG 0xA5 - FUNC 0x10 - TYPE 0x06 - Temperature Sensor, Set Point and Day/Night Control + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|SP |Set Point (linear) |value |0.0-255.0 ↔ 0.0-255.0 % | +|TMP |Temperature (linear) |value |255.0-0.0 ↔ 0.0-40.0 °C | +|SLSW |Slide switch |enum |0 - Position I / Night / Off | +| | | |1 - Position O / Day / On | + + +##### RORG 0xA5 - FUNC 0x10 - TYPE 0x10 - Temperature and Humidity Sensor, Set Point and Occupancy Control + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|SP |Set Point (linear) |value |0.0-255.0 ↔ 0.0-255.0 | +|HUM |Rel. Humidity (linear) |value |0.0-250.0 ↔ 0.0-100.0 % | +|TMP |Temperature (linear) |value |0.0-250.0 ↔ 0.0-40.0 °C | +|OCC |Occupancy Button |enum |0 - Button pressed | +| | | |1 - Button released | + + +##### RORG 0xA5 - FUNC 0x10 - TYPE 0x12 - Temperature and Humidity Sensor and Set Point + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|SP |Set Point (linear) |value |0.0-255.0 ↔ 0.0-255.0 | +|HUM |Rel. Humidity (linear) |value |0.0-250.0 ↔ 0.0-100.0 % | +|TMP |Temperature (linear) |value |0.0-250.0 ↔ 0.0-40.0 °C | + + + +##### RORG 0xA5 - FUNC 0x11 - TYPE 0x02 - Temperature Controller Output + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|CVAR |Actual value of controller |value |0.0-255.0 ↔ 0.0-100.0 % | +|FAN |Actual value of fan |enum |0 - State 0 Manual | +| | | |1 - State 1 Manual | +| | | |2 - State 2 Manual | +| | | |3 - State 3 Manual | +| | | |16 - State 0 Automatic | +| | | |17 - State 1 Automatic | +| | | |18 - State 2 Automatic | +| | | |19 - State 3 Automatic | +| | | |255 - Not Available | +|ASP |Actual Setpoint |value |0.0-255.0 ↔ 0.0-51.2 C | +|ALR |Alarm |enum |0 - No alarm | +| | | |1 - Alarm | +|CTM |Controller mode |enum |1 - Heating | +| | | |2 - Cooling | +| | | |3 - Off | +|CTS |Controller state |enum |0 - Automatic | +| | | |1 - Override | +|ERH |Energy hold-off |enum |0 - Normal | +| | | |1 - Energy hold-off / Dew point | +|RO |Room occupancy |enum |0 - Occupied | +| | | |1 - Unoccupied | +| | | |2 - StandBy | +| | | |3 - Frost | + + +##### RORG 0xA5 - FUNC 0x11 - TYPE 0x03 - Blind Status + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|BSP |Blind/shutter position |value |0.0-100.0 ↔ 0.0-100.0 % | +|AS |Angle sign |enum |0 - Positive sign | +| | | |1 - Negative sign | +|AN |Angle in 2 degrees steps |value |0.0-90.0 ↔ 0.0-180.0 degrees | +|PVF |Position value flag |enum |0 - No position value available | +| | | |1 - Position value available | +|AVF |Angle value flag |enum |0 - No Angle value available | +| | | |1 - Angle value available | +|ES |Error state |enum |0 - No error present | +| | | |1 - End-positions are not configured | +| | | |2 - Internal failure | +| | | |3 - Not used | +|EP |End position |enum |0 - No End-position available | +| | | |1 - No End-position reached | +| | | |2 - Blind fully open | +| | | |3 - Blind fully closed | +|ST |Status |enum |0 - No status available | +| | | |1 - Blind is stopped | +| | | |2 - Blind opens | +| | | |3 - Blind closes | +|SM |Service mode |enum |0 - Normal mode | +| | | |1 - Service mode activated | +|MOTP |Mode of the position |enum |0 - Normal mode | +| | | |1 - Inverse ode | + + + +##### RORG 0xA5 - FUNC 0x14 - TYPE 0x01 - Single Input Contact (Window/Door), Supply voltage monitor + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|SVC |Supply voltage / super cap. (linear); 251 - 255 reserved for error code|value |0.0-250.0 ↔ 0.0-5.0 V | +|CT |Contact |enum |1 - open | +| | | |0 - closed | + + + +##### RORG 0xA5 - FUNC 0x20 - TYPE 0x01 - Battery Powered Actuator (BI-DIR) + +###### direction: 1 +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|CV |Current Value |value |0.0-100.0 ↔ 0.0-100.0 % | +|SO |Service On |enum |0 - off | +| | | |1 - on | +|ENIE |Energy input enabled |enum |0 - false | +| | | |1 - true | +|ES |Energy storage sufficiently charged |enum |0 - false | +| | | |1 - true | +|BCAP |Battery capacity; change battery next days |enum |0 - false | +| | | |1 - true | +|CCO |Contact, cover open |enum |0 - false | +| | | |1 - true | +|FTS |Failure Temperature sensor, out of range |enum |0 - false | +| | | |1 - true | +|DWO |Detection, window open |enum |0 - false | +| | | |1 - true | +|ACO |Actuator obstructed |enum |0 - false | +| | | |1 - true | +|TMP |Temperature (linear) |value |0.0-255.0 ↔ 0.0-40.0 °C | + +###### direction: 2 +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|SP |Valve Position or Temperature Setpoint |value |0.0-100.0 ↔ 0.0-100.0 % | +|TMP |Temperature from RCU |value |0.0-255.0 ↔ 0.0-40.0 °C | +|RIN |Run init sequence |enum |0 - false | +| | | |1 - true | +|LFS |Lift set |enum |0 - false | +| | | |1 - true | +|VO |Valve open / maintenance |enum |0 - false | +| | | |1 - true | +|VC |Valve closed |enum |0 - false | +| | | |1 - true | +|SB |Summer bit, Reduction of energy consumption |enum |0 - false | +| | | |1 - true | +|SPS |Set point selection |enum |0 - Valve position | +| | | |1 - Temperature set point | +|SPN |Set point inverse |enum |0 - false | +| | | |1 - true | +|RCU |Select function |enum |0 - RCU | +| | | |1 - service on | + + + +##### RORG 0xA5 - FUNC 0x12 - TYPE 0x01 - Electricity + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|MR |current value in W or cumulative value in kWh |value |0.0-16777215.0 ↔ 0.0-16777215.0 | +|TI |Tariff info |value |0.0-15.0 ↔ 0.0-15.0 | +|DT |Current value or cumulative value |enum |0 - kWh | +| | | |1 - W | +|DIV |Divisor for value |enum |0 - x/1 | +| | | |1 - x/10 | +| | | |2 - x/100 | +| | | |3 - x/1000 | + + + +##### RORG 0xA5 - FUNC 0x30 - TYPE 0x03 - Digital Inputs, Wake and Temperature + +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|TMP |Temperature (linear) |value |255.0-0.0 ↔ 0.0-40.0 °C | +|WA0 |Value of wake signal |enum |0 - Low | +| | | |1 - High | +|DI3 |Digital Input 3 |enum |0 - Low | +| | | |1 - High | +|DI2 |Digital Input 2 |enum |0 - Low | +| | | |1 - High | +|DI1 |Digital Input 1 |enum |0 - Low | +| | | |1 - High | +|DI0 |Digital Input 0 |enum |0 - Low | +| | | |1 - High | + + + +##### RORG 0xA5 - FUNC 0x38 - TYPE 0x08 - Gateway + +###### command: 1 +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|COM |Command ID |enum |0-13 - Command ID {value} | +|TIM |Time in 1/10 seconds. 0 = no time specifed |value |1.0-65535.0 ↔ 0.1-6553.5 s | +|LCK |Lock for duration time if time >0, unlimited time of no time specified. Locking may be cleared with "unlock". During lock phase no other commands will be accepted or executed|enum |0 - Unlock | +| | | |1 - Lock | +|DEL |Delay or duration (if Time > 0); 0 = Duration (Execute switching command immediately and switch back after duration) 1 = Delay (Execute switching command after delay)|enum |0 - Duration | +| | | |1 - Delay | +|SW |Switching command ON/OFF |enum |0 - Off | +| | | |1 - On | + +###### command: 2 +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|COM |Command ID |enum |0-13 - Command ID {value} | +|EDIM |Dimming value (absolute [0...255] or relative [0...100])|value |0.0-255.0 ↔ 0.0-255.0 % | +|RMP |Ramping time in seconds, 0 = no ramping, 1...255 = seconds to 100%|value |0.0-255.0 ↔ 0.0-255.0 s | +|EDIMR |Dimming Range |enum |0 - Absolute value | +| | | |1 - Relative value | +|STR |Store final value |enum |0 - No | +| | | |1 - Yes | +|SW |Switching command |enum |0 - Off | +| | | |1 - On | + + + +### VLD Telegram (0xD2) +##### RORG 0xD2 - FUNC 0x01 - TYPE 0x01 - Electronic switch with Local Control + +###### command: 4 +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|PF |Power Failure |enum |0 - Power Failure Detection disabled/not supported | +| | | |1 - Power Failure Detection enabled | +|PFD |Power Failure Detection |enum |0 - Power Failure Detection not detected/not supported/disabled | +| | | |1 - Power Failure Detection Detected | +|CMD |Command indentifier |enum |0-13 - Command ID {value} | +|OC |Over current switch off |enum |0 - Over current switch off: ready / not supported | +| | | |1 - Over current switch off: executed | +|EL |Error level |enum |0 - Error level 0: hardware OK | +| | | |1 - Error level 1: hardware warning | +| | | |2 - Error level 2: hardware failure | +| | | |3 - Error level not supported | +|IO |I/O channel |enum |0-29 - Output channel {value} (to load) | +| | | |30 - Not applicable, do not use | +| | | |31 - Input channel (from mains supply) | +|LC |Local control |enum |0 - Local control disabled / not supported | +| | | |1 - Local control enabled | +|OV |Output value |enum |0 - Output value 0% or OFF | +| | | |1-100 - Output value {value}% or ON | +| | | |101-126 - Not used | +| | | |127 - output value not valid / not set | + +###### command: 1 +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|CMD |Command indentifier |enum |0-13 - Command ID {value} | +|DV |Dim value |enum |0 - Switch to new output value | +| | | |1 - Dim to new output level - dim timer 1 | +| | | |2 - Dim to new output level - dim timer 2 | +| | | |3 - Dim to new output level - dim timer 3 | +| | | |4 - Stop dimming | +|IO |I/O channel |enum |0-29 - Output channel {value} (to load) | +| | | |30 - All output channels supported by the device | +| | | |31 - Input channel (from mains supply) | +|OV |Output value |enum |0 - Output value 0% or OFF | +| | | |1-100 - Output value {value}% or ON | +| | | |101-126 - Not used | +| | | |127 - output value not valid / not set | + + + +##### RORG 0xD2 - FUNC 0x05 - TYPE 0x00 - Type 0x00 + +###### command: 1 +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|POS |Vertical position |enum |0-100 - Output position {value}% | +| | | |127 - Do not change | +|ANG |Rotation angle |enum |0-100 - Output angle {value}% | +| | | |127 - Do not change | +|REPO |Repositioning |enum |0 - Go directly to POS/ANG | +| | | |1 - Go up (0%), then to POS/ANG | +| | | |2 - Go down (100%), then to POS/ANG | +| | | |3 - Reserved | +|LOCK |Locking modes |enum |0 - Do not change | +| | | |1 - Set blockage mode | +| | | |2 - Set alarm mode | +| | | |3 - Reserved | +| | | |4 - Reserved | +| | | |5 - Reserved | +| | | |6 - Reserved | +| | | |7 - Deblockage | +|CHN |Channel |enum |0 - Channel 1 | +|CMD |Command Id |enum |0-5 - Command ID {value} | + +###### command: 2 +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|CHN |Channel |enum |0 - Channel 1 | +|CMD |Command Id |enum |0-5 - Command ID {value} | + +###### command: 3 +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|CHN |Channel |enum |0 - Channel 1 | +|CMD |Command Id |enum |0-5 - Command ID {value} | + +###### command: 4 +|shortcut|description |type |values | +|--------|--------------------------------------------------|--------|---- | +|POS |Vertical position |enum |0-100 - Output position {value}% | +| | | |127 - Do not change | +|ANG |Rotation angle |enum |0-100 - Output angle {value}% | +| | | |127 - Do not change | +|REPO |Repositioning |enum |0 - Go directly to POS/ANG | +| | | |1 - Go up (0%), then to POS/ANG | +| | | |2 - Go down (100%), then to POS/ANG | +| | | |3 - Reserved | +|LOCK |Locking modes |enum |0 - Do not change | +| | | |1 - Set blockage mode | +| | | |2 - Set alarm mode | +| | | |3 - Reserved | +| | | |4 - Reserved | +| | | |5 - Reserved | +| | | |6 - Reserved | +| | | |7 - Deblockage | +|CHN |Channel |enum |0 - Channel 1 | +|CMD |Command Id |enum |0-5 - Command ID {value} | + + + diff --git a/enocean/enocean/__init__.py b/enocean/enocean/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/enocean/enocean/communicators/__init__.py b/enocean/enocean/communicators/__init__.py new file mode 100644 index 0000000..7408bbe --- /dev/null +++ b/enocean/enocean/communicators/__init__.py @@ -0,0 +1,4 @@ +''' Provider for different Communicator -classes for EnOcean. ''' +from enocean.communicators.communicator import Communicator +from enocean.communicators.serialcommunicator import SerialCommunicator +from enocean.communicators.tcpcommunicator import TCPCommunicator diff --git a/enocean/enocean/communicators/communicator.py b/enocean/enocean/communicators/communicator.py new file mode 100644 index 0000000..9aff7c0 --- /dev/null +++ b/enocean/enocean/communicators/communicator.py @@ -0,0 +1,116 @@ +# -*- encoding: utf-8 -*- +from __future__ import print_function, unicode_literals, division, absolute_import +import logging +import datetime + +import threading +try: + import queue +except ImportError: + import Queue as queue +from enocean.protocol.packet import Packet, UTETeachInPacket +from enocean.protocol.constants import PACKET, PARSE_RESULT, RETURN_CODE + + +class Communicator(threading.Thread): + ''' + Communicator base-class for EnOcean. + Not to be used directly, only serves as base class for SerialCommunicator etc. + ''' + logger = logging.getLogger('enocean.communicators.Communicator') + + def __init__(self, callback=None, teach_in=True): + super(Communicator, self).__init__() + # Create an event to stop the thread + self._stop_flag = threading.Event() + # Input buffer + self._buffer = [] + # Setup packet queues + self.transmit = queue.Queue() + self.receive = queue.Queue() + # Set the callback method + self.__callback = callback + # Internal variable for the Base ID of the module. + self._base_id = None + # Should new messages be learned automatically? Defaults to True. + # TODO: Not sure if we should use CO_WR_LEARNMODE?? + self.teach_in = teach_in + + def _get_from_send_queue(self): + ''' Get message from send queue, if one exists ''' + try: + packet = self.transmit.get(block=False) + self.logger.info('Sending packet') + self.logger.debug(packet) + return packet + except queue.Empty: + pass + return None + + def send(self, packet): + if not isinstance(packet, Packet): + self.logger.error('Object to send must be an instance of Packet') + return False + self.transmit.put(packet) + return True + + def stop(self): + self._stop_flag.set() + + def parse(self): + ''' Parses messages and puts them to receive queue ''' + # Loop while we get new messages + while True: + status, self._buffer, packet = Packet.parse_msg(self._buffer) + # If message is incomplete -> break the loop + if status == PARSE_RESULT.INCOMPLETE: + return status + + # If message is OK, add it to receive queue or send to the callback method + if status == PARSE_RESULT.OK and packet: + packet.received = datetime.datetime.now() + + if isinstance(packet, UTETeachInPacket) and self.teach_in: + response_packet = packet.create_response_packet(self.base_id) + self.logger.info('Sending response to UTE teach-in.') + self.send(response_packet) + + if self.__callback is None: + self.receive.put(packet) + else: + self.__callback(packet) + self.logger.debug(packet) + + @property + def base_id(self): + ''' Fetches Base ID from the transmitter, if required. Otherwise returns the currently set Base ID. ''' + # If base id is already set, return it. + if self._base_id is not None: + return self._base_id + + # Send COMMON_COMMAND 0x08, CO_RD_IDBASE request to the module + self.send(Packet(PACKET.COMMON_COMMAND, data=[0x08])) + # Loop over 10 times, to make sure we catch the response. + # Thanks to timeout, shouldn't take more than a second. + # Unfortunately, all other messages received during this time are ignored. + for i in range(0, 10): + try: + packet = self.receive.get(block=True, timeout=0.1) + # We're only interested in responses to the request in question. + if packet.packet_type == PACKET.RESPONSE and packet.response == RETURN_CODE.OK and len(packet.response_data) == 4: # noqa: E501 + # Base ID is set in the response data. + self._base_id = packet.response_data + # Put packet back to the Queue, so the user can also react to it if required... + self.receive.put(packet) + break + # Put other packets back to the Queue. + self.receive.put(packet) + except queue.Empty: + continue + # Return the current Base ID (might be None). + return self._base_id + + @base_id.setter + def base_id(self, base_id): + ''' Sets the Base ID manually, only for testing purposes. ''' + self._base_id = base_id diff --git a/enocean/enocean/communicators/serialcommunicator.py b/enocean/enocean/communicators/serialcommunicator.py new file mode 100644 index 0000000..fe33cf8 --- /dev/null +++ b/enocean/enocean/communicators/serialcommunicator.py @@ -0,0 +1,43 @@ +# -*- encoding: utf-8 -*- +from __future__ import print_function, unicode_literals, division, absolute_import +import logging +import serial +import time + +from enocean.communicators.communicator import Communicator + + +class SerialCommunicator(Communicator): + ''' Serial port communicator class for EnOcean radio ''' + logger = logging.getLogger('enocean.communicators.SerialCommunicator') + + def __init__(self, port='/dev/ttyS4', callback=None): + super(SerialCommunicator, self).__init__(callback) + # Initialize serial port + self.__ser = serial.Serial(port, 57600, timeout=0.1) + + def run(self): + self.logger.info('SerialCommunicator started') + while not self._stop_flag.is_set(): + # If there's messages in transmit queue + # send them + while True: + packet = self._get_from_send_queue() + if not packet: + break + try: + self.__ser.write(bytearray(packet.build())) + except serial.SerialException: + self.stop() + + # Read chars from serial port as hex numbers + try: + self._buffer.extend(bytearray(self.__ser.read(16))) + except serial.SerialException: + self.logger.error('Serial port exception! (device disconnected or multiple access on port?)') + self.stop() + self.parse() + time.sleep(0) + + self.__ser.close() + self.logger.info('SerialCommunicator stopped') diff --git a/enocean/enocean/communicators/tcpcommunicator.py b/enocean/enocean/communicators/tcpcommunicator.py new file mode 100644 index 0000000..239012d --- /dev/null +++ b/enocean/enocean/communicators/tcpcommunicator.py @@ -0,0 +1,44 @@ +# -*- encoding: utf-8 -*- +from __future__ import print_function, unicode_literals, division, absolute_import +import logging +import socket + +from enocean.communicators.communicator import Communicator + + +class TCPCommunicator(Communicator): + ''' Socket communicator class for EnOcean radio ''' + logger = logging.getLogger('enocean.communicators.TCPCommunicator') + + def __init__(self, host='', port=9637): + super(TCPCommunicator, self).__init__() + self.host = host + self.port = port + + def run(self): + self.logger.info('TCPCommunicator started') + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind((self.host, self.port)) + sock.listen(5) + sock.settimeout(0.5) + + while not self._stop_flag.is_set(): + try: + (client, addr) = sock.accept() + except socket.timeout: + continue + self.logger.debug('Client "%s" connected' % (addr)) + client.settimeout(0.5) + while True and not self._stop_flag.is_set(): + try: + data = client.recv(2048) + except socket.timeout: + break + if not data: + break + self._buffer.extend(bytearray(data)) + self.parse() + client.close() + self.logger.debug('Client disconnected') + sock.close() + self.logger.info('TCPCommunicator stopped') diff --git a/enocean/enocean/communicators/tests/test_communicator.py b/enocean/enocean/communicators/tests/test_communicator.py new file mode 100644 index 0000000..c4d1d54 --- /dev/null +++ b/enocean/enocean/communicators/tests/test_communicator.py @@ -0,0 +1,92 @@ +# -*- encoding: utf-8 -*- +from __future__ import print_function, unicode_literals, division, absolute_import + +from enocean.communicators.communicator import Communicator +from enocean.protocol.packet import Packet, RadioPacket +from enocean.protocol.constants import PACKET +from enocean.decorators import timing + + +@timing(1000) +def test_buffer(): + ''' Test buffer parsing for Communicator ''' + data = bytearray([ + 0x55, + 0x00, 0x0A, 0x07, 0x01, + 0xEB, + 0xA5, 0x00, 0x00, 0x55, 0x08, 0x01, 0x81, 0xB7, 0x44, 0x00, + 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x2D, 0x00, + 0x75 + ]) + com = Communicator() + com._buffer.extend(data[0:5]) + com.parse() + assert com.receive.qsize() == 0 + com._buffer.extend(data[5:]) + com.parse() + assert com.receive.qsize() == 1 + + +@timing(1000) +def test_send(): + ''' Test sending packets to Communicator ''' + com = Communicator() + assert com.send('AJSNDJASNDJANSD') is False + assert com.transmit.qsize() == 0 + assert com._get_from_send_queue() is None + assert com.send(Packet(PACKET.COMMON_COMMAND, [0x08])) is True + assert com.transmit.qsize() == 1 + assert isinstance(com._get_from_send_queue(), Packet) + + +def test_stop(): + com = Communicator() + com.stop() + assert com._stop_flag.is_set() + + +def test_callback(): + def callback(packet): + assert isinstance(packet, RadioPacket) + + data = bytearray([ + 0x55, + 0x00, 0x0A, 0x07, 0x01, + 0xEB, + 0xA5, 0x00, 0x00, 0x55, 0x08, 0x01, 0x81, 0xB7, 0x44, 0x00, + 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x2D, 0x00, + 0x75 + ]) + + com = Communicator(callback=callback) + com._buffer.extend(data) + com.parse() + assert com.receive.qsize() == 0 + + +def test_base_id(): + com = Communicator() + assert com.base_id is None + + other_data = bytearray([ + 0x55, + 0x00, 0x0A, 0x07, 0x01, + 0xEB, + 0xA5, 0x00, 0x00, 0x55, 0x08, 0x01, 0x81, 0xB7, 0x44, 0x00, + 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x2D, 0x00, + 0x75 + ]) + + response_data = bytearray([ + 0x55, + 0x00, 0x05, 0x00, 0x02, + 0xCE, + 0x00, 0xFF, 0x87, 0xCA, 0x00, + 0xA3 + ]) + + com._buffer.extend(other_data) + com._buffer.extend(response_data) + com.parse() + assert com.base_id == [0xFF, 0x87, 0xCA, 0x00] + assert com.receive.qsize() == 2 diff --git a/enocean/enocean/communicators/utils.py b/enocean/enocean/communicators/utils.py new file mode 100644 index 0000000..646841d --- /dev/null +++ b/enocean/enocean/communicators/utils.py @@ -0,0 +1,10 @@ +# -*- encoding: utf-8 -*- +from __future__ import print_function, unicode_literals, division, absolute_import +import socket + + +def send_to_tcp_socket(host, port, packet): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((host, port)) + sock.send(str(bytearray(packet.build()))) + sock.close() diff --git a/enocean/enocean/consolelogger.py b/enocean/enocean/consolelogger.py new file mode 100644 index 0000000..de3e01d --- /dev/null +++ b/enocean/enocean/consolelogger.py @@ -0,0 +1,22 @@ +# -*- encoding: utf-8 -*- +from __future__ import print_function, unicode_literals, division, absolute_import +import logging +import logging.handlers + + +def init_logging(level=logging.DEBUG, log_to_file=False, logsize=1024, logcount=5): + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + + logger = logging.getLogger('enocean') + logger.setLevel(level) + + stream_handler = logging.StreamHandler() + stream_handler.setLevel(level) + stream_handler.setFormatter(formatter) + logger.addHandler(stream_handler) + + if log_to_file: + file_handler = logging.handlers.RotatingFileHandler('enocean.log', 'a', logsize*1000, logcount) + file_handler.setLevel(level) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) diff --git a/enocean/enocean/decorators.py b/enocean/enocean/decorators.py new file mode 100644 index 0000000..e953709 --- /dev/null +++ b/enocean/enocean/decorators.py @@ -0,0 +1,38 @@ +# -*- encoding: utf-8 -*- +from __future__ import print_function, unicode_literals, division +import time +import functools +from os import environ + + +def timing(rounds=1, limit=None): + ''' + Wrapper to implement simple timing of tests. + Allows running multiple rounds to calculate average time. + Limit (in milliseconds) can be set to assert, if (average) duration is too high. + ''' + def decorator(method): + @functools.wraps(method) + def f(): + if rounds == 1: + start = time.time() + method() + duration = time.time() - start + else: + start = time.time() + for i in range(rounds): + method() + duration = (time.time() - start) / rounds + # Use milliseconds for duration counter + duration = duration * 1e3 + + print('Test "%s.%s" took %.06f ms.' % (method.__module__, method.__name__, duration)) + if limit is not None: + assert limit > duration, 'Timing failure: %.06f > %.06f' % (duration, limit) + + # Run tests with timings, only if WITH_TIMINGS environment variable is set. + # This is because tests with multiple rounds can take long to process. + if environ.get('WITH_TIMINGS', None) == '1': + return f + return method + return decorator diff --git a/enocean/enocean/protocol/EEP.xml b/enocean/enocean/protocol/EEP.xml new file mode 100644 index 0000000..b7d79e2 --- /dev/null +++ b/enocean/enocean/protocol/EEP.xml @@ -0,0 +1,1792 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 255 + 0 + + + -40.000000 + 0.000000 + + + + + + + + + 255 + 0 + + + -30.000000 + 10.000000 + + + + + + + + + 255 + 0 + + + -20.000000 + 20.000000 + + + + + + + + + 255 + 0 + + + -10.000000 + 30.000000 + + + + + + + + + 255 + 0 + + + 0.000000 + 40.000000 + + + + + + + + + 255 + 0 + + + 10.000000 + 50.000000 + + + + + + + + + 255 + 0 + + + 20.000000 + 60.000000 + + + + + + + + + 255 + 0 + + + 30.000000 + 70.000000 + + + + + + + + + 255 + 0 + + + 40.000000 + 80.000000 + + + + + + + + + 255 + 0 + + + 50.000000 + 90.000000 + + + + + + + + + 255 + 0 + + + 60.000000 + 100.000000 + + + + + + + + + 255 + 0 + + + -60.000000 + 20.000000 + + + + + + + + + 255 + 0 + + + -50.000000 + 30.000000 + + + + + + + + + 255 + 0 + + + -40.000000 + 40.000000 + + + + + + + + + 255 + 0 + + + -30.000000 + 50.000000 + + + + + + + + + 255 + 0 + + + -20.000000 + 60.000000 + + + + + + + + + 255 + 0 + + + -10.000000 + 70.000000 + + + + + + + + + 255 + 0 + + + 0.000000 + 80.000000 + + + + + + + + + 255 + 0 + + + 10.000000 + 90.000000 + + + + + + + + + 255 + 0 + + + 20.000000 + 100.000000 + + + + + + + + + 255 + 0 + + + 30.000000 + 110.000000 + + + + + + + + + 255 + 0 + + + 40.000000 + 120.000000 + + + + + + + + + 255 + 0 + + + 50.000000 + 130.000000 + + + + + + + + + 1023 + 0 + + + -10.000000 + 41.200000 + + + + + + + + + 1023 + 0 + + + -40.000000 + 62.300000 + + + + + + + + + + + 0 + 250 + + + 0 + 100 + + + + + 0 + 250 + + + 0 + 40 + + + + + + + + + + + + + 0 + 255 + + + 0 + 100 + + + + + 0 + 1023 + + + -20 + +60 + + + + + + + + + + + + + + + 0 + 255 + + + 0.000000 + 5.100000 + + + + + 0 + 255 + + + 300.000000 + 30000.000000 + + + + + 0 + 255 + + + 600.000000 + 60000.000000 + + + + + + + + + + + + + 0 + 255 + + + 0.000000 + 5.100000 + + + + + 0 + 255 + + + 0.000000 + 510.000000 + + + + + 0 + 255 + + + 0.000000 + 1020.000000 + + + + + + + + + + + + + + + 0 + 250 + + + 0 + 5.000000 + + + + + + + + + + + + + + + 0 + 255 + + + 0 + 5.100000 + + + + + 0 + 255 + + + 0 + 510 + + + + + 0 + 255 + + + 0 + 51 + + + + + + + + + + + + + + + + + + + 0 + 200 + + + 0 + 100 + + + + + 0 + 255 + + + 0 + 2550 + + + + + 0 + 255 + + + 0 + 51 + + + + + + + + + 0 + 65535 + + + 0 + 65535 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 255 + + + 0 + 255 + + + + + 255 + 0 + + + 0 + 40 + + + + + + + + + 0 + 255 + + + 0 + 255 + + + + + 255 + 0 + + + 0 + 40 + + + + + + + + + + + + + 0 + 255 + + + 0 + 255 + + + + + 255 + 0 + + + 0 + 40 + + + + + + + + + + + + + 0 + 255 + + + 0 + 255 + + + + + 0 + 250 + + + 0 + 100 + + + + + 0 + 250 + + + 0 + 40 + + + + + + + + + + + + + 0 + 255 + + + 0 + 255 + + + + + 0 + 250 + + + 0 + 100 + + + + + 0 + 250 + + + 0 + 40 + + + + + + + + + + + 0 + 255 + + + 0 + 100 + + + + + + + + + + + + + + + + 0 + 255 + + + 0 + 51.2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 100 + + + 0 + 100 + + + + + + + + + 0 + 90 + + + 0 + 180 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 255 + + + 0 + 999 + + + + + 0 + 255 + + + -40 + 80 + + + + + 0 + 255 + + + 0 + 70 + + + + + + + + + + + + + + + 0 + 255 + + + 0 + 150 + + + + + 0 + 255 + + + 0 + 150 + + + + + 0 + 255 + + + 0 + 150 + + + + + + + + + + + 1 + 31 + + + 1 + 31 + + + + + 1 + 12 + + + 1 + 12 + + + + + 0 + 99 + + + 2000 + 2099 + + + + + + + + + + + + + + + + + + + + 0 + 23 + + + 0 + 23 + + + + + 0 + 59 + + + 0 + 59 + + + + + 0 + 59 + + + 0 + 59 + + + + + + + + + + + + + + + + + + + 0 + 180 + + + -90 + 90 + + + + + 0 + 359 + + + 0 + 359 + + + + + + + 0 + 15 + + + 0 + 15 + + + + + 0 + 15 + + + 0 + 15 + + + + + 0 + 255 + + + 0 + 255 + + + + + 0 + 255 + + + 0 + 255 + + + + + + + + + + + 0 + 250 + + + 0 + 5.0 + + + + + + + + + + + + + + + 0 + 100 + + + 0 + 100 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 255 + + + 0 + 40 + + + + + + + 0 + 100 + + + 0 + 100 + + + + + 0 + 255 + + + 0 + 40 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 16777215 + + + 0.000000 + 16777215 + + + + + 0 + 15 + + + 0 + 15 + + + + + + + + + + + + + + + + + + + + + 255 + 0 + + + 0 + 40 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 65535 + + + 0.1 + 6553.5 + + + + + + + + + + + + + + + + + + + + + + 0 + 255 + + + 0 + + 255 + + + + + 0 + 255 + + + 0 + 255 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 1000 + + + -40.0 + 60.0 + + + + + 0 + 200 + + + 0.0 + 100.0 + + + + + 0 + 100000 + + + 0 + 100000 + + + + + + + + + + 0 + 1000 + + + -2.5 + 2.5 + + + + + 0 + 1000 + + + -2.5 + 2.5 + + + + + 0 + 1000 + + + -2.5 + 2.5 + + + + + + + + + + + diff --git a/enocean/enocean/protocol/__init__.py b/enocean/enocean/protocol/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/enocean/enocean/protocol/constants.py b/enocean/enocean/protocol/constants.py new file mode 100644 index 0000000..8dd3e74 --- /dev/null +++ b/enocean/enocean/protocol/constants.py @@ -0,0 +1,148 @@ +# -*- encoding: utf-8 -*- +from __future__ import print_function, unicode_literals, division, absolute_import +from enum import IntEnum + + +# EnOceanSerialProtocol3.pdf / 12 +class PACKET(IntEnum): + RESERVED = 0x00 + # RADIO == RADIO_ERP1 + # Kept for backwards compatibility reasons, for example custom packet + # generation shouldn't be affected... + RADIO = 0x01 + RADIO_ERP1 = 0x01 + RESPONSE = 0x02 + RADIO_SUB_TEL = 0x03 + EVENT = 0x04 + COMMON_COMMAND = 0x05 + SMART_ACK_COMMAND = 0x06 + REMOTE_MAN_COMMAND = 0x07 + RADIO_MESSAGE = 0x09 + # RADIO_ADVANCED == RADIO_ERP2 + # Kept for backwards compatibility reasons + RADIO_ADVANCED = 0x0A + RADIO_ERP2 = 0x0A + RADIO_802_15_4 = 0x10 + COMMAND_2_4 = 0x11 + + +# EnOceanSerialProtocol3.pdf / 18 +class RETURN_CODE(IntEnum): + OK = 0x00 + ERROR = 0x01 + NOT_SUPPORTED = 0x02 + WRONG_PARAM = 0x03 + OPERATION_DENIED = 0x04 + + +# EnOceanSerialProtocol3.pdf / 20 +class EVENT_CODE(IntEnum): + SA_RECLAIM_NOT_SUCCESFUL = 0x01 + SA_CONFIRM_LEARN = 0x02 + SA_LEARN_ACK = 0x03 + CO_READY = 0x04 + CO_EVENT_SECUREDEVICES = 0x05 + + +# EnOcean_Equipment_Profiles_EEP_V2.61_public.pdf / 8 +class RORG(IntEnum): + UNDEFINED = 0x00 + RPS = 0xF6 + BS1 = 0xD5 + BS4 = 0xA5 + VLD = 0xD2 + MSC = 0xD1 + ADT = 0xA6 + SM_LRN_REQ = 0xC6 + SM_LRN_ANS = 0xC7 + SM_REC = 0xA7 + SYS_EX = 0xC5 + SEC = 0x30 + SEC_ENCAPS = 0x31 + UTE = 0xD4 + + +# Results for message parsing +class PARSE_RESULT(IntEnum): + OK = 0x00 + INCOMPLETE = 0x01 + CRC_MISMATCH = 0x03 + + +# Data byte indexing +# Starts from the end, so works on messages of all length. +class DB0(object): + BIT_0 = -1 + BIT_1 = -2 + BIT_2 = -3 + BIT_3 = -4 + BIT_4 = -5 + BIT_5 = -6 + BIT_6 = -7 + BIT_7 = -8 + + +class DB1(object): + BIT_0 = -9 + BIT_1 = -10 + BIT_2 = -11 + BIT_3 = -12 + BIT_4 = -13 + BIT_5 = -14 + BIT_6 = -15 + BIT_7 = -16 + + +class DB2(object): + BIT_0 = -17 + BIT_1 = -18 + BIT_2 = -19 + BIT_3 = -20 + BIT_4 = -21 + BIT_5 = -22 + BIT_6 = -23 + BIT_7 = -24 + + +class DB3(object): + BIT_0 = -25 + BIT_1 = -26 + BIT_2 = -27 + BIT_3 = -28 + BIT_4 = -29 + BIT_5 = -30 + BIT_6 = -31 + BIT_7 = -32 + + +class DB4(object): + BIT_0 = -33 + BIT_1 = -34 + BIT_2 = -35 + BIT_3 = -36 + BIT_4 = -37 + BIT_5 = -38 + BIT_6 = -39 + BIT_7 = -40 + + +class DB5(object): + BIT_0 = -41 + BIT_1 = -42 + BIT_2 = -43 + BIT_3 = -44 + BIT_4 = -45 + BIT_5 = -46 + BIT_6 = -47 + BIT_7 = -48 + + +class DB6(object): + BIT_0 = -49 + BIT_1 = -50 + BIT_2 = -51 + BIT_3 = -52 + BIT_4 = -53 + BIT_5 = -54 + BIT_6 = -55 + BIT_7 = -56 diff --git a/enocean/enocean/protocol/crc8.py b/enocean/enocean/protocol/crc8.py new file mode 100644 index 0000000..5dd9b9d --- /dev/null +++ b/enocean/enocean/protocol/crc8.py @@ -0,0 +1,41 @@ +# -*- encoding: utf-8 -*- +from __future__ import print_function, unicode_literals, division, absolute_import + +# https://gist.github.com/hypebeast/3833758 +CRC_TABLE = ( + 0x00, 0x07, 0x0e, 0x09, 0x1c, 0x1b, 0x12, 0x15, 0x38, + 0x3f, 0x36, 0x31, 0x24, 0x23, 0x2a, 0x2d, 0x70, 0x77, + 0x7e, 0x79, 0x6c, 0x6b, 0x62, 0x65, 0x48, 0x4f, 0x46, + 0x41, 0x54, 0x53, 0x5a, 0x5d, 0xe0, 0xe7, 0xee, 0xe9, + 0xfc, 0xfb, 0xf2, 0xf5, 0xd8, 0xdf, 0xd6, 0xd1, 0xc4, + 0xc3, 0xca, 0xcd, 0x90, 0x97, 0x9e, 0x99, 0x8c, 0x8b, + 0x82, 0x85, 0xa8, 0xaf, 0xa6, 0xa1, 0xb4, 0xb3, 0xba, + 0xbd, 0xc7, 0xc0, 0xc9, 0xce, 0xdb, 0xdc, 0xd5, 0xd2, + 0xff, 0xf8, 0xf1, 0xf6, 0xe3, 0xe4, 0xed, 0xea, 0xb7, + 0xb0, 0xb9, 0xbe, 0xab, 0xac, 0xa5, 0xa2, 0x8f, 0x88, + 0x81, 0x86, 0x93, 0x94, 0x9d, 0x9a, 0x27, 0x20, 0x29, + 0x2e, 0x3b, 0x3c, 0x35, 0x32, 0x1f, 0x18, 0x11, 0x16, + 0x03, 0x04, 0x0d, 0x0a, 0x57, 0x50, 0x59, 0x5e, 0x4b, + 0x4c, 0x45, 0x42, 0x6f, 0x68, 0x61, 0x66, 0x73, 0x74, + 0x7d, 0x7a, 0x89, 0x8e, 0x87, 0x80, 0x95, 0x92, 0x9b, + 0x9c, 0xb1, 0xb6, 0xbf, 0xb8, 0xad, 0xaa, 0xa3, 0xa4, + 0xf9, 0xfe, 0xf7, 0xf0, 0xe5, 0xe2, 0xeb, 0xec, 0xc1, + 0xc6, 0xcf, 0xc8, 0xdd, 0xda, 0xd3, 0xd4, 0x69, 0x6e, + 0x67, 0x60, 0x75, 0x72, 0x7b, 0x7c, 0x51, 0x56, 0x5f, + 0x58, 0x4d, 0x4a, 0x43, 0x44, 0x19, 0x1e, 0x17, 0x10, + 0x05, 0x02, 0x0b, 0x0c, 0x21, 0x26, 0x2f, 0x28, 0x3d, + 0x3a, 0x33, 0x34, 0x4e, 0x49, 0x40, 0x47, 0x52, 0x55, + 0x5c, 0x5b, 0x76, 0x71, 0x78, 0x7f, 0x6a, 0x6d, 0x64, + 0x63, 0x3e, 0x39, 0x30, 0x37, 0x22, 0x25, 0x2c, 0x2b, + 0x06, 0x01, 0x08, 0x0f, 0x1a, 0x1d, 0x14, 0x13, 0xae, + 0xa9, 0xa0, 0xa7, 0xb2, 0xb5, 0xbc, 0xbb, 0x96, 0x91, + 0x98, 0x9f, 0x8a, 0x8d, 0x84, 0x83, 0xde, 0xd9, 0xd0, + 0xd7, 0xc2, 0xc5, 0xcc, 0xcb, 0xe6, 0xe1, 0xe8, 0xef, + 0xfa, 0xfd, 0xf4, 0xf3) + + +def calc(msg): + checksum = 0 + for byte in msg: + checksum = CRC_TABLE[checksum & 0xFF ^ byte & 0xFF] + return checksum diff --git a/enocean/enocean/protocol/eep.py b/enocean/enocean/protocol/eep.py new file mode 100644 index 0000000..de5b7bb --- /dev/null +++ b/enocean/enocean/protocol/eep.py @@ -0,0 +1,233 @@ +# -*- encoding: utf-8 -*- +from __future__ import print_function, unicode_literals, division, absolute_import +import os +import logging +from sys import version_info +from collections import OrderedDict +from bs4 import BeautifulSoup +from bs4 import XMLParsedAsHTMLWarning +import warnings +warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning) +import enocean.utils +# Left as a helper +from enocean.protocol.constants import RORG # noqa: F401 + + +class EEP(object): + logger = logging.getLogger('enocean.protocol.eep') + + def __init__(self): + self.init_ok = False + self.telegrams = {} + + eep_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'EEP.xml') + try: + if version_info[0] > 2: + with open(eep_path, 'r', encoding='UTF-8') as xml_file: + self.soup = BeautifulSoup(xml_file.read(), "html.parser") + else: + with open(eep_path, 'r') as xml_file: + self.soup = BeautifulSoup(xml_file.read(), "html.parser") + self.init_ok = True + self.__load_xml() + except IOError: + # Impossible to test with the current structure? + # To be honest, as the XML is included with the library, + # there should be no possibility of ever reaching this... + self.logger.warn('Cannot load protocol file!') + self.init_ok = False + + def __load_xml(self): + self.telegrams = { + enocean.utils.from_hex_string(telegram['rorg']): { + enocean.utils.from_hex_string(function['func']): { + enocean.utils.from_hex_string(type['type'], ): type + for type in function.find_all('profile') + } + for function in telegram.find_all('profiles') + } + for telegram in self.soup.find_all('telegram') + } + + @staticmethod + def _get_raw(source, bitarray): + ''' Get raw data as integer, based on offset and size ''' + offset = int(source['offset']) + size = int(source['size']) + return int(''.join(['1' if digit else '0' for digit in bitarray[offset:offset + size]]), 2) + + @staticmethod + def _set_raw(target, raw_value, bitarray): + ''' put value into bit array ''' + offset = int(target['offset']) + size = int(target['size']) + for digit in range(size): + bitarray[offset+digit] = (raw_value >> (size-digit-1)) & 0x01 != 0 + return bitarray + + @staticmethod + def _get_rangeitem(source, raw_value): + for rangeitem in source.find_all('rangeitem'): + if raw_value in range(int(rangeitem.get('start', -1)), int(rangeitem.get('end', -1)) + 1): + return rangeitem + + def _get_value(self, source, bitarray): + ''' Get value, based on the data in XML ''' + raw_value = self._get_raw(source, bitarray) + + rng = source.find('range') + rng_min = float(rng.find('min').text) + rng_max = float(rng.find('max').text) + + scl = source.find('scale') + scl_min = float(scl.find('min').text) + scl_max = float(scl.find('max').text) + + return { + source['shortcut']: { + 'description': source.get('description'), + 'unit': source['unit'], + 'value': (scl_max - scl_min) / (rng_max - rng_min) * (raw_value - rng_min) + scl_min, + 'raw_value': raw_value, + } + } + + def _get_enum(self, source, bitarray): + ''' Get enum value, based on the data in XML ''' + raw_value = self._get_raw(source, bitarray) + + # Find value description. + value_desc = source.find('item', {'value': str(raw_value)}) or self._get_rangeitem(source, raw_value) + + return { + source['shortcut']: { + 'description': source.get('description'), + 'unit': source.get('unit', ''), + 'value': value_desc['description'].format(value=raw_value), + 'raw_value': raw_value, + } + } + + def _get_boolean(self, source, bitarray): + ''' Get boolean value, based on the data in XML ''' + raw_value = self._get_raw(source, bitarray) + return { + source['shortcut']: { + 'description': source.get('description'), + 'unit': source.get('unit', ''), + 'value': True if raw_value else False, + 'raw_value': raw_value, + } + } + + def _set_value(self, target, value, bitarray): + ''' set given numeric value to target field in bitarray ''' + # derive raw value + rng = target.find('range') + rng_min = float(rng.find('min').text) + rng_max = float(rng.find('max').text) + scl = target.find('scale') + scl_min = float(scl.find('min').text) + scl_max = float(scl.find('max').text) + raw_value = (value - scl_min) * (rng_max - rng_min) / (scl_max - scl_min) + rng_min + # store value in bitfield + return self._set_raw(target, int(raw_value), bitarray) + + def _set_enum(self, target, value, bitarray): + ''' set given enum value (by string or integer value) to target field in bitarray ''' + # derive raw value + if isinstance(value, int): + # check whether this value exists + if target.find('item', {'value': value}) or self._get_rangeitem(target, value): + # set integer values directly + raw_value = value + else: + raise ValueError('Enum value "%s" not found in EEP.' % (value)) + else: + value_item = target.find('item', {'description': value}) + if value_item is None: + raise ValueError('Enum description for value "%s" not found in EEP.' % (value)) + raw_value = int(value_item['value']) + return self._set_raw(target, raw_value, bitarray) + + @staticmethod + def _set_boolean(target, data, bitarray): + ''' set given value to target bit in bitarray ''' + bitarray[int(target['offset'])] = data + return bitarray + + def find_profile(self, bitarray, eep_rorg, rorg_func, rorg_type, direction=None, command=None): + ''' Find profile and data description, matching RORG, FUNC and TYPE ''' + if not self.init_ok: + self.logger.warn('EEP.xml not loaded!') + return None + + if eep_rorg not in self.telegrams.keys(): + self.logger.warn('Cannot find rorg %s in EEP!', hex(eep_rorg)) + return None + + if rorg_func not in self.telegrams[eep_rorg].keys(): + self.logger.warn('Cannot find rorg %s func %s in EEP!', hex(eep_rorg), hex(rorg_func)) + return None + + if rorg_type not in self.telegrams[eep_rorg][rorg_func].keys(): + self.logger.warn('Cannot find rorg %s func %s type %s in EEP!', hex(eep_rorg), hex(rorg_func), hex(rorg_type)) + return None + + profile = self.telegrams[eep_rorg][rorg_func][rorg_type] + + if command: + # multiple commands can be defined, with the command id always in same location (per RORG-FUNC-TYPE). + eep_command = profile.find('command', recursive=False) + # If commands are not set in EEP, or command is None, + # get the first data as a "best guess". + if not eep_command: + return profile.find('data', recursive=False) + + # If eep_command is defined, so should be data.command + return profile.find('data', {'command': str(command)}, recursive=False) + + # extract data description + # the direction tag is optional + if direction is None: + return profile.find('data', recursive=False) + return profile.find('data', {'direction': direction}, recursive=False) + + def get_values(self, profile, bitarray, status): + ''' Get keys and values from bitarray ''' + if not self.init_ok or profile is None: + return [], {} + + output = OrderedDict({}) + for source in profile.contents: + if not source.name: + continue + if source.name == 'value': + output.update(self._get_value(source, bitarray)) + if source.name == 'enum': + output.update(self._get_enum(source, bitarray)) + if source.name == 'status': + output.update(self._get_boolean(source, status)) + return output.keys(), output + + def set_values(self, profile, data, status, properties): + ''' Update data based on data contained in properties ''' + if not self.init_ok or profile is None: + return data, status + + for shortcut, value in properties.items(): + # find the given property from EEP + target = profile.find(shortcut=shortcut) + if not target: + # TODO: Should we raise an error? + self.logger.warning('Cannot find data description for shortcut %s', shortcut) + continue + + # update bit_data + if target.name == 'value': + data = self._set_value(target, value, data) + if target.name == 'enum': + data = self._set_enum(target, value, data) + if target.name == 'status': + status = self._set_boolean(target, value, status) + return data, status diff --git a/enocean/enocean/protocol/packet.py b/enocean/enocean/protocol/packet.py new file mode 100644 index 0000000..14fdc34 --- /dev/null +++ b/enocean/enocean/protocol/packet.py @@ -0,0 +1,435 @@ +# -*- encoding: utf-8 -*- +from __future__ import print_function, unicode_literals, division, absolute_import +import logging +from collections import OrderedDict + +import enocean.utils +from enocean.protocol import crc8 +from enocean.protocol.eep import EEP +from enocean.protocol.constants import PACKET, RORG, PARSE_RESULT, DB0, DB2, DB3, DB4, DB6 + + +class Packet(object): + ''' + Base class for Packet. + Mainly used for for packet generation and + Packet.parse_msg(buf) for parsing message. + parse_msg() returns subclass, if one is defined for the data type. + ''' + eep = EEP() + logger = logging.getLogger('enocean.protocol.packet') + + def __init__(self, packet_type, data=None, optional=None): + self.packet_type = packet_type + self.rorg = RORG.UNDEFINED + self.rorg_func = None + self.rorg_type = None + self.rorg_manufacturer = None + + self.received = None + + if not isinstance(data, list) or data is None: + self.logger.warning('Replacing Packet.data with default value.') + self.data = [] + else: + self.data = data + + if not isinstance(optional, list) or optional is None: + self.logger.warning('Replacing Packet.optional with default value.') + self.optional = [] + else: + self.optional = optional + + self.status = 0 + self.parsed = OrderedDict({}) + self.repeater_count = 0 + self._profile = None + + self.parse() + + def __str__(self): + return '0x%02X %s %s %s' % ( + self.packet_type, + [hex(o) for o in self.data], + [hex(o) for o in self.optional], + self.parsed) + + def __unicode__(self): + return self.__str__() + + def __eq__(self, other): + return self.packet_type == other.packet_type and self.rorg == other.rorg \ + and self.data == other.data and self.optional == other.optional + + @property + def _bit_data(self): + # First and last 5 bits are always defined, so the data we're modifying is between them... + # TODO: This is valid for the packets we're currently manipulating. + # Needs the redefinition of Packet.data -> Packet.message. + # Packet.data would then only have the actual, documented data-bytes. + # Packet.message would contain the whole message. + # See discussion in issue #14 + return enocean.utils.to_bitarray(self.data[1:len(self.data) - 5], (len(self.data) - 6) * 8) + + @_bit_data.setter + def _bit_data(self, value): + # The same as getting the data, first and last 5 bits are ommitted, as they are defined... + for byte in range(len(self.data) - 6): + self.data[byte+1] = enocean.utils.from_bitarray(value[byte*8:(byte+1)*8]) + + # # COMMENTED OUT, AS NOTHING TOUCHES _bit_optional FOR NOW. + # # Thus, this is also untested. + # @property + # def _bit_optional(self): + # return enocean.utils.to_bitarray(self.optional, 8 * len(self.optional)) + + # @_bit_optional.setter + # def _bit_optional(self, value): + # if self.rorg in [RORG.RPS, RORG.BS1]: + # self.data[1] = enocean.utils.from_bitarray(value) + # if self.rorg == RORG.BS4: + # for byte in range(4): + # self.data[byte+1] = enocean.utils.from_bitarray(value[byte*8:(byte+1)*8]) + + @property + def _bit_status(self): + return enocean.utils.to_bitarray(self.status) + + @_bit_status.setter + def _bit_status(self, value): + self.status = enocean.utils.from_bitarray(value) + + @staticmethod + def parse_msg(buf): + ''' + Parses message from buffer. + returns: + - PARSE_RESULT + - remaining buffer + - Packet -object (if message was valid, else None) + ''' + # If the buffer doesn't contain 0x55 (start char) + # the message isn't needed -> ignore + if 0x55 not in buf: + return PARSE_RESULT.INCOMPLETE, [], None + + # Valid buffer starts from 0x55 + # Convert to list, as index -method isn't defined for bytearray + buf = [ord(x) if not isinstance(x, int) else x for x in buf[list(buf).index(0x55):]] + try: + data_len = (buf[1] << 8) | buf[2] + opt_len = buf[3] + except IndexError: + # If the fields don't exist, message is incomplete + return PARSE_RESULT.INCOMPLETE, buf, None + + # Header: 6 bytes, data, optional data and data checksum + msg_len = 6 + data_len + opt_len + 1 + if len(buf) < msg_len: + # If buffer isn't long enough, the message is incomplete + return PARSE_RESULT.INCOMPLETE, buf, None + + msg = buf[0:msg_len] + buf = buf[msg_len:] + + packet_type = msg[4] + data = msg[6:6 + data_len] + opt_data = msg[6 + data_len:6 + data_len + opt_len] + + # Check CRCs for header and data + if msg[5] != crc8.calc(msg[1:5]): + # Fail if doesn't match message + Packet.logger.error('Header CRC error!') + # Return CRC_MISMATCH + return PARSE_RESULT.CRC_MISMATCH, buf, None + if msg[6 + data_len + opt_len] != crc8.calc(msg[6:6 + data_len + opt_len]): + # Fail if doesn't match message + Packet.logger.error('Data CRC error!') + # Return CRC_MISMATCH + return PARSE_RESULT.CRC_MISMATCH, buf, None + + # If we got this far, everything went ok (?) + if packet_type == PACKET.RADIO_ERP1: + # Need to handle UTE Teach-in here, as it's a separate packet type... + if data[0] == RORG.UTE: + packet = UTETeachInPacket(packet_type, data, opt_data) + else: + packet = RadioPacket(packet_type, data, opt_data) + elif packet_type == PACKET.RESPONSE: + packet = ResponsePacket(packet_type, data, opt_data) + elif packet_type == PACKET.EVENT: + packet = EventPacket(packet_type, data, opt_data) + else: + packet = Packet(packet_type, data, opt_data) + + return PARSE_RESULT.OK, buf, packet + + @staticmethod + def create(packet_type, rorg, rorg_func, rorg_type, direction=None, command=None, + destination=None, + sender=None, + learn=False, **kwargs): + ''' + Creates an packet ready for sending. + Uses rorg, rorg_func and rorg_type to determine the values set based on EEP. + Additional arguments (**kwargs) are used for setting the values. + + Currently only supports: + - PACKET.RADIO_ERP1 + - RORGs RPS, BS1, BS4, VLD. + + TODO: + - Require sender to be set? Would force the "correct" sender to be set. + - Do we need to set telegram control bits? + Might be useful for acting as a repeater? + ''' + + if packet_type != PACKET.RADIO_ERP1: + # At least for now, only support PACKET.RADIO_ERP1. + raise ValueError('Packet type not supported by this function.') + + if rorg not in [RORG.RPS, RORG.BS1, RORG.BS4, RORG.VLD]: + # At least for now, only support these RORGS. + raise ValueError('RORG not supported by this function.') + + if destination is None: + Packet.logger.warning('Replacing destination with broadcast address.') + destination = [0xFF, 0xFF, 0xFF, 0xFF] + + # TODO: Should use the correct Base ID as default. + # Might want to change the sender to be an offset from the actual address? + if sender is None: + Packet.logger.warning('Replacing sender with default address.') + sender = [0xDE, 0xAD, 0xBE, 0xEF] + + if not isinstance(destination, list) or len(destination) != 4: + raise ValueError('Destination must a list containing 4 (numeric) values.') + + if not isinstance(sender, list) or len(sender) != 4: + raise ValueError('Sender must a list containing 4 (numeric) values.') + + packet = Packet(packet_type, data=[], optional=[]) + packet.rorg = rorg + packet.data = [packet.rorg] + # Select EEP at this point, so we know how many bits we're dealing with (for VLD). + packet.select_eep(rorg_func, rorg_type, direction, command) + + # Initialize data depending on the profile. + if rorg in [RORG.RPS, RORG.BS1]: + packet.data.extend([0]) + elif rorg == RORG.BS4: + packet.data.extend([0, 0, 0, 0]) + else: + packet.data.extend([0] * int(packet._profile.get('bits', '1'))) + packet.data.extend(sender) + packet.data.extend([0]) + # Always use sub-telegram 3, maximum dbm (as per spec, when sending), + # and no security (security not supported as per EnOcean Serial Protocol). + packet.optional = [3] + destination + [0xFF] + [0] + + if command: + # Set CMD to command, if applicable.. Helps with VLD. + kwargs['CMD'] = command + + packet.set_eep(kwargs) + if rorg in [RORG.BS1, RORG.BS4] and not learn: + if rorg == RORG.BS1: + packet.data[1] |= (1 << 3) + if rorg == RORG.BS4: + packet.data[4] |= (1 << 3) + packet.data[-1] = packet.status + + # Parse the built packet, so it corresponds to the received packages + # For example, stuff like RadioPacket.learn should be set. + packet = Packet.parse_msg(packet.build())[2] + packet.rorg = rorg + packet.parse_eep(rorg_func, rorg_type, direction, command) + return packet + + def parse(self): + ''' Parse data from Packet ''' + # Parse status from messages + if self.rorg in [RORG.RPS, RORG.BS1, RORG.BS4]: + self.status = self.data[-1] + if self.rorg == RORG.VLD: + self.status = self.optional[-1] + + if self.rorg in [RORG.RPS, RORG.BS1, RORG.BS4]: + # These message types should have repeater count in the last for bits of status. + self.repeater_count = enocean.utils.from_bitarray(self._bit_status[4:]) + return self.parsed + + def select_eep(self, rorg_func, rorg_type, direction=None, command=None): + ''' Set EEP based on FUNC and TYPE ''' + # set EEP profile + self.rorg_func = rorg_func + self.rorg_type = rorg_type + self._profile = self.eep.find_profile(self._bit_data, self.rorg, rorg_func, rorg_type, direction, command) + return self._profile is not None + + def parse_eep(self, rorg_func=None, rorg_type=None, direction=None, command=None): + ''' Parse EEP based on FUNC and TYPE ''' + # set EEP profile, if demanded + if rorg_func is not None and rorg_type is not None: + self.select_eep(rorg_func, rorg_type, direction, command) + # parse data + provides, values = self.eep.get_values(self._profile, self._bit_data, self._bit_status) + self.parsed.update(values) + return list(provides) + + def set_eep(self, data): + ''' Update packet data based on EEP. Input data is a dictionary with keys corresponding to the EEP. ''' + self._bit_data, self._bit_status = self.eep.set_values(self._profile, self._bit_data, self._bit_status, data) + + def build(self): + ''' Build Packet for sending to EnOcean controller ''' + data_length = len(self.data) + ords = [0x55, (data_length >> 8) & 0xFF, data_length & 0xFF, len(self.optional), int(self.packet_type)] + ords.append(crc8.calc(ords[1:5])) + ords.extend(self.data) + ords.extend(self.optional) + ords.append(crc8.calc(ords[6:])) + return ords + + +class RadioPacket(Packet): + destination = [0xFF, 0xFF, 0xFF, 0xFF] + dBm = 0 + sender = [0xFF, 0xFF, 0xFF, 0xFF] + learn = True + contains_eep = False + + def __str__(self): + packet_str = super(RadioPacket, self).__str__() + return '%s->%s (%d dBm): %s' % (self.sender_hex, self.destination_hex, self.dBm, packet_str) + + @staticmethod + def create(rorg, rorg_func, rorg_type, direction=None, command=None, + destination=None, sender=None, learn=False, **kwargs): + return Packet.create(PACKET.RADIO_ERP1, rorg, rorg_func, rorg_type, + direction, command, destination, sender, learn, **kwargs) + + @property + def sender_int(self): + return enocean.utils.combine_hex(self.sender) + + @property + def sender_hex(self): + return enocean.utils.to_hex_string(self.sender) + + @property + def destination_int(self): + return enocean.utils.combine_hex(self.destination) + + @property + def destination_hex(self): + return enocean.utils.to_hex_string(self.destination) + + def parse(self): + self.destination = self.optional[1:5] + self.dBm = -self.optional[5] + self.sender = self.data[-5:-1] + # Default to learn == True, as some devices don't have a learn button + self.learn = True + + self.rorg = self.data[0] + + # parse learn bit and FUNC/TYPE, if applicable + if self.rorg == RORG.BS1: + self.learn = not self._bit_data[DB0.BIT_3] + if self.rorg == RORG.BS4: + self.learn = not self._bit_data[DB0.BIT_3] + if self.learn: + self.contains_eep = self._bit_data[DB0.BIT_7] + if self.contains_eep: + # Get rorg_func and rorg_type from an unidirectional learn packet + self.rorg_func = enocean.utils.from_bitarray(self._bit_data[DB3.BIT_7:DB3.BIT_1]) + self.rorg_type = enocean.utils.from_bitarray(self._bit_data[DB3.BIT_1:DB2.BIT_2]) + self.rorg_manufacturer = enocean.utils.from_bitarray(self._bit_data[DB2.BIT_2:DB0.BIT_7]) + self.logger.debug('learn received, EEP detected, RORG: 0x%02X, FUNC: 0x%02X, TYPE: 0x%02X, Manufacturer: 0x%02X' % (self.rorg, self.rorg_func, self.rorg_type, self.rorg_manufacturer)) # noqa: E501 + + return super(RadioPacket, self).parse() + + +class UTETeachInPacket(RadioPacket): + # Request types + TEACH_IN = 0b00 + DELETE = 0b01 + NOT_SPECIFIC = 0b10 + + # Response types + NOT_ACCEPTED = [False, False] + TEACHIN_ACCEPTED = [False, True] + DELETE_ACCEPTED = [True, False] + EEP_NOT_SUPPORTED = [True, True] + + unidirectional = False + response_expected = False + number_of_channels = 0xFF + rorg_of_eep = RORG.UNDEFINED + request_type = NOT_SPECIFIC + channel = None + + contains_eep = True + + @property + def bidirectional(self): + return not self.unidirectional + + @property + def teach_in(self): + return self.request_type != self.DELETE + + @property + def delete(self): + return self.request_type == self.DELETE + + def parse(self): + super(UTETeachInPacket, self).parse() + self.unidirectional = not self._bit_data[DB6.BIT_7] + self.response_expected = not self._bit_data[DB6.BIT_6] + self.request_type = enocean.utils.from_bitarray(self._bit_data[DB6.BIT_5:DB6.BIT_3]) + self.rorg_manufacturer = enocean.utils.from_bitarray(self._bit_data[DB3.BIT_2:DB2.BIT_7] + self._bit_data[DB4.BIT_7:DB3.BIT_7]) # noqa: E501 + self.channel = self.data[2] + self.rorg_type = self.data[5] + self.rorg_func = self.data[6] + self.rorg_of_eep = self.data[7] + if self.teach_in: + self.learn = True + return self.parsed + + def create_response_packet(self, sender_id, response=TEACHIN_ACCEPTED): + # Create data: + # - Respond with same RORG (UTE Teach-in) + # - Always use bidirectional communication, set response code, set command identifier. + # - Databytes 5 to 0 are copied from the original message + # - Set sender id and status + data = [self.rorg] + \ + [enocean.utils.from_bitarray([True, False] + response + [False, False, False, True])] + \ + self.data[2:8] + \ + sender_id + [0] + + # Always use 0x03 to indicate sending, attach sender ID, dBm, and security level + optional = [0x03] + self.sender + [0xFF, 0x00] + + return RadioPacket(PACKET.RADIO_ERP1, data=data, optional=optional) + + +class ResponsePacket(Packet): + response = 0 + response_data = [] + + def parse(self): + self.response = self.data[0] + self.response_data = self.data[1:] + return super(ResponsePacket, self).parse() + + +class EventPacket(Packet): + event = 0 + event_data = [] + + def parse(self): + self.event = self.data[0] + self.event_data = self.data[1:] + return super(EventPacket, self).parse() diff --git a/enocean/enocean/protocol/tests/test_eep.py b/enocean/enocean/protocol/tests/test_eep.py new file mode 100644 index 0000000..acdecdf --- /dev/null +++ b/enocean/enocean/protocol/tests/test_eep.py @@ -0,0 +1,268 @@ +# -*- encoding: utf-8 -*- +from __future__ import print_function, unicode_literals, division, absolute_import + +from enocean.protocol.packet import Packet +from enocean.protocol.eep import EEP +from enocean.protocol.constants import RORG +from enocean.decorators import timing + + +@timing(1000) +def test_temperature(): + ''' Tests RADIO message for EEP -profile 0xA5 0x02 0x05 ''' + status, buf, packet = Packet.parse_msg(bytearray([ + 0x55, + 0x00, 0x0A, 0x07, 0x01, + 0xEB, + 0xA5, 0x00, 0x00, 0x55, 0x08, 0x01, 0x81, 0xB7, 0x44, 0x00, + 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x2D, 0x00, + 0x75 + ])) + assert packet.parse_eep(0x02, 0x05) == ['TMP'] + assert round(packet.parsed['TMP']['value'], 1) == 26.7 + assert packet.parsed['TMP']['raw_value'] == 85 + assert packet.learn is False + assert packet.contains_eep is False + assert packet.rorg == 0xA5 + assert packet.rorg == int(RORG.BS4) + assert packet.rorg_func == 0x02 + assert packet.rorg_type == 0x05 + assert packet.status == 0x00 + assert packet.repeater_count == 0 + assert packet.sender == [0x01, 0x81, 0xB7, 0x44] + assert packet.sender_hex == '01:81:B7:44' + + +@timing(1000) +def test_magnetic_switch(): + ''' Tests RADIO message for EEP -profile 0xD5 0x00 0x01 ''' + status, buf, packet = Packet.parse_msg(bytearray([ + 0x55, + 0x00, 0x07, 0x07, 0x01, + 0x7A, + 0xD5, 0x08, 0x01, 0x82, 0x5D, 0xAB, 0x00, + 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x36, 0x00, + 0x53 + ])) + assert packet.parse_eep(0x00, 0x01) == ['CO'] + assert packet.parsed['CO']['value'] == 'open' + assert packet.parsed['CO']['raw_value'] == 0 + assert packet.status == 0x00 + assert packet.repeater_count == 0 + + status, buf, packet = Packet.parse_msg(bytearray([ + 0x55, + 0x00, 0x07, 0x07, 0x01, + 0x7A, + 0xD5, 0x09, 0x01, 0x82, 0x5D, 0xAB, 0x00, + 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x36, 0x00, + 0xC7 + ])) + assert packet.parse_eep(0x00, 0x01) == ['CO'] + assert packet.parsed['CO']['value'] == 'closed' + assert packet.parsed['CO']['raw_value'] == 1 + assert packet.learn is False + assert packet.status == 0x00 + assert packet.repeater_count == 0 + + +@timing(1000) +def test_switch(): + status, buf, packet = Packet.parse_msg(bytearray([ + 0x55, + 0x00, 0x07, 0x07, 0x01, + 0x7A, + 0xF6, 0x50, 0x00, 0x29, 0x89, 0x79, 0x30, + 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x37, 0x00, + 0x9D + ])) + assert packet.parse_eep(0x02, 0x02) == ['R1', 'EB', 'R2', 'SA', 'T21', 'NU'] + assert packet.parsed['SA']['value'] == 'No 2nd action' + assert packet.parsed['EB']['value'] == 'pressed' + assert packet.parsed['R1']['value'] == 'Button BI' + assert packet.parsed['T21']['value'] is True + assert packet.parsed['NU']['value'] is True + assert packet.learn is True + assert packet.status == 0x30 + assert packet.repeater_count == 0 + + status, buf, packet = Packet.parse_msg(bytearray([ + 0x55, + 0x00, 0x07, 0x07, 0x01, + 0x7A, + 0xF6, 0x00, 0x00, 0x29, 0x89, 0x79, 0x20, + 0x02, 0xFF, 0xFF, 0xFF, 0xFF, 0x4A, 0x00, + 0x03 + ])) + assert packet.parse_eep(0x02, 0x02) == ['R1', 'EB', 'R2', 'SA', 'T21', 'NU'] + assert packet.parsed['SA']['value'] == 'No 2nd action' + assert packet.parsed['EB']['value'] == 'released' + assert packet.parsed['T21']['value'] is True + assert packet.parsed['NU']['value'] is False + assert packet.learn is True + assert packet.status == 0x20 + assert packet.repeater_count == 0 + + +@timing(1000) +def test_eep_parsing(): + status, buf, packet = Packet.parse_msg(bytearray([ + 0x55, + 0x00, 0x0A, 0x07, 0x01, + 0xEB, + 0xA5, 0x08, 0x28, 0x46, 0x80, 0x01, 0x8A, 0x7B, 0x30, 0x00, + 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x49, 0x00, + 0x26 + ])) + assert packet.learn is True + assert packet.contains_eep is True + assert packet.rorg_func == 0x02 + assert packet.rorg_type == 0x05 + assert packet.status == 0x00 + assert packet.repeater_count == 0 + + +@timing(1000) +def test_eep_remaining(): + # Magnetic switch -example + status, buf, packet = Packet.parse_msg(bytearray([ + 0x55, + 0x00, 0x07, 0x07, 0x01, + 0x7A, + 0xD5, 0x08, 0x01, 0x82, 0x5D, 0xAB, 0x00, + 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x36, 0x00, + 0x53 + ])) + assert packet.parse_eep(0x00, 0x01) == ['CO'] + + # Temperature-example + status, buf, packet = Packet.parse_msg(bytearray([ + 0x55, + 0x00, 0x0A, 0x07, 0x01, + 0xEB, + 0xA5, 0x00, 0x00, 0x55, 0x08, 0x01, 0x81, 0xB7, 0x44, 0x00, + 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x2D, 0x00, + 0x75 + ])) + # If this fails, the data is retained from the last Packet parsing! + assert packet.parse_eep(0x00, 0x01) == [] + # Once we have parse with the correct func and type, this should pass. + assert packet.parse_eep(0x02, 0x05) == ['TMP'] + + +@timing(1000) +def test_eep_direction(): + status, buf, packet = Packet.parse_msg(bytearray([ + 0x55, + 0x00, 0x0A, 0x07, 0x01, + 0xEB, + 0xA5, 0x32, 0x20, 0x89, 0x00, 0xDE, 0xAD, 0xBE, 0xEF, 0x00, + 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, + 0x43 + ])) + assert packet.parse_eep(0x20, 0x01, 1) == ['CV', 'SO', 'ENIE', 'ES', 'BCAP', 'CCO', 'FTS', 'DWO', 'ACO', 'TMP'] + assert packet.parsed['CV']['value'] == 50 + assert packet.parse_eep(0x20, 0x01, 2) == ['SP', 'TMP', 'RIN', 'LFS', 'VO', 'VC', 'SB', 'SPS', 'SPN', 'RCU'] + assert packet.parsed['SP']['value'] == 50 + + +@timing(1000) +def test_vld(): + status, buf, p = Packet.parse_msg(bytearray([ + 0x55, + 0x00, 0x09, 0x07, 0x01, + 0x56, + 0xD2, 0x04, 0x00, 0x64, 0x01, 0x94, 0xE3, 0xB9, 0x00, + 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x40, 0x00, + 0xE4 + ])) + assert p.rorg == RORG.VLD + assert p.parse_eep(0x01, 0x01) == ['PF', 'PFD', 'CMD', 'OC', 'EL', 'IO', 'LC', 'OV'] + + assert p.parsed['EL']['raw_value'] == 0 + assert p.parsed['EL']['value'] == 'Error level 0: hardware OK' + + assert p.parsed['PF']['raw_value'] == 0 + assert p.parsed['PF']['value'] == 'Power Failure Detection disabled/not supported' + + assert p.parsed['PFD']['raw_value'] == 0 + assert p.parsed['PFD']['value'] == 'Power Failure Detection not detected/not supported/disabled' + + assert p.parsed['IO']['raw_value'] == 0 + assert p.parsed['IO']['value'] == 'Output channel 0 (to load)' + + assert p.parsed['OV']['raw_value'] == 100 + assert p.parsed['OV']['value'] == 'Output value 100% or ON' + + assert p.parsed['OC']['raw_value'] == 0 + assert p.parsed['OC']['value'] == 'Over current switch off: ready / not supported' + + assert p.parsed['LC']['raw_value'] == 0 + assert p.parsed['LC']['value'] == 'Local control disabled / not supported' + + status, buf, p = Packet.parse_msg(bytearray([ + 0x55, + 0x00, 0x09, 0x07, 0x01, + 0x56, + 0xD2, 0x04, 0x00, 0x00, 0x01, 0x94, 0xE3, 0xB9, 0x00, + 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x40, 0x00, + 0xBF + ])) + assert p.rorg == RORG.VLD + assert p.parse_eep(0x01, 0x01) == ['PF', 'PFD', 'CMD', 'OC', 'EL', 'IO', 'LC', 'OV'] + + assert p.parsed['EL']['raw_value'] == 0 + assert p.parsed['EL']['value'] == 'Error level 0: hardware OK' + + assert p.parsed['PF']['raw_value'] == 0 + assert p.parsed['PF']['value'] == 'Power Failure Detection disabled/not supported' + + assert p.parsed['PFD']['raw_value'] == 0 + assert p.parsed['PFD']['value'] == 'Power Failure Detection not detected/not supported/disabled' + + assert p.parsed['IO']['raw_value'] == 0 + assert p.parsed['IO']['value'] == 'Output channel 0 (to load)' + + assert p.parsed['OV']['raw_value'] == 0 + assert p.parsed['OV']['value'] == 'Output value 0% or OFF' + + assert p.parsed['OC']['raw_value'] == 0 + assert p.parsed['OC']['value'] == 'Over current switch off: ready / not supported' + + assert p.parsed['LC']['raw_value'] == 0 + assert p.parsed['LC']['value'] == 'Local control disabled / not supported' + + +def test_fails(): + status, buf, packet = Packet.parse_msg(bytearray([ + 0x55, + 0x00, 0x07, 0x07, 0x01, + 0x7A, + 0xD5, 0x08, 0x01, 0x82, 0x5D, 0xAB, 0x00, + 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x36, 0x00, + 0x53 + ])) + eep = EEP() + # Mock initialization failure + eep.init_ok = False + assert eep.find_profile(packet._bit_data, 0xD5, 0x00, 0x01) is None + # TODO: Needs better test. A much better. + assert eep.set_values(profile=None, data=[True], status=[False, False], properties={'CV': False}) + eep.init_ok = True + profile = eep.find_profile(packet._bit_data, 0xD5, 0x00, 0x01) + assert eep.set_values(profile, packet._bit_data, packet.status, {'ASD': 1}) + + assert eep.find_profile(packet._bit_data, 0xFF, 0x00, 0x01) is None + assert eep.find_profile(packet._bit_data, 0xD5, 0xFF, 0x01) is None + assert eep.find_profile(packet._bit_data, 0xD5, 0x00, 0xFF) is None + + status, buf, packet = Packet.parse_msg(bytearray([ + 0x55, + 0x00, 0x09, 0x07, 0x01, + 0x56, + 0xD2, 0x04, 0x00, 0x00, 0x01, 0x94, 0xE3, 0xB9, 0x00, + 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x40, 0x00, + 0xBF + ])) + assert eep.find_profile(packet._bit_data, 0xD2, 0x01, 0x01) is not None + assert eep.find_profile(packet._bit_data, 0xD2, 0x01, 0x01, command=-1) is None diff --git a/enocean/enocean/protocol/tests/test_packet.py b/enocean/enocean/protocol/tests/test_packet.py new file mode 100644 index 0000000..96fa4c3 --- /dev/null +++ b/enocean/enocean/protocol/tests/test_packet.py @@ -0,0 +1,206 @@ +# -*- encoding: utf-8 -*- +from __future__ import print_function, unicode_literals, division, absolute_import + +from enocean.protocol.packet import Packet, EventPacket +from enocean.protocol.constants import PACKET, PARSE_RESULT, EVENT_CODE +from enocean.decorators import timing + + +@timing(1000) +def test_packet_examples(): + ''' Tests examples found at EnOceanSerialProtocol3.pdf / 74 ''' + telegram_examples = { + # Radio VLD + PACKET.RADIO_ERP1: { + 'msg': bytearray([ + 0x55, + 0x00, 0x0F, 0x07, 0x01, + 0x2B, + 0xD2, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0x00, 0x80, 0x35, 0xC4, 0x00, + 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0x4D, 0x00, + 0x36]), + 'data_len': 15, + 'opt_len': 7, + }, + # CO_WR_SLEEP + PACKET.COMMON_COMMAND: { + 'msg': bytearray([ + 0x55, + 0x00, 0x05, 0x00, 0x05, + 0xDB, + 0x01, 0x00, 0x00, 0x00, 0x0A, + 0x54]), + 'data_len': 5, + 'opt_len': 0, + }, + # CO_WR_RESET + PACKET.COMMON_COMMAND: { + 'msg': bytearray([ + 0x55, + 0x00, 0x01, 0x00, 0x05, + 0x70, + 0x02, + 0x0E]), + 'data_len': 1, + 'opt_len': 0, + }, + # CO_RD_IDBASE + PACKET.COMMON_COMMAND: { + 'msg': bytearray([ + 0x55, + 0x00, 0x01, 0x00, 0x05, + 0x70, + 0x08, + 0x38]), + 'data_len': 1, + 'opt_len': 0, + }, + # Response RET_OK + PACKET.RESPONSE: { + 'msg': bytearray([ + 0x55, + 0x00, 0x05, 0x00, 0x02, + 0xCE, + 0x00, 0xFF, 0x80, 0x00, 0x00, + 0xDA]), + 'data_len': 5, + 'opt_len': 0, + }, + # REMOTE_MAN_COMMAND + PACKET.REMOTE_MAN_COMMAND: { + 'msg': bytearray([ + 0x55, + 0x00, 0x19, 0x00, 0x07, 0x8D, + 0x12, 0x12, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, + 0xDA]), + 'data_len': 25, + 'opt_len': 0, + }, + # QueryID + PACKET.REMOTE_MAN_COMMAND: { + 'msg': bytearray([ + 0x55, + 0x00, 0x0C, 0x00, 0x07, + 0xEF, + 0x00, 0x04, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, + 0x65]), + 'data_len': 12, + 'opt_len': 0, + }, + # Custom test, containing 0x55 in message + PACKET.RESPONSE: { + 'msg': bytearray([ + 0x55, + 0x00, 0x05, 0x01, 0x02, + 0xDB, + 0x00, 0xFF, 0x9E, 0x55, 0x00, + 0x0A, + 0x79, + # unnecessary data, to check for message length checking + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF + ]), + 'data_len': 5, + 'opt_len': 1, + }, + } + + for packet, values in telegram_examples.items(): + status, remainder, pack = Packet.parse_msg(values['msg']) + assert status == PARSE_RESULT.OK + assert pack.packet_type != 0x00 + assert pack.packet_type == packet + assert len(pack.data) == values['data_len'] + assert len(pack.optional) == values['opt_len'] + assert pack.status == 0x00 + assert pack.repeater_count == 0 + + +@timing(1000) +def test_packet_fails(): + ''' + Tests designed to fail. + These include changes to checksum, data length or something like that. + ''' + fail_examples = ( + bytearray([ + 0x55, + 0x00, 0x0F, 0x07, 0x01, + 0x2B, + 0xD2, 0xDD, 0xDC, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0x00, 0x80, 0x35, 0xC4, 0x00, + 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0x4D, 0x00, + 0x36 + ]), + bytearray([ + 0x55, + 0x00, 0x0F, 0x07, 0x01, + 0x2B, + 0xD2, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0x00, 0x80, 0x35, 0xC4, 0x00, + 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0x4D, 0x00, + 0x37 + ]), + bytearray([ + 0x55, + 0x00, 0x0F, 0x07, 0x01, + 0x1B, + 0xD2, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0x00, 0x80, 0x35, 0xC4, 0x00, + 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0x4D, 0x00, + 0x36 + ]), + bytearray([ + 0x55, + 0x00, 0x01, 0x00, 0x05, + 0x70, + 0x38 + ]), + bytearray([ + 0x55, + 0x00, 0x01 + ]), + ) + + for msg in fail_examples: + status, remainder, packet = Packet.parse_msg(msg) + assert status in [PARSE_RESULT.INCOMPLETE, PARSE_RESULT.CRC_MISMATCH] + + +def test_packet_equals(): + data_1 = bytearray([ + 0x55, + 0x00, 0x01, 0x00, 0x05, + 0x70, + 0x08, + 0x38 + ]) + data_2 = bytearray([ + 0x55, + 0x00, 0x01, 0x00, 0x05, + 0x70, + 0x08, + 0x38 + ]) + _, _, packet_1 = Packet.parse_msg(data_1) + _, _, packet_2 = Packet.parse_msg(data_2) + + assert str(packet_1) == '0x%02X %s %s %s' % (packet_1.packet_type, + [hex(o) for o in packet_1.data], + [hex(o) for o in packet_1.optional], + packet_1.parsed) + assert str(packet_1) == str(packet_2) + assert packet_1 == packet_2 + + +def test_event_packet(): + data = bytearray([ + 0x55, + 0x00, 0x01, 0x00, 0x04, + 0x77, + 0x01, + 0x07 + ]) + + _, _, packet = Packet.parse_msg(data) + assert isinstance(packet, EventPacket) + assert packet.event == EVENT_CODE.SA_RECLAIM_NOT_SUCCESFUL + assert packet.event_data == [] + assert packet.optional == [] diff --git a/enocean/enocean/protocol/tests/test_packet_creation.py b/enocean/enocean/protocol/tests/test_packet_creation.py new file mode 100644 index 0000000..03a6a8b --- /dev/null +++ b/enocean/enocean/protocol/tests/test_packet_creation.py @@ -0,0 +1,335 @@ +# -*- encoding: utf-8 -*- +from __future__ import print_function, unicode_literals, division, absolute_import +from nose.tools import raises + +from enocean.protocol.packet import Packet, RadioPacket +from enocean.protocol.constants import PACKET, RORG +from enocean.decorators import timing + + +@timing(1000) +def test_packet_assembly(): + PACKET_CONTENT_1 = bytearray([ + 0x55, + 0x00, 0x0A, 0x00, 0x01, + 0x80, + 0xA5, 0x00, 0x00, 0x00, 0x00, 0xDE, 0xAD, 0xBE, 0xEF, 0x00, + 0x18 + ]) + PACKET_CONTENT_2 = bytearray([ + 0x55, + 0x00, 0x0A, 0x07, 0x01, + 0xEB, + 0xA5, 0x00, 0x00, 0x00, 0x00, 0xDE, 0xAD, 0xBE, 0xEF, 0x00, + 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, + 0xE4 + ]) + PACKET_CONTENT_3 = bytearray([ + 0x55, + 0x00, 0x0A, 0x07, 0x01, + 0xEB, + 0xA5, 0x32, 0x20, 0x89, 0x00, 0xDE, 0xAD, 0xBE, 0xEF, 0x00, + 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, + 0x43 + ]) + PACKET_CONTENT_4 = bytearray([ + 0x55, + 0x00, 0x0A, 0x07, 0x01, + 0xEB, + 0xA5, 0x32, 0x00, 0x00, 0x00, 0xDE, 0xAD, 0xBE, 0xEF, 0x00, + 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, + 0x80 + ]) + + # manually assemble packet + packet = Packet(PACKET.RADIO_ERP1) + packet.rorg = RORG.BS4 + sender_bytes = [(0xdeadbeef >> i & 0xff) for i in (24, 16, 8, 0)] + data = [0, 0, 0, 0] + packet.data = [packet.rorg] + data + sender_bytes + [0] + + # test content + packet_serialized = packet.build() + assert len(packet_serialized) == len(PACKET_CONTENT_1) + assert list(packet_serialized) == list(PACKET_CONTENT_1) + + # set optional data + sub_tel_num = 3 + destination = [255, 255, 255, 255] # broadcast + dbm = 0xff + security = 0 + packet.optional = [sub_tel_num] + destination + [dbm] + [security] + + # test content + packet_serialized = packet.build() + assert len(packet_serialized) == len(PACKET_CONTENT_2) + assert list(packet_serialized) == list(PACKET_CONTENT_2) + + # update data based on EEP + packet.select_eep(0x20, 0x01, 1) + prop = { + 'CV': 50, + 'TMP': 21.5, + 'ES': 'true', + } + packet.set_eep(prop) + + # test content + packet_serialized = packet.build() + assert len(packet_serialized) == len(PACKET_CONTENT_3) + assert list(packet_serialized) == list(PACKET_CONTENT_3) + assert packet.rorg_func == 0x20 + assert packet.rorg_type == 0x01 + + # Test the easier method of sending packets. + packet = Packet.create(PACKET.RADIO_ERP1, rorg=RORG.BS4, rorg_func=0x20, rorg_type=0x01, + learn=True, direction=1, **prop) + packet_serialized = packet.build() + assert len(packet_serialized) == len(PACKET_CONTENT_3) + assert list(packet_serialized) == list(PACKET_CONTENT_3) + assert packet.rorg_func == 0x20 + assert packet.rorg_type == 0x01 + + # Test creating RadioPacket directly. + packet = RadioPacket.create(rorg=RORG.BS4, rorg_func=0x20, rorg_type=0x01, learn=True, direction=2, SP=50) + packet_serialized = packet.build() + assert len(packet_serialized) == len(PACKET_CONTENT_4) + assert list(packet_serialized) == list(PACKET_CONTENT_4) + assert packet.rorg_func == 0x20 + assert packet.rorg_type == 0x01 + + +# Corresponds to the tests done in test_eep +@timing(1000) +def test_temperature(): + TEMPERATURE = bytearray([ + 0x55, + 0x00, 0x0A, 0x07, 0x01, + 0xEB, + 0xA5, 0x00, 0x00, 0x55, 0x08, 0x01, 0x81, 0xB7, 0x44, 0x00, + 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, + 0x5C + ]) + packet = RadioPacket.create(rorg=RORG.BS4, rorg_func=0x02, rorg_type=0x05, + sender=[0x01, 0x81, 0xB7, 0x44], TMP=26.66666666666666666666666666666666666666666667) + packet_serialized = packet.build() + assert len(packet_serialized) == len(TEMPERATURE) + assert list(packet_serialized) == list(TEMPERATURE) + assert packet.learn is False + + TEMPERATURE = bytearray([ + 0x55, + 0x00, 0x0A, 0x07, 0x01, + 0xEB, + 0xA5, 0x00, 0x00, 0x55, 0x00, 0x01, 0x81, 0xB7, 0x44, 0x00, + 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, + 0xE0 + ]) + packet = RadioPacket.create(rorg=RORG.BS4, rorg_func=0x02, rorg_type=0x05, sender=[0x01, 0x81, 0xB7, 0x44], + learn=True, TMP=26.66666666666666666666666666666666666666666667) + packet_serialized = packet.build() + assert len(packet_serialized) == len(TEMPERATURE) + assert packet.learn is True + + +# Corresponds to the tests done in test_eep +@timing(1000) +def test_magnetic_switch(): + MAGNETIC_SWITCH = bytearray([ + 0x55, + 0x00, 0x07, 0x07, 0x01, + 0x7A, + 0xD5, 0x08, 0x01, 0x82, 0x5D, 0xAB, 0x00, + 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, + 0xBA + ]) + packet = RadioPacket.create(rorg=RORG.BS1, rorg_func=0x00, rorg_type=0x01, sender=[0x01, 0x82, 0x5D, 0xAB], + CO='open') + packet_serialized = packet.build() + assert len(packet_serialized) == len(MAGNETIC_SWITCH) + assert list(packet_serialized) == list(MAGNETIC_SWITCH) + assert packet.learn is False + + MAGNETIC_SWITCH = bytearray([ + 0x55, + 0x00, 0x07, 0x07, 0x01, + 0x7A, + 0xD5, 0x00, 0x01, 0x82, 0x5D, 0xAB, 0x00, + 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, + 0x06 + ]) + packet = RadioPacket.create(rorg=RORG.BS1, rorg_func=0x00, rorg_type=0x01, sender=[0x01, 0x82, 0x5D, 0xAB], + learn=True, CO='open') + packet_serialized = packet.build() + assert len(packet_serialized) == len(MAGNETIC_SWITCH) + assert list(packet_serialized) == list(MAGNETIC_SWITCH) + assert packet.learn is True + + MAGNETIC_SWITCH = bytearray([ + 0x55, + 0x00, 0x07, 0x07, 0x01, + 0x7A, + 0xD5, 0x09, 0x01, 0x82, 0x5D, 0xAB, 0x00, + 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, + 0x2E + ]) + + packet = RadioPacket.create(rorg=RORG.BS1, rorg_func=0x00, rorg_type=0x01, sender=[0x01, 0x82, 0x5D, 0xAB], + CO='closed') + packet_serialized = packet.build() + assert len(packet_serialized) == len(MAGNETIC_SWITCH) + assert list(packet_serialized) == list(MAGNETIC_SWITCH) + assert packet.learn is False + + MAGNETIC_SWITCH = bytearray([ + 0x55, + 0x00, 0x07, 0x07, 0x01, + 0x7A, + 0xD5, 0x01, 0x01, 0x82, 0x5D, 0xAB, 0x00, + 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, + 0x92 + ]) + + packet = RadioPacket.create(rorg=RORG.BS1, rorg_func=0x00, rorg_type=0x01, sender=[0x01, 0x82, 0x5D, 0xAB], + learn=True, CO='closed') + packet_serialized = packet.build() + assert len(packet_serialized) == len(MAGNETIC_SWITCH) + assert list(packet_serialized) == list(MAGNETIC_SWITCH) + assert packet.learn is True + + +@timing(1000) +def test_switch(): + SWITCH = bytearray([ + 0x55, + 0x00, 0x07, 0x07, 0x01, + 0x7A, + 0xF6, 0x50, 0x00, 0x29, 0x89, 0x79, 0x30, + 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, + 0x61 + ]) + + # test also enum setting by integer value with EB0 + packet = RadioPacket.create(rorg=RORG.RPS, rorg_func=0x02, rorg_type=0x02, sender=[0x00, 0x29, 0x89, 0x79], + SA='No 2nd action', + EB=1, + R1='Button BI', + T21=True, + NU=True, + ) + packet_serialized = packet.build() + assert len(packet_serialized) == len(SWITCH) + assert list(packet_serialized) == list(SWITCH) + + SWITCH = bytearray([ + 0x55, + 0x00, 0x07, 0x07, 0x01, + 0x7A, + 0xF6, 0x00, 0x00, 0x29, 0x89, 0x79, 0x20, + 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, + 0xD2 + ]) + + packet = RadioPacket.create(rorg=RORG.RPS, rorg_func=0x02, rorg_type=0x02, sender=[0x00, 0x29, 0x89, 0x79], + SA='No 2nd action', + EB='released', + T21=True, + NU=False, + ) + packet_serialized = packet.build() + assert len(packet_serialized) == len(SWITCH) + assert list(packet_serialized) == list(SWITCH) + + +@timing(1000) +@raises(ValueError) +def test_illegal_eep_enum1(): + RadioPacket.create(rorg=RORG.RPS, rorg_func=0x02, rorg_type=0x02, sender=[0x00, 0x29, 0x89, 0x79], EB='inexisting') + + +@raises(ValueError) +@timing(1000) +def test_illegal_eep_enum2(): + RadioPacket.create(rorg=RORG.RPS, rorg_func=0x02, rorg_type=0x02, sender=[0x00, 0x29, 0x89, 0x79], EB=2) + + +# Corresponds to the tests done in test_eep +@timing(1000) +def test_packets_with_destination(): + TEMPERATURE = bytearray([ + 0x55, + 0x00, 0x0A, 0x07, 0x01, + 0xEB, + 0xA5, 0x00, 0x00, 0x55, 0x08, 0x01, 0x81, 0xB7, 0x44, 0x00, + 0x03, 0xDE, 0xAD, 0xBE, 0xEF, 0xFF, 0x00, + 0x5F + ]) + packet = RadioPacket.create(rorg=RORG.BS4, rorg_func=0x02, rorg_type=0x05, sender=[0x01, 0x81, 0xB7, 0x44], + destination=[0xDE, 0xAD, 0xBE, 0xEF], + TMP=26.66666666666666666666666666666666666666666667) + packet_serialized = packet.build() + assert len(packet_serialized) == len(TEMPERATURE) + assert list(packet_serialized) == list(TEMPERATURE) + assert packet.learn is False + assert packet.sender_int == 25278276 + assert packet.destination_int == 3735928559 + + MAGNETIC_SWITCH = bytearray([ + 0x55, + 0x00, 0x07, 0x07, 0x01, + 0x7A, + 0xD5, 0x08, 0x01, 0x82, 0x5D, 0xAB, 0x00, + 0x03, 0xDE, 0xAD, 0xBE, 0xEF, 0xFF, 0x00, + 0xB9 + ]) + packet = RadioPacket.create(rorg=RORG.BS1, rorg_func=0x00, rorg_type=0x01, sender=[0x01, 0x82, 0x5D, 0xAB], + destination=[0xDE, 0xAD, 0xBE, 0xEF], CO='open') + packet_serialized = packet.build() + assert len(packet_serialized) == len(MAGNETIC_SWITCH) + assert list(packet_serialized) == list(MAGNETIC_SWITCH) + assert packet.learn is False + + +@timing(1000) +def test_vld(): + SWITCH = bytearray([ + 0x55, + 0x00, 0x09, 0x07, 0x01, + 0x56, + 0xD2, 0x01, 0x1E, 0x64, 0xDE, 0xAD, 0xBE, 0xEF, 0x00, + 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, + 0x5A + ]) + packet = RadioPacket.create(rorg=RORG.VLD, rorg_func=0x01, rorg_type=0x01, command=1, DV=0, IO=0x1E, OV=0x64) + packet_serialized = packet.build() + + assert len(packet_serialized) == len(SWITCH) + assert list(packet_serialized) == list(SWITCH) + assert packet.parsed['CMD']['raw_value'] == 0x01 + assert packet.parsed['IO']['raw_value'] == 0x1E + assert packet.parsed['IO']['value'] == 'All output channels supported by the device' + assert packet.parsed['DV']['value'] == 'Switch to new output value' + assert packet.parsed['OV']['value'] == 'Output value 100% or ON' + + +def test_fails(): + try: + Packet.create(PACKET.RESPONSE, 0xA5, 0x01, 0x01) + assert False + except ValueError: + assert True + try: + Packet.create(PACKET.RADIO_ERP1, 0xA6, 0x01, 0x01) + assert False + except ValueError: + assert True + try: + Packet.create(PACKET.RADIO_ERP1, 0xA5, 0x01, 0x01, destination='ASDASDASD') + assert False + except ValueError: + assert True + try: + Packet.create(PACKET.RADIO_ERP1, 0xA5, 0x01, 0x01, sender='ASDASDASD') + assert False + except ValueError: + assert True diff --git a/enocean/enocean/protocol/tests/test_teachin.py b/enocean/enocean/protocol/tests/test_teachin.py new file mode 100644 index 0000000..d908a94 --- /dev/null +++ b/enocean/enocean/protocol/tests/test_teachin.py @@ -0,0 +1,44 @@ +# -*- encoding: utf-8 -*- +from __future__ import print_function, unicode_literals, division, absolute_import + +from enocean.communicators import Communicator +from enocean.protocol.packet import Packet +from enocean.protocol.constants import RORG, DB6 +from enocean.decorators import timing + + +@timing(rounds=100, limit=750) +def test_ute_in(): + communicator = Communicator() + communicator.base_id = [0xDE, 0xAD, 0xBE, 0xEF] + + status, buf, packet = Packet.parse_msg( + bytearray([ + 0x55, + 0x00, 0x0D, 0x07, 0x01, + 0xFD, + 0xD4, 0xA0, 0xFF, 0x3E, 0x00, 0x01, 0x01, 0xD2, 0x01, 0x94, 0xE3, 0xB9, 0x00, + 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x40, 0x00, + 0xAB + ]) + ) + + assert packet.sender_hex == '01:94:E3:B9' + assert packet.unidirectional is False + assert packet.bidirectional is True + assert packet.response_expected is True + assert packet.number_of_channels == 0xFF + assert packet.rorg_manufacturer == 0x3E + assert packet.rorg_of_eep == RORG.VLD + assert packet.rorg_func == 0x01 + assert packet.rorg_type == 0x01 + assert packet.teach_in is True + assert packet.delete is False + assert packet.learn is True + assert packet.contains_eep is True + + response_packet = packet.create_response_packet(communicator.base_id) + assert response_packet.sender_hex == 'DE:AD:BE:EF' + assert response_packet.destination_hex == '01:94:E3:B9' + assert response_packet._bit_data[DB6.BIT_5:DB6.BIT_3] == [False, True] + assert response_packet.data[2:7] == packet.data[2:7] diff --git a/enocean/enocean/protocol/tests/test_temperature_sensors.py b/enocean/enocean/protocol/tests/test_temperature_sensors.py new file mode 100644 index 0000000..b5bba06 --- /dev/null +++ b/enocean/enocean/protocol/tests/test_temperature_sensors.py @@ -0,0 +1,40 @@ +# -*- encoding: utf-8 -*- +from __future__ import print_function, unicode_literals, division, absolute_import + +from enocean.protocol.eep import EEP +eep = EEP() +# profiles = eep. + + +def test_first_range(): + offset = -40 + values = range(0x01, 0x0C) + for i in range(len(values)): + minimum = float(i * 10 + offset) + maximum = minimum + 40 + profile = eep.find_profile([], 0xA5, 0x02, values[i]) + + assert minimum == float(profile.find('value', {'shortcut': 'TMP'}).find('scale').find('min').text) + assert maximum == float(profile.find('value', {'shortcut': 'TMP'}).find('scale').find('max').text) + + +def test_second_range(): + offset = -60 + values = range(0x10, 0x1C) + for i in range(len(values)): + minimum = float(i * 10 + offset) + maximum = minimum + 80 + profile = eep.find_profile([], 0xA5, 0x02, values[i]) + + assert minimum == float(profile.find('value', {'shortcut': 'TMP'}).find('scale').find('min').text) + assert maximum == float(profile.find('value', {'shortcut': 'TMP'}).find('scale').find('max').text) + + +def test_rest(): + profile = eep.find_profile([], 0xA5, 0x02, 0x20) + assert -10 == float(profile.find('value', {'shortcut': 'TMP'}).find('scale').find('min').text) + assert +41.2 == float(profile.find('value', {'shortcut': 'TMP'}).find('scale').find('max').text) + + profile = eep.find_profile([], 0xA5, 0x02, 0x30) + assert -40 == float(profile.find('value', {'shortcut': 'TMP'}).find('scale').find('min').text) + assert +62.3 == float(profile.find('value', {'shortcut': 'TMP'}).find('scale').find('max').text) diff --git a/enocean/enocean/tests/test_utils.py b/enocean/enocean/tests/test_utils.py new file mode 100644 index 0000000..9112d7d --- /dev/null +++ b/enocean/enocean/tests/test_utils.py @@ -0,0 +1,30 @@ +# -*- encoding: utf-8 -*- +from __future__ import print_function, unicode_literals, division, absolute_import +import enocean.utils + + +def test_get_bit(): + assert enocean.utils.get_bit(1, 0) == 1 + assert enocean.utils.get_bit(8, 3) == 1 + assert enocean.utils.get_bit(6, 2) == 1 + assert enocean.utils.get_bit(6, 1) == 1 + + +def test_to_hex_string(): + assert enocean.utils.to_hex_string(0) == '00' + assert enocean.utils.to_hex_string(15) == '0F' + assert enocean.utils.to_hex_string(16) == '10' + assert enocean.utils.to_hex_string(22) == '16' + + assert enocean.utils.to_hex_string([0, 15, 16, 22]) == '00:0F:10:16' + assert enocean.utils.to_hex_string([0x00, 0x0F, 0x10, 0x16]) == '00:0F:10:16' + + +def test_from_hex_string(): + assert enocean.utils.from_hex_string('00') == 0 + assert enocean.utils.from_hex_string('0F') == 15 + assert enocean.utils.from_hex_string('10') == 16 + assert enocean.utils.from_hex_string('16') == 22 + + assert enocean.utils.from_hex_string('00:0F:10:16') == [0, 15, 16, 22] + assert enocean.utils.from_hex_string('00:0F:10:16') == [0x00, 0x0F, 0x10, 0x16] diff --git a/enocean/enocean/utils.py b/enocean/enocean/utils.py new file mode 100644 index 0000000..528b5af --- /dev/null +++ b/enocean/enocean/utils.py @@ -0,0 +1,41 @@ +# -*- encoding: utf-8 -*- +from __future__ import print_function, unicode_literals, division, absolute_import + + +def get_bit(byte, bit): + ''' Get bit value from byte ''' + return (byte >> bit) & 0x01 + + +def combine_hex(data): + ''' Combine list of integer values to one big integer ''' + output = 0x00 + for i, value in enumerate(reversed(data)): + output |= (value << i * 8) + return output + + +def to_bitarray(data, width=8): + ''' Convert data (list of integers, bytearray or integer) to bitarray ''' + if isinstance(data, list) or isinstance(data, bytearray): + data = combine_hex(data) + return [True if digit == '1' else False for digit in bin(data)[2:].zfill(width)] + + +def from_bitarray(data): + ''' Convert bit array back to integer ''' + return int(''.join(['1' if x else '0' for x in data]), 2) + + +def to_hex_string(data): + ''' Convert list of integers to a hex string, separated by ":" ''' + if isinstance(data, int): + return '%02X' % data + return ':'.join([('%02X' % o) for o in data]) + + +def from_hex_string(hex_string): + reval = [int(x, 16) for x in hex_string.split(':')] + if len(reval) == 1: + return reval[0] + return reval diff --git a/enocean/enocean2knx-gw.py b/enocean/enocean2knx-gw.py new file mode 100644 index 0000000..aec8b0e --- /dev/null +++ b/enocean/enocean2knx-gw.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +import logging +import subprocess +from enocean.communicators.serialcommunicator import SerialCommunicator + +# ----------------------------- +# Konfiguration +# ----------------------------- + +# ----------------------------- +# EnOcean +# ----------------------------- +ENOCEAN_PORT = "/dev/ttyS4" +SENDER_ID = "FE:D0:A6:44" + +# ----------------------------- +# KNXD +# ----------------------------- +KNXD_IP = "ip:172.20.21.168" +GA_FRONT = "11/1/14" +GA_REAR = "11/1/15" + +# ----------------------------- +# Logging +# ----------------------------- + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s: %(message)s" +) + +log = logging.getLogger("enocean-knx") + + +# ----------------------------- +# KNX Senden über knxd +# ----------------------------- + +def knx_write(ip: str, ga: str, state: bool): + """ + Sendet Boolean an KNX über knxd (groupswrite). + """ + value = "1" if state else "0" + + try: + subprocess.run( + ["/usr/lib/knxd/groupswrite", ip, ga, value], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + log.info(f"KNX → {ga} = {value}") + + except Exception as e: + log.error(f"KNX send failed: {e}") + + +# ----------------------------- +# EnOcean Verarbeitung +# ----------------------------- + +last_button = None # "front" | "rear" + + +def process_packet(packet): + global last_button + + try: + sender = packet.sender_hex + + if sender != SENDER_ID: + return + + # Rohdaten + data = packet.data + + if not data: + return + + event = data[1] + + # ------------------------- + # Fronttür (Button BO) + # ------------------------- + if event == 0x70: + last_button = "front" + knx_write(KNXD_IP, GA_FRONT, True) + log.info("Front door OPEN") + + elif event == 0x00 and last_button == "front": + knx_write(KNXD_IP, GA_FRONT, False) + log.info("Front door CLOSED") + + # ------------------------- + # Hecktür (Button BI) + # ------------------------- + elif event == 0x50: + last_button = "rear" + knx_write(KNXD_IP, GA_REAR, True) + log.info("Rear door OPEN") + + elif event == 0x00 and last_button == "rear": + knx_write(KNXD_IP, GA_REAR, False) + log.info("Rear door CLOSED") + + except Exception as e: + log.error(f"Packet error: {e}") + + +# ----------------------------- +# Main Loop +# ----------------------------- + +def main(): + log.info("Starting EnOcean → KNX gateway") + + communicator = SerialCommunicator(port=ENOCEAN_PORT) + communicator.start() + + try: + while True: + packet = communicator.receive.get() + process_packet(packet) + + except KeyboardInterrupt: + log.info("Stopping gateway") + + finally: + communicator.stop() + + +if __name__ == "__main__": + main() + diff --git a/enocean/examples/co_rd_version_example.py b/enocean/examples/co_rd_version_example.py new file mode 100755 index 0000000..7ff2d42 --- /dev/null +++ b/enocean/examples/co_rd_version_example.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- + +""" +Example on getting the information about the EnOcean controller. +The sending is happening between the application and the EnOcean controller, +no wireless communication is taking place here. + +The command used here is specified as 1.10.5 Code 03: CO_RD_VERSION +in the ESP3 document. +""" + +from enocean.consolelogger import init_logging +from enocean.communicators.serialcommunicator import SerialCommunicator +from enocean.protocol.packet import Packet +from enocean.protocol.constants import PACKET +from enocean import utils +import traceback +import sys + +try: + import queue +except ImportError: + import Queue as queue + +init_logging() +""" +'/dev/ttyUSB0' might change depending on where your device is. +To prevent running the app as root, change the access permissions: +'sudo chmod 777 /dev/ttyUSB0' +""" +communicator = SerialCommunicator(port=u'/dev/ttyUSB0', callback=None) +packet = Packet(PACKET.COMMON_COMMAND, [0x03]) + +communicator.daemon = True +communicator.start() +communicator.send(packet) + +while communicator.is_alive(): + try: + receivedPacket = communicator.receive.get(block=True, timeout=1) + if receivedPacket.packet_type == PACKET.RESPONSE: + print('Return Code: %s' % utils.to_hex_string(receivedPacket.data[0])) + print('APP version: %s' % utils.to_hex_string(receivedPacket.data[1:5])) + print('API version: %s' % utils.to_hex_string(receivedPacket.data[5:9])) + print('Chip ID: %s' % utils.to_hex_string(receivedPacket.data[9:13])) + print('Chip Version: %s' % utils.to_hex_string(receivedPacket.data[13:17])) + print('App Description Version: %s' % utils.to_hex_string(receivedPacket.data[17:])) + print('App Description Version (ASCII): %s' % str(bytearray(receivedPacket.data[17:]))) + + except queue.Empty: + continue + except KeyboardInterrupt: + break + except Exception: + traceback.print_exc(file=sys.stdout) + break + +if communicator.is_alive(): + communicator.stop() diff --git a/enocean/examples/enocean2knx.py b/enocean/examples/enocean2knx.py new file mode 100755 index 0000000..be9eb0c --- /dev/null +++ b/enocean/examples/enocean2knx.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +from enocean.consolelogger import init_logging +import enocean.utils +from enocean.communicators.serialcommunicator import SerialCommunicator +from enocean.protocol.packet import RadioPacket +from enocean.protocol.constants import PACKET, RORG +import sys +import traceback + +try: + import queue +except ImportError: + import Queue as queue + + +def assemble_radio_packet(transmitter_id): + return RadioPacket.create(rorg=RORG.BS4, rorg_func=0x20, rorg_type=0x01, + sender=transmitter_id, + CV=50, + TMP=21.5, + ES='true') + + +init_logging() +communicator = SerialCommunicator() +communicator.start() +print('The Base ID of your module is %s.' % enocean.utils.to_hex_string(communicator.base_id)) + +if communicator.base_id is not None: + print('Sending example package.') + communicator.send(assemble_radio_packet(communicator.base_id)) + +# endless loop receiving radio packets +while communicator.is_alive(): + try: + # Loop to empty the queue... + packet = communicator.receive.get(block=True, timeout=1) + if packet.packet_type == PACKET.RADIO_ERP1 and packet.rorg == RORG.VLD: + packet.select_eep(0x05, 0x00) + packet.parse_eep() + for k in packet.parsed: + print('%s: %s' % (k, packet.parsed[k])) + if packet.packet_type == PACKET.RADIO_ERP1 and packet.rorg == RORG.BS4: + # parse packet with given FUNC and TYPE + for k in packet.parse_eep(0x02, 0x05): + print('%s: %s' % (k, packet.parsed[k])) + if packet.packet_type == PACKET.RADIO_ERP1 and packet.rorg == RORG.BS1: + # alternatively you can select FUNC and TYPE explicitely + packet.select_eep(0x00, 0x01) + # parse it + packet.parse_eep() + for k in packet.parsed: + print('%s: %s' % (k, packet.parsed[k])) + if packet.packet_type == PACKET.RADIO_ERP1 and packet.rorg == RORG.RPS: + for k in packet.parse_eep(0x02, 0x02): + print('%s: %s' % (k, packet.parsed[k])) + except queue.Empty: + continue + except KeyboardInterrupt: + break + except Exception: + traceback.print_exc(file=sys.stdout) + break + +if communicator.is_alive(): + communicator.stop() diff --git a/enocean/examples/enocean_example.py b/enocean/examples/enocean_example.py new file mode 100755 index 0000000..be9eb0c --- /dev/null +++ b/enocean/examples/enocean_example.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +from enocean.consolelogger import init_logging +import enocean.utils +from enocean.communicators.serialcommunicator import SerialCommunicator +from enocean.protocol.packet import RadioPacket +from enocean.protocol.constants import PACKET, RORG +import sys +import traceback + +try: + import queue +except ImportError: + import Queue as queue + + +def assemble_radio_packet(transmitter_id): + return RadioPacket.create(rorg=RORG.BS4, rorg_func=0x20, rorg_type=0x01, + sender=transmitter_id, + CV=50, + TMP=21.5, + ES='true') + + +init_logging() +communicator = SerialCommunicator() +communicator.start() +print('The Base ID of your module is %s.' % enocean.utils.to_hex_string(communicator.base_id)) + +if communicator.base_id is not None: + print('Sending example package.') + communicator.send(assemble_radio_packet(communicator.base_id)) + +# endless loop receiving radio packets +while communicator.is_alive(): + try: + # Loop to empty the queue... + packet = communicator.receive.get(block=True, timeout=1) + if packet.packet_type == PACKET.RADIO_ERP1 and packet.rorg == RORG.VLD: + packet.select_eep(0x05, 0x00) + packet.parse_eep() + for k in packet.parsed: + print('%s: %s' % (k, packet.parsed[k])) + if packet.packet_type == PACKET.RADIO_ERP1 and packet.rorg == RORG.BS4: + # parse packet with given FUNC and TYPE + for k in packet.parse_eep(0x02, 0x05): + print('%s: %s' % (k, packet.parsed[k])) + if packet.packet_type == PACKET.RADIO_ERP1 and packet.rorg == RORG.BS1: + # alternatively you can select FUNC and TYPE explicitely + packet.select_eep(0x00, 0x01) + # parse it + packet.parse_eep() + for k in packet.parsed: + print('%s: %s' % (k, packet.parsed[k])) + if packet.packet_type == PACKET.RADIO_ERP1 and packet.rorg == RORG.RPS: + for k in packet.parse_eep(0x02, 0x02): + print('%s: %s' % (k, packet.parsed[k])) + except queue.Empty: + continue + except KeyboardInterrupt: + break + except Exception: + traceback.print_exc(file=sys.stdout) + break + +if communicator.is_alive(): + communicator.stop() diff --git a/enocean/examples/example_D2-05-00.py b/enocean/examples/example_D2-05-00.py new file mode 100644 index 0000000..20f655d --- /dev/null +++ b/enocean/examples/example_D2-05-00.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +''' +Example to show automatic UTE Teach-in responses using +http://www.g-media.fr/prise-gigogne-enocean.html + +Waits for UTE Teach-ins, sends the response automatically and prints the ID of new device. +''' + +import sys +import time +import traceback +import enocean.utils +from enocean.communicators import SerialCommunicator +from enocean.protocol.packet import RadioPacket, UTETeachInPacket +from enocean.protocol.constants import RORG + +try: + import queue +except ImportError: + import Queue as queue + + +def set_position(destination, percentage): + global communicator + communicator.send( + RadioPacket.create(rorg=RORG.VLD, rorg_func=0x05, rorg_type=0x00, destination=destination, sender=communicator.base_id, command=1, POS=percentage) + ) + + + +communicator = SerialCommunicator() +communicator.start() +print('The Base ID of your module is %s.' % enocean.utils.to_hex_string(communicator.base_id)) + + +# set_position([0x05, 0x0F, 0x0B, 0xEA], 100) +# time.sleep(10) +set_position([0x05, 0x0F, 0x0B, 0xEA], 50) + + +print('Press and hold the teach-in button on the plug now, till it starts turning itself off and on (about 10 seconds or so...)') +devices_learned = [] + +# endless loop receiving radio packets +while communicator.is_alive(): + try: + # Loop to empty the queue... + packet = communicator.receive.get(block=True, timeout=1) + if isinstance(packet, UTETeachInPacket): + print('New device learned! The ID is %s.' % (packet.sender_hex)) + devices_learned.append(packet.sender) + except queue.Empty: + continue + except KeyboardInterrupt: + break + except Exception: + traceback.print_exc(file=sys.stdout) + break + +print('Devices learned during this session: %s' % (', '.join([enocean.utils.to_hex_string(x) for x in devices_learned]))) + +if communicator.is_alive(): + communicator.stop() diff --git a/enocean/examples/example_DO21-11B-E.py b/enocean/examples/example_DO21-11B-E.py new file mode 100644 index 0000000..52464ec --- /dev/null +++ b/enocean/examples/example_DO21-11B-E.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +''' +Example to show automatic UTE Teach-in responses using +http://www.g-media.fr/prise-gigogne-enocean.html + +Waits for UTE Teach-ins, sends the response automatically and prints the ID of new device. +''' + +import sys +import time +import traceback +import enocean.utils +from enocean.communicators import SerialCommunicator +from enocean.protocol.packet import RadioPacket, UTETeachInPacket +from enocean.protocol.constants import RORG + +try: + import queue +except ImportError: + import Queue as queue + + +def send_command(destination, output_value): + global communicator + communicator.send( + RadioPacket.create(rorg=RORG.VLD, rorg_func=0x01, rorg_type=0x01, destination=destination, sender=communicator.base_id, command=1, IO=0x1E, OV=output_value) + ) + + +def turn_on(destination): + send_command(destination, 100) + + +def turn_off(destination): + send_command(destination, 0) + + +communicator = SerialCommunicator() +communicator.start() +print('The Base ID of your module is %s.' % enocean.utils.to_hex_string(communicator.base_id)) + +# Example of turning switches on and off +turn_on([0x01, 0x94, 0xB9, 0x46]) +# Needs a bit of sleep in between, working too fast :S +time.sleep(0.1) +turn_on([0x01, 0x94, 0xE3, 0xB9]) +time.sleep(1) + +turn_off([0x01, 0x94, 0xB9, 0x46]) +time.sleep(0.1) +turn_off([0x01, 0x94, 0xE3, 0xB9]) + + +print('Press and hold the teach-in button on the plug now, till it starts turning itself off and on (about 10 seconds or so...)') +devices_learned = [] + +# endless loop receiving radio packets +while communicator.is_alive(): + try: + # Loop to empty the queue... + packet = communicator.receive.get(block=True, timeout=1) + if isinstance(packet, UTETeachInPacket): + print('New device learned! The ID is %s.' % (packet.sender_hex)) + devices_learned.append(packet.sender) + except queue.Empty: + continue + except KeyboardInterrupt: + break + except Exception: + traceback.print_exc(file=sys.stdout) + break + +print('Devices learned during this session: %s' % (', '.join([enocean.utils.to_hex_string(x) for x in devices_learned]))) + +if communicator.is_alive(): + communicator.stop() diff --git a/enocean/examples/serial_to_tcp.py b/enocean/examples/serial_to_tcp.py new file mode 100755 index 0000000..e68d19c --- /dev/null +++ b/enocean/examples/serial_to_tcp.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +from enocean.consolelogger import init_logging +from enocean.communicators.serialcommunicator import SerialCommunicator +from enocean.communicators.utils import send_to_tcp_socket +import sys +import traceback + +try: + import queue +except ImportError: + import Queue as queue + +init_logging() +communicator = SerialCommunicator() +communicator.start() +while communicator.is_alive(): + try: + # Loop to empty the queue... + packet = communicator.receive.get(block=True, timeout=1) + send_to_tcp_socket('localhost', 9637, packet) + except queue.Empty: + continue + except KeyboardInterrupt: + break + except Exception: + traceback.print_exc(file=sys.stdout) + break + +if communicator.is_alive(): + communicator.stop() diff --git a/enocean/examples/tcp_server.py b/enocean/examples/tcp_server.py new file mode 100755 index 0000000..d7fa94d --- /dev/null +++ b/enocean/examples/tcp_server.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +from enocean.consolelogger import init_logging +from enocean.communicators.tcpcommunicator import TCPCommunicator +from enocean.protocol.constants import PACKET, RORG +import sys +import traceback + +try: + import queue +except ImportError: + import Queue as queue + +init_logging() +communicator = TCPCommunicator() +communicator.start() +while communicator.is_alive(): + try: + # Loop to empty the queue... + packet = communicator.receive.get(block=True, timeout=1) + if packet.packet_type == PACKET.RADIO_ERP1 and packet.rorg == RORG.BS4: + for k in packet.parse_eep(0x02, 0x05): + print('%s: %s' % (k, packet.parsed[k])) + if packet.packet_type == PACKET.RADIO_ERP1 and packet.rorg == RORG.BS1: + for k in packet.parse_eep(0x00, 0x01): + print('%s: %s' % (k, packet.parsed[k])) + if packet.packet_type == PACKET.RADIO_ERP1 and packet.rorg == RORG.RPS: + for k in packet.parse_eep(0x02, 0x04): + print('%s: %s' % (k, packet.parsed[k])) + except queue.Empty: + continue + except KeyboardInterrupt: + break + except Exception: + traceback.print_exc(file=sys.stdout) + break + +if communicator.is_alive(): + communicator.stop() diff --git a/enocean/generate_supported_profiles.py b/enocean/generate_supported_profiles.py new file mode 100755 index 0000000..30ee887 --- /dev/null +++ b/enocean/generate_supported_profiles.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +from __future__ import print_function, unicode_literals, division, absolute_import +import codecs +from enocean.protocol.eep import EEP + +ROW_FORMAT = '|{:8s}|{:50s}|{:8s}|{:70s}|\n' + + +eep = EEP() + +with codecs.open('SUPPORTED_PROFILES.md', 'w', 'utf-8') as f_handle: + f_handle.write('# Supported profiles\n') + f_handle.write('All profiles (should) correspond to the official [EEP](http://www.enocean-alliance.org/eep/) by EnOcean.\n\n') + + for telegram in eep.soup.find_all('telegram'): + f_handle.write('### %s (%s)\n' % (telegram['description'], telegram['rorg'])) + for func in telegram.find_all('profiles'): + # f_handle.write('##### FUNC %s - %s\n' % (func['func'], func['description'])) + for profile in func.find_all('profile'): + f_handle.write('##### RORG %s - FUNC %s - TYPE %s - %s\n\n' % (telegram['rorg'], func['func'], profile['type'], profile['description'])) + + for data in profile.find_all('data'): + header = [] + + if data.get('direction'): + header.append('direction: %s' % (data.get('direction'))) + if data.get('command'): + header.append('command: %s' % (data.get('command'))) + + if header: + f_handle.write('###### %s\n' % ' '.join(header)) + + f_handle.write(ROW_FORMAT.format('shortcut', 'description', 'type', 'values')) + f_handle.write(ROW_FORMAT.format('--------', '--------------------------------------------------', '--------', '----')) + for child in data.children: + if child.name is None: + continue + + values = [] + for item in child.children: + if item.name is None: + continue + + if item.name == 'rangeitem': + values.append('%s-%s - %s' % (item['start'], item['end'], item['description'])) + elif item.name == 'item': + values.append('%s - %s' % (item['value'], item['description'])) + elif item.name == 'range': + parent = item.parent + + range_min = float(item.find('min').text) + range_max = float(item.find('max').text) + scale = parent.find('scale') + scale_min = float(scale.find('min').text) + scale_max = float(scale.find('max').text) + + values.append('%s-%s ↔ %s-%s %s' % (range_min, range_max, scale_min, scale_max, parent['unit'])) + if not values: + f_handle.write(ROW_FORMAT.format(child['shortcut'], child['description'], child.name, '')) + continue + + f_handle.write(ROW_FORMAT.format(child['shortcut'], child['description'], child.name, values[0])) + for i in range(1, len(values)): + f_handle.write(ROW_FORMAT.format('', '', '', values[i])) + f_handle.write('\n') + f_handle.write('\n') + + f_handle.write('\n') diff --git a/enocean/requirements.txt b/enocean/requirements.txt new file mode 100644 index 0000000..fb7ca9a --- /dev/null +++ b/enocean/requirements.txt @@ -0,0 +1,3 @@ +enum-compat>=0.0.2 +pyserial>=3.0 +beautifulsoup4>=4.3.2 diff --git a/enocean/run_tests_with_timing.sh b/enocean/run_tests_with_timing.sh new file mode 100755 index 0000000..4cd310e --- /dev/null +++ b/enocean/run_tests_with_timing.sh @@ -0,0 +1,2 @@ +#!/bin/sh +WITH_TIMINGS=1 nosetests -s -q diff --git a/enocean/setup.py b/enocean/setup.py new file mode 100644 index 0000000..3fea09c --- /dev/null +++ b/enocean/setup.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python +try: + from setuptools import setup +except ImportError: + from distutils.core import setup + +setup( + name='enocean', + version='0.60.1', + description='EnOcean serial protocol implementation', + author='Kimmo Huoman', + author_email='kipenroskaposti@gmail.com', + url='https://github.com/kipe/enocean', + packages=[ + 'enocean', + 'enocean.protocol', + 'enocean.communicators', + ], + scripts=[ + 'examples/enocean_example.py', + ], + package_data={ + '': ['EEP.xml'] + }, + install_requires=[ + 'enum-compat>=0.0.2', + 'pyserial>=3.0', + 'beautifulsoup4>=4.3.2', + ]) diff --git a/systemd/system/enocean2knx-gw.service b/systemd/system/enocean2knx-gw.service new file mode 100644 index 0000000..cee8f4f --- /dev/null +++ b/systemd/system/enocean2knx-gw.service @@ -0,0 +1,14 @@ +[Unit] +Description=EnOcean to KNX Gateway +After=network.target knxd.service + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/enocean +ExecStart=/opt/enocean/.venv/bin/python3 /opt/enocean/enocean2knx-gw.py +Restart=always + +[Install] +WantedBy=multi-user.target +