├── .dockerignore ├── .gitattributes ├── .github └── workflows │ ├── docker-image-build.yml │ ├── docker-test-build.yml │ └── python-publish.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── build.sh ├── config_example.yml ├── docker-compose.yml ├── module_usage_example.py ├── pyproject.toml ├── senselink ├── __init__.py ├── __main__.py ├── common.py ├── data_source.py ├── homeassistant │ ├── __init__.py │ ├── ha_controller.py │ └── ha_data_source.py ├── mqtt │ ├── __init__.py │ ├── mqtt_controller.py │ ├── mqtt_data_source.py │ └── mqtt_listener.py ├── plug_instance.py ├── senselink.py └── tplink_encryption.py └── setup.py /.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .git 3 | .idea 4 | .DS_Store 5 | .gitignore 6 | env 7 | 8 | # Ignore repo config files 9 | config_* 10 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | _version.py export-subst 2 | senselink/_version.py export-subst 3 | -------------------------------------------------------------------------------- /.github/workflows/docker-image-build.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Docker Image Builder 3 | 4 | on: 5 | # Allow manual dispatch 6 | workflow_dispatch: 7 | # Run only manually for now 8 | # release: 9 | # types: [released] 10 | 11 | jobs: 12 | 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v3 20 | 21 | - name: Get tags 22 | run: git fetch --tags origin 23 | 24 | - name: Get git tag version 25 | run: echo git_tag=$(git describe --tags $(git rev-list --tags --max-count=1)) >> $GITHUB_ENV 26 | 27 | - name: Running for git tag 28 | run: echo ${{ env.git_tag }} 29 | 30 | - name: Set up Python 31 | uses: actions/setup-python@v2 32 | with: 33 | python-version: '3.10' 34 | 35 | - name: Install dependencies 36 | run: | 37 | python -m pip install --upgrade pip 38 | pip install wheel 39 | 40 | - name: Set up QEMU 41 | uses: docker/setup-qemu-action@v1 42 | 43 | - name: Set up Docker Buildx 44 | id: buildx 45 | uses: docker/setup-buildx-action@v1 46 | 47 | - name: Create builder 48 | run: docker buildx create --name senselink-builder 49 | 50 | - name: Use senselink-builder 51 | run: docker buildx use senselink-builder 52 | 53 | - run: docker buildx inspect --bootstrap 54 | 55 | - name: Log in to Docker Hub 56 | uses: docker/login-action@v1.14.1 57 | with: 58 | username: theta142 59 | password: ${{ secrets.DOCKER_HUB_TOKEN }} 60 | 61 | - name: Build SenseLink for various platforms 62 | run: docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t theta142/senselink:${{ env.git_tag }} --push . 63 | 64 | - name: Push for tag latest only if not beta or alpha tagged 65 | if: ${{ !contains(env.git_tag, 'beta') && !contains(env.git_tag, 'alpha') }} 66 | run: docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t theta142/senselink:latest --push . 67 | -------------------------------------------------------------------------------- /.github/workflows/docker-test-build.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Docker Test Builder 3 | 4 | on: 5 | # Allow manual dispatch 6 | workflow_dispatch: 7 | 8 | jobs: 9 | 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | 18 | - name: Get tags 19 | run: git fetch --tags origin 20 | 21 | - name: Get git tag version 22 | run: echo git_tag=$(git describe --tags $(git rev-list --tags --max-count=1)) >> $GITHUB_ENV 23 | 24 | - name: Running for git tag 25 | run: echo ${{ env.git_tag }} 26 | 27 | - name: Set up Python 28 | uses: actions/setup-python@v2 29 | with: 30 | python-version: '3.10' 31 | 32 | - name: Install dependencies 33 | run: | 34 | python -m pip install --upgrade pip 35 | pip install wheel 36 | 37 | - name: Build and push for tag ONLY if beta or alpha tagged 38 | if: ${{ contains(env.git_tag, 'beta') || contains(env.git_tag, 'alpha') }} 39 | run: docker build -t theta142/senselink:${{ env.git_tag }} --push . 40 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package to PyPI 10 | 11 | on: 12 | # Allow manual dispatch 13 | workflow_dispatch: 14 | release: 15 | types: [released] 16 | 17 | jobs: 18 | pypi-publish: 19 | 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v3 25 | 26 | - name: Get tags 27 | run: git fetch --tags origin 28 | 29 | - name: Set up Python 30 | uses: actions/setup-python@v2 31 | with: 32 | python-version: '3.10' 33 | 34 | - name: Get git tag version 35 | run: echo git_tag=$(git describe --tags $(git rev-list --tags --max-count=1)) >> $GITHUB_ENV 36 | 37 | - name: Get PyPI package version 38 | run: echo pypi_version=$(python3 setup.py -V) >> $GITHUB_ENV 39 | 40 | - name: Check for consistent versions 41 | if: ${{ env.git_tag != env.pypi_version }} 42 | run: | 43 | echo Git Tag ${{ env.git_tag }} does not match PyPI version ${{ env.pypi_version }}. Canceling workflow. 44 | exit 1 45 | 46 | - name: Install dependencies 47 | run: | 48 | python -m pip install --upgrade pip 49 | pip install build wheel 50 | 51 | - name: Build package 52 | run: python -m build 53 | 54 | - name: Publish package 55 | uses: pypa/gh-action-pypi-publish@release/v1 56 | # Double check that versions match 57 | if: ${{ env.git_tag == env.pypi_version }} 58 | with: 59 | user: __token__ 60 | password: ${{ secrets.PYPI_TOKEN }} 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Private and test files 2 | *_test.py 3 | *_private.yml 4 | *_private.yaml 5 | 6 | ### VirtualEnv template 7 | # Virtualenv 8 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 9 | .Python 10 | [Bb]in 11 | [Ii]nclude 12 | [Ll]ib 13 | [Ll]ib64 14 | [Ll]ocal 15 | [Ss]cripts 16 | pyvenv.cfg 17 | .venv 18 | env 19 | pip-selfcheck.json 20 | __pycache__ 21 | 22 | ### JetBrains template 23 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 24 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 25 | 26 | # User-specific stuff: 27 | .idea/workspace.xml 28 | .idea/tasks.xml 29 | .idea/dictionaries 30 | .idea/vcs.xml 31 | .idea/jsLibraryMappings.xml 32 | 33 | # Sensitive or high-churn files: 34 | .idea/dataSources.ids 35 | .idea/dataSources.xml 36 | .idea/dataSources.local.xml 37 | .idea/sqlDataSources.xml 38 | .idea/dynamic.xml 39 | .idea/uiDesigner.xml 40 | 41 | # Gradle: 42 | .idea/gradle.xml 43 | .idea/libraries 44 | 45 | # Mongo Explorer plugin: 46 | .idea/mongoSettings.xml 47 | 48 | .idea/ 49 | /dist/ 50 | /SenseLink.egg-info/ 51 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim 2 | MAINTAINER Charles Powell 3 | 4 | # Install all dependencies 5 | ADD . /senselink 6 | RUN pip install /senselink 7 | 8 | # Make non-root user 9 | RUN useradd --create-home appuser 10 | WORKDIR /home/appuser 11 | USER appuser 12 | 13 | # Run 14 | CMD ["python", "-m", "senselink"] 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Charles Powell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SenseLink 2 | A tool to inform a Sense Home Energy Monitor of **known** energy usage in your home, written in Python. A Docker image is also provided! 3 | 4 | If you're sourcing your energy usage from ESP8266/ESP32 devices via ESPHome, check out my partner project [ESPSense](https://github.com/cbpowell/ESPSense)! You might be able to report power usage to Sense directly from your device, including other cheap commercial Smart Plugs! 5 | 6 | # About 7 | SenseLink is a tool that emulates the energy monitoring functionality of [TP-Link Kasa HS110](https://www.tp-link.com/us/home-networking/smart-plug/hs110/) Smart Plugs, and allows you to report "custom" power usage to your [Sense Home Energy Monitor](https://sense.com) based on other parameters. 8 | 9 | SenseLink can emulate multiple plugs at the same time, and can report: 10 | 1. Static/unchanging power usage 11 | 2. Dynamic power usage based on other parameters through API integrations (e.g. a dimmer brightness value) 12 | 3. Aggregate usage of any number of other plugs (static or dynamic) 13 | 14 | At the moment, dynamic power plugs can source data from the [Home Assistant](https://www.home-assistant.io) (Websockets API) and MQTT. Plus, other integrations should be relatively easy to implement! 15 | 16 | Aggregate "plugs" sum the power usage data from the specified sub-elements, and report usage just as dynamically. 17 | 18 | While Sense [doesn't currently](https://community.sense.com/t/smart-plugs-frequently-asked-questions/7211) use the data from smart plugs for device detection algorithm training, you should be a good citizen and try only provide accurate data! Not to mention, incorrectly reporting your own data hurts your own monitoring as well! 19 | 20 | **You should use this tool at your own risk!** Sense is not obligated to provide any support related to issues with this tool, and there's no guarantee everything will reliably work, or even work. Neither I or Sense can guarantee it won't affect your Sense data, particularly if things go wrong! 21 | 22 | 23 | # Configuration 24 | Configuration is defined through a YAML file, that should be passed in when creating an instance of the `SenseLink` class. See the [`config_example.yml`](https://github.com/cbpowell/SenseLink/blob/master/config_example.yml) file for examples of how to write configurations (note the example config itself is not a valid demo config!). 25 | 26 | The YAML configuration file should start with a top level `sources` key, which defines an array of sources for power data. Each source then has a `plugs` key to define an array of individual emulated plugs, plugs other configuration details as needed for that particular source. The current supported sources types are: 27 | - `static`: Plugs with unchanging power values 28 | - `hass`: Home Assistant, via the Websockets API 29 | - `mqtt`: MQTT, via a MQTT broker 30 | - `aggregate`: Summed values of other plugs, for example for a whole room - useful for staying under the Sense limit of ~20 plugs! 31 | - `mutable`: Plugs designed to have their power values changed by other areas of the code/program. Primarily only useful when using SenseLink as a module in other code. See the [`module_usage_example.py`](https://github.com/cbpowell/SenseLink/blob/master/module_usage_example.py) file. 32 | 33 | See the [`config_example.yml`](https://github.com/cbpowell/SenseLink/blob/master/config_example.yml) for examples of each, and [the wiki](https://github.com/cbpowell/SenseLink/wiki) for configuration details! 34 | 35 | ## Plug Definition 36 | ### Required Parameters 37 | Each plug definition needs, at the minimum, the following parameters: 38 | - `alias`: The plug name - this is the name you'd see if this was a real plug configured in the TP-Link Kasa app 39 | - `mac`: A **unique** MAC address for the emulated plug. This is how Sense differentiates plugs! 40 | 41 | If a `mac` value is not supplied, SenseLink will generate one at runtime - but this is almost certainly **not what you want**. With a random MAC address, a Sense will detect the SenseLink instances as "new" plug each time SenseLink is started! 42 | 43 | You can use the `PlugInstance` module to generate a random MAC address if you don't want to just make one up. When in the project folder, use: `python3 -m PlugInstance` 44 | 45 | ### Optional Parameters 46 | #### Skip Rate 47 | A `skip_rate` key and value can be provided in the plug definition. This per-plug value defines how many incoming requests will be skipped before SenseLink will allow the plug to respond. A `skip_rate` of `0` is the inherent default, and means the plug will respond to every request. A `skip_rate` of `3` will cause three (3) requests to be skipped before a response is provided. 48 | 49 | While this is completely unverified, anecdotally it has been stated that the Sense plug limit is related to the available processing power to parse incoming replies. This feature *may* allow you to expand beyond this limit, by reducing the response rate for plugs with static or near-static power readings, and thereby reducing the response load on your Sense meter. 50 | 51 | Note that (obviously) the value reported by Sense will not change when responses are skipped, even if your data source value is updated. In my testing, a `skip_rate` of more than `5` or `6` will cause Sense to start reporting the plug as "N/A", and values higher than that will result in the plug appearing as "Off". 52 | 53 | #### Device ID 54 | Each real TP-Link plug also supplies a unique `device_id` value, however based on my testing Sense doesn't care about this value. If not provided in your configuration, SenseLink will generate a random one at runtime for each plug. Sense could change this in the future, so it is probably a good idea to generate and define a static `device_id` value in your configuration. The `PlugInstances` module will provide one if run as described above. 55 | 56 | ### Minimum Configuration 57 | A minimum configuration file and static-type plug definition will look like the following: 58 | ```yaml 59 | sources: 60 | - static: 61 | plugs: 62 | - BasicPlug: 63 | mac: 50:c7:bf:f6:4b:07 64 | max_watts: 15 65 | alias: "Basic Plug" 66 | ``` 67 | 68 | ## Dynamic Plug Definition 69 | More "advanced" plugs using smarthome/IoT integrations will require more details - see [the wiki configuration pages](https://github.com/cbpowell/SenseLink/wiki) for more information! 70 | 71 | 1. [Static plugs](https://github.com/cbpowell/SenseLink/wiki/Static-Plugs) 72 | 2. [Home Assistant plugs](https://github.com/cbpowell/SenseLink/wiki/Home-Assistant-Plugs) 73 | 3. [MQTT plugs](https://github.com/cbpowell/SenseLink/wiki/MQTT-Plugs) 74 | 4. [Mutable plugs](https://github.com/cbpowell/SenseLink/wiki/Mutable-Plugs) (Mutable plugs are dynamic only in that they may be updated directly via Python code in module usage) 75 | 76 | ## Aggregate Plug Definition 77 | Aggregate plugs can be used to __sum the power usage__ of any number of other defined plugs (inside SenseLink). For example: if you have Caseta dimmers on multiple light switches in your Kitchen, you can define individual HASS plugs for each switch, and then specify a "Kitchen" aggregate plug comprised of all those individual HASS plugs. The Aggregate plug will report the sum power of the individual plugs, and the individual plugs will __not__ be reported to Sense independently. 78 | 79 | Each Aggregate plug requires the following definition (similar to the Basic plug, but without the `max_watts` key): 80 | ```yaml 81 | sources: 82 | ... # other plugs defined here! 83 | - aggregate: 84 | plugs: 85 | - Kitchen_Aggregate: 86 | mac: 50:c7:bf:f6:4d:01 87 | alias: "Kitchen Lights" 88 | elements: 89 | - Kitchen_Overhead 90 | - Kitchen_LEDs 91 | - Kitchen_Spot 92 | ``` 93 | Note: SenseLink will prevent you from listing the same plug in more than one Aggregate plug, to prevent double-reporting. 94 | 95 | ## Additional Configuration 96 | ### Target Setting 97 | SenseLink will respond with power usage data to the/any IP that sends the appropriate broadcast UDP request (normally your Sense monitor), unless the top-level `target` key is specified. If the `target` key is specified, SenseLink will respond to *only* that host/IP address when it receives a broadcast request. This is useful when using SenseLink on a non-Linux Docker host that does not allow using host networking (i.e. `--net=host`). You can specify the (preferably static) IP address of your Sense monitor as the target. 98 | 99 | The `target` key should be used as follows: 100 | ```yaml 101 | target: 192.168.1.20 # replace with your Monitor IP 102 | sources: 103 | - static: 104 | plugs: 105 | ... 106 | ``` 107 | 108 | # Usage 109 | First of all, note that whatever **computer or device running SenseLink needs to be on the same subnet as your Sense Home Energy Meter**! Otherwise SenseLink won't get the UDP broadcasts from the Sense requesting plug updates. There might be ways around this with UDP reflectors, but that's beyond the scope of this document. 110 | 111 | ## Command Line / Python Interpreter 112 | SenseLink can be installed via `pip`, using: `pip install senselink`. Alternatively you can clone the git repository and use it directly. 113 | 114 | Once installed, SenseLinnk can be started directly via the command line using: 115 | `python3 -m senselink -c "/path/to/your/config.yml"` 116 | 117 | The `-l` option can also be used to set the logging level (`-l "DEBUG"`). SenseLink needs to be able to listen on UDP port `9999`, so be sure you allow incoming on any firewalls. 118 | 119 | ## Docker 120 | A Docker image is [available](https://hub.docker.com/repository/docker/theta142/senselink) from Dockerhub, as: `theta142/SenseLink`. When running in Docker the configuration file needs to be passed in to SenseLink, and and the container needs to be able to listen on UDP port `9999`. Unfortunately the Docker network translation doesn't play nice with the Sense UDP broadcast, so you must use either: 121 | 1. Host networking (`--net=host`) on a Linux host, or 122 | 2. The [`target` configuration setting](#target-setting), with your Sense monitor IP specified. A Docker port mapping (`-p 9999:9999`) should also be set in this case. 123 | 124 | An example run command is: 125 | 126 | `docker run -v $(pwd)/your_config.yml:/etc/senselink/config.yml -e LOGLEVEL=INFO --net=host theta142/senselink:latest` 127 | 128 | An example `docker-compose` file is also provided in the repository. 129 | 130 | ## In other projects 131 | See the usage in the [`module_usage_example.py`](https://github.com/cbpowell/SenseLink/blob/master/module_usage_example.py) file. 132 | 133 | # Todo 134 | - Add additional integrations! 135 | - Add a HTTP GET/POST semi-static data source type 136 | - Make things more Pythonic (this is my first major tool written in Python!) 137 | - Allow non-linear attribute-to-power relationships 138 | 139 | 140 | # About 141 | Copyright 2020, Charles Powell 142 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker buildx use armbuilder 4 | docker buildx inspect --bootstrap 5 | 6 | latest=$(git describe --tags $(git rev-list --tags --max-count=1)) 7 | 8 | docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t theta142/senselink:$latest -t theta142/senselink:latest --push . -------------------------------------------------------------------------------- /config_example.yml: -------------------------------------------------------------------------------- 1 | sources: 2 | # Home Assistant 3 | - hass: 4 | url: "ws://your.HASS.API.URL.here" 5 | auth_token: "your_token_here" 6 | plugs: 7 | # Scaled attribute (dimmer setting) example 8 | - Kitchen_Lights: 9 | alias: "Kitchen Lights" 10 | entity_id: light.kitchen_main_lights 11 | mac: 53:75:31:f6:4b:01 12 | off_usage: 0 13 | min_watts: 0 14 | max_watts: 42 15 | attribute_min: 0 16 | attribute_max: 255 17 | attribute: brightness 18 | off_state_value: off 19 | 20 | # Direct power usage reporting example 21 | - Pump_Power_Meter: 22 | alias: "Sump Pump Power Meter" 23 | entity_id: sensor.sump_pump 24 | mac: 53:75:31:f6:4b:02 25 | # Or, if the power usage value is buried in the state update, something like: 26 | # power_keypath: "state/usage/power" 27 | 28 | # Example with an "off" vampire consumption 29 | - Outdoor_Lights: 30 | alias: "Outdoor Lights" 31 | entity_id: light.outdoor_deck_lights 32 | mac: 53:75:31:f6:4b:03 33 | off_usage: 4 # Represents a vampire load when "off" 34 | min_watts: 10 35 | max_watts: 60 36 | attribute_min: 0 37 | attribute_max: 255 38 | attribute: brightness 39 | off_state_value: off 40 | 41 | # Binary on/off state-based usage device 42 | - Dehumidifier: 43 | alias: "Dehumidifier" 44 | entity_id: switch.dehumidifier 45 | mac: 53:75:31:f6:4b:04 46 | off_usage: 0 47 | max_watts: 42 # Wattage used for 'on' state 48 | on_state_value: "on" 49 | off_state_value: "off" 50 | 51 | # MQTT 52 | - mqtt: 53 | host: "your.mqtt.broker" 54 | port: 1883 # Optional 55 | username: admin # Optional 56 | password: supersecret1 # Optional 57 | plugs: 58 | # Direct power reporting example 59 | - UPS: 60 | mac: 53:75:31:f6:4d:01 61 | alias: "UPS Backup" 62 | power_topic: server_ups/usage # Value at this topic should be numeric and units of watts! 63 | # Direct power, with state 64 | - VacuumCharger: 65 | alias: "Vacuum Charger" 66 | mac: 53:75:31:f6:4d:02 67 | power_topic: vacuum/usage 68 | state_topic: vacuum/charging_state 69 | on_state_value: "charging" 70 | off_state_value: "not charging" 71 | off_usage: 1 # 1W vampire draw, reported when the state topic value is "not charging" 72 | # Scaled attribute (dimmer setting) example 73 | - Porch_Light: 74 | alias: "Back Porch Light" 75 | mac: 53:75:31:f6:4d:03 76 | attribute_topic: "lights/porch/brightness" 77 | attribute_min: 0 78 | attribute_max: 255 79 | min_watts: 0 80 | max_watts: 120 81 | timeout_duration: 3600 # Seconds 82 | # Use If the device will normally publish updates on at least a regular interval, and you 83 | # want to assume the "off" state if it misses that interval (*technically* this 84 | # should be done via MQTT's Last Will & Testament feature...) 85 | 86 | # Static 87 | - static: 88 | plugs: 89 | - NAS: 90 | mac: 53:75:31:f6:4c:01 91 | max_watts: 15 92 | alias: "NAS Server" 93 | skip_rate: 3 94 | - Fan: 95 | mac: 53:75:31:f6:4c:02 96 | max_watts: 5 97 | alias: "Ceiling Fan" 98 | 99 | # Mutable 100 | - mutable: 101 | plugs: 102 | - mutable1: 103 | alias: "Mutable 1" 104 | mac: 53:75:31:f6:5c:02 105 | power: 12 106 | 107 | # Aggregate 108 | - aggregate: 109 | plugs: 110 | - agg1: 111 | alias: "Kitchen Aggregate" 112 | mac: 50:c7:bf:f6:4e:01 113 | # Specify plug ID values (keys) to aggregate and report as a single value 114 | # Aggregate plug will update dynamically as the element reported powers change! 115 | # Useful to stay under the ~20 plug Sense limit 116 | elements: 117 | - Fan 118 | - Kitchen_Lights -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | senselink: 4 | container_name: senselink 5 | image: theta142/senselink:latest 6 | restart: unless-stopped 7 | network_mode: host 8 | 9 | # Optional environment variables 10 | # environment: 11 | # - LOGLEVEL=DEBUG 12 | # - SENSE_RESPONSE=False 13 | 14 | # Pass local configuration as volume to expected location 15 | volumes: 16 | - ./config.yml:/etc/senselink/config.yml:ro 17 | -------------------------------------------------------------------------------- /module_usage_example.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import random 4 | import sys 5 | 6 | # pip install SenseLink 7 | from senselink import SenseLink 8 | 9 | root = logging.getLogger() 10 | root.setLevel(logging.DEBUG) 11 | handler = logging.StreamHandler(sys.stdout) 12 | 13 | 14 | async def change_mutable_plug_power(plug): 15 | while True: 16 | power = random.randrange(2, 15, 1) 17 | plug.data_source.power = power 18 | logging.info(f"Changed power to {power}") 19 | await asyncio.sleep(random.randrange(1, 4, 1)) 20 | 21 | 22 | # Config example 23 | # sources: 24 | # - mutable: 25 | # plugs: 26 | # - mutable1: 27 | # alias: "Mutable 1" 28 | # mac: 50:c7:bf:f6:4f:39 # used specifically below 29 | # power: 15 30 | 31 | 32 | async def main(): 33 | # Get config 34 | config = open('config.yml', 'r') 35 | # Create controller, with config 36 | controller = SenseLink(config) 37 | # Create instances 38 | controller.create_instances() 39 | 40 | # Get Mutable controller object, and create task to update it 41 | mutable_plug = controller.plug_for_mac("50:c7:bf:f6:4f:39") 42 | plug_update = change_mutable_plug_power(mutable_plug) 43 | 44 | # Get base SenseLink tasks (for other controllers in the config, perhaps), and 45 | # add our new top level plug task, as well as the main SenseLink controller itself 46 | tasks = controller.tasks 47 | tasks.add(plug_update) 48 | tasks.add(controller.server_start()) 49 | 50 | # Start all the tasks 51 | logging.info("Starting SenseLink controller") 52 | await asyncio.gather(*tasks) 53 | 54 | 55 | if __name__ == "__main__": 56 | try: 57 | asyncio.run(main()) 58 | except KeyboardInterrupt: 59 | logging.info("Interrupt received, stopping SenseLink") 60 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42"] 3 | build-backend = "setuptools.build_meta" -------------------------------------------------------------------------------- /senselink/__init__.py: -------------------------------------------------------------------------------- 1 | from .senselink import SenseLink 2 | from .plug_instance import PlugInstance 3 | from .data_source import DataSource, MutableSource, AggregateSource 4 | -------------------------------------------------------------------------------- /senselink/__main__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import asyncio 3 | import os 4 | import argparse 5 | from senselink import SenseLink 6 | 7 | if __name__ == "__main__": 8 | parser = argparse.ArgumentParser() 9 | parser.add_argument("-c", "--config", help="specify config file path") 10 | parser.add_argument("-l", "--log", help="specify log level (DEBUG, INFO, etc)") 11 | parser.add_argument("-q", "--quiet", help="do not respond to Sense UPD queries", action="store_true") 12 | args = parser.parse_args() 13 | config_path = args.config or '/etc/senselink/config.yml' 14 | loglevel = args.log or 'WARNING' 15 | 16 | loglevel = os.environ.get('LOGLEVEL', loglevel).upper() 17 | logging.basicConfig(level=loglevel) 18 | 19 | # Assume config file is in etc directory 20 | config_location = os.environ.get('CONFIG_LOCATION', config_path) 21 | logging.debug(f"Using config at: {config_location}") 22 | config = open(config_location, 'r') 23 | 24 | server = SenseLink(config) 25 | 26 | if os.environ.get('SENSE_RESPONSE', 'True').upper() == 'TRUE' and not args.quiet: 27 | logging.info("Will respond to Sense broadcasts") 28 | server.should_respond = True 29 | # Create instances 30 | server.create_instances() 31 | 32 | # Start and run indefinitely 33 | logging.info("Starting SenseLink controller") 34 | try: 35 | asyncio.run(server.start()) 36 | except KeyboardInterrupt: 37 | logging.info("Interrupt received, stopping SenseLink") 38 | -------------------------------------------------------------------------------- /senselink/common.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022, Charles Powell 2 | import dpath.util 3 | import logging 4 | 5 | 6 | # Check if a multi-layer key exists 7 | def keys_exist(element, *keys): 8 | if not isinstance(element, dict): 9 | raise AttributeError('keys_exists() expects dict as first argument.') 10 | if len(keys) == 0: 11 | raise AttributeError('keys_exists() expects at least two arguments, one given.') 12 | 13 | _element = element 14 | for key in keys: 15 | try: 16 | _element = _element[key] 17 | except KeyError: 18 | return False 19 | return True 20 | 21 | 22 | def safekey(d, keypath, default=None): 23 | try: 24 | val = dpath.util.get(d, keypath) 25 | return val 26 | except KeyError: 27 | return default 28 | 29 | 30 | def get_float_at_path(message, path, default_value=None): 31 | # Get attribute value, checking to force it to be a number 32 | raw_value = safekey(message, path) 33 | try: 34 | value = float(raw_value) 35 | except (ValueError, TypeError): 36 | logging.debug(f'Unable to convert attribute path {path} value ({raw_value}) to float, using {default_value}') 37 | value = default_value 38 | 39 | return value 40 | -------------------------------------------------------------------------------- /senselink/data_source.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022, Charles Powell 2 | 3 | class DataSource: 4 | _power = None 5 | _voltage = 120 6 | instances = [] 7 | state = True # Assume on 8 | off_usage = 0.0 9 | min_watts = 0.0 10 | max_watts = 0.0 11 | on_fraction = 1.0 12 | controller = None 13 | 14 | def __init__(self, identifier, details, controller=None): 15 | self.identifier = identifier 16 | self.add_controller(controller) 17 | if details is not None: 18 | min_watts = details.get('min_watts') or 0.0 19 | self.off_usage = details.get('off_usage') or min_watts 20 | self.min_watts = min_watts or 0.0 21 | self.max_watts = details.get('max_watts') or 0.0 22 | self.on_fraction = details.get('on_fraction') or 1.0 23 | self.voltage = details.get('voltage') or 120 24 | 25 | self.delta_watts = self.max_watts - self.min_watts 26 | 27 | @property 28 | def power(self): 29 | if self._power is not None: 30 | return self._power 31 | 32 | # Otherwise, determine wattage 33 | if self.state: 34 | # On 35 | power = self.min_watts + self.on_fraction * self.delta_watts 36 | else: 37 | # Off 38 | power = self.off_usage 39 | return power 40 | 41 | @power.setter 42 | def power(self, new_power): 43 | self._power = new_power 44 | 45 | @property 46 | def current(self): 47 | # Determine current, assume 120V 48 | voltage = self.voltage 49 | current = self.power / voltage 50 | return current 51 | 52 | @property 53 | def voltage(self): 54 | # Return preset voltage 55 | return self._voltage 56 | 57 | @voltage.setter 58 | def voltage(self, new_voltage): 59 | self._voltage = new_voltage 60 | 61 | def add_controller(self, controller): 62 | # Provided to allow override 63 | self.controller = controller 64 | # Add self to passed-in controller (which might be None, for static plugs) 65 | if self.controller is not None: 66 | self.controller.data_sources.append(self) 67 | 68 | 69 | class MutableSource(DataSource): 70 | _power = 0.0 71 | 72 | def __init__(self, identifier, details, controller=None): 73 | super().__init__(identifier, details, controller) 74 | if details is not None: 75 | self.power = details.get('power') or 0.0 76 | 77 | 78 | class AggregateSource(DataSource): 79 | 80 | def __init__(self, identifier, details, controller): 81 | super().__init__(identifier, details, controller) 82 | 83 | self.elements = [] 84 | 85 | if details is not None: 86 | self.element_ids = details.get('elements') or [] 87 | 88 | @property 89 | def power(self): 90 | # Get power values from individual elements, and sum 91 | plug_powers = list(map(lambda plug: plug.power, self.elements)) 92 | sum_power = sum(plug_powers) 93 | return sum_power 94 | 95 | 96 | if __name__ == "__main__": 97 | pass 98 | -------------------------------------------------------------------------------- /senselink/homeassistant/__init__.py: -------------------------------------------------------------------------------- 1 | from .ha_controller import HAController 2 | from .ha_data_source import HASource 3 | -------------------------------------------------------------------------------- /senselink/homeassistant/ha_controller.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import websockets 3 | import json 4 | from socket import gaierror 5 | from senselink.common import * 6 | 7 | 8 | class HAController: 9 | ws = None 10 | event_rq_id = 1 11 | bulk_rq_id = 2 12 | data_sources = [] 13 | 14 | def __init__(self, url, auth_token, max_ws_message_size=None): 15 | self.url = url 16 | self.auth_token = auth_token 17 | self.max_ws_message = max_ws_message_size 18 | 19 | async def connect(self): 20 | # Create task 21 | await self.client_handler() 22 | 23 | async def client_handler(self): 24 | logging.info(f"Starting websocket client to URL: {self.url}") 25 | try: 26 | if self.max_ws_message is not None: 27 | max_ws_param = int(self.max_ws_message) 28 | if max_ws_param > 0: 29 | ws_args = {"uri": self.url, "max_size": max_ws_param} 30 | else: 31 | # Interpret 0 as None/No limit 32 | ws_args = ws_args = {"uri": self.url, "max_size": None} 33 | else: 34 | ws_args = {"uri": self.url} 35 | 36 | async with websockets.connect(**ws_args) as websocket: 37 | self.ws = websocket 38 | # Wait for incoming message from server 39 | while True: 40 | try: 41 | message = await websocket.recv() 42 | logging.debug(f"Received message: {message}") 43 | await self.on_message(websocket, message) 44 | except websockets.exceptions.ConnectionClosed as err: 45 | logging.error(f"Lost connection to websocket server ({err})") 46 | logging.info(f"Reconnecting in 10...") 47 | await asyncio.sleep(10) 48 | asyncio.create_task(self.client_handler()) 49 | return False 50 | except (websockets.exceptions.WebSocketException, asyncio.exceptions.TimeoutError, gaierror) as err: 51 | logging.error(f"Unable to connect to server at {self.url} ({type(err)}:{err})") 52 | logging.info(f"Attempting to reconnect in 10...") 53 | await asyncio.sleep(10) 54 | asyncio.create_task(self.client_handler()) 55 | 56 | async def on_message(self, ws, message): 57 | # Authentication with HASS Websockets 58 | message = json.loads(message) 59 | 60 | if 'type' in message and message['type'] == 'auth_required': 61 | logging.info("Authentication requested") 62 | auth_response = {'type': 'auth', 'access_token': self.auth_token} 63 | await ws.send(json.dumps(auth_response)) 64 | 65 | elif 'type' in message and message['type'] == "auth_invalid": 66 | logging.error("Authentication failed") 67 | 68 | elif 'type' in message and message['type'] == "auth_ok": 69 | logging.info("Authentication successful") 70 | # Authentication successful 71 | # Send subscription command 72 | events_command = { 73 | "id": self.event_rq_id, 74 | "type": "subscribe_events", 75 | "event_type": "state_changed" 76 | } 77 | await ws.send(json.dumps(events_command)) 78 | logging.info("Event update request sent") 79 | 80 | # Request full status update to get current value 81 | events_command = { 82 | "id": self.bulk_rq_id, 83 | "type": "get_states", 84 | } 85 | await ws.send(json.dumps(events_command)) 86 | logging.info("All states request sent") 87 | 88 | elif 'type' in message and message['id'] == self.event_rq_id: 89 | # Look for state_changed events 90 | logging.debug("Potential event update received") 91 | # Check for data 92 | if not safekey(message, 'event/data'): 93 | return 94 | # Notify attached data sources 95 | for ds in self.data_sources: 96 | ds.parse_incremental_update(message['event']['data']) 97 | 98 | elif 'type' in message and message['id'] == self.bulk_rq_id: 99 | # Look for state_changed events 100 | logging.info("Bulk update received") 101 | if message.get('result') is None: 102 | return 103 | # Extract data 104 | bulk_update = message.get('result') 105 | logging.debug(f"Entity update received: {bulk_update}") 106 | # Loop through statuses 107 | for status in bulk_update: 108 | # Notify attached data sources 109 | for ds in self.data_sources: 110 | ds.parse_bulk_update(status) 111 | else: 112 | logging.debug(f"Unknown/unhandled message received: {message}") 113 | -------------------------------------------------------------------------------- /senselink/homeassistant/ha_data_source.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022, Charles Powell 2 | from math import isclose 3 | 4 | from senselink.data_source import DataSource 5 | from .ha_controller import * 6 | 7 | # Independently set WS logger 8 | wslogger = logging.getLogger('websockets') 9 | wslogger.setLevel(logging.WARNING) 10 | 11 | 12 | class HASource(DataSource): 13 | # Primary output property 14 | _power = 0.0 15 | 16 | def add_controller(self, controller): 17 | # Add self to passed-in Websocket controller 18 | if not isinstance(controller, HAController): 19 | raise TypeError( 20 | f"Incorrect controller type {type(self.controller).__name__} passed to HASS Data Source") 21 | super().add_controller(controller) 22 | 23 | def __init__(self, identifier, details, controller): 24 | super().__init__(identifier, details, controller) 25 | 26 | if details is not None: 27 | # Entity ID 28 | self.entity_id = details.get('entity_id') 29 | # First check if power_keypath is defined, indicating this entity should provide a pre-calculated 30 | # power value, so no attribute scaling required 31 | self.power_keypath = details.get('power_keypath') or None 32 | # Min/max values for the wattage reference from the source (i.e. 0 to 255 brightness, 0 to 100%, etc) 33 | self.attribute_min = details.get('attribute_min') or 0.0 34 | self.attribute_max = details.get('attribute_max') or 0.0 35 | # Websocket response key paths 36 | self.state_keypath = details.get('state_keypath') or 'state' 37 | self.off_state_value = details.get('off_state_value') or 'off' 38 | self.on_state_value = details.get('on_state_value') or None 39 | self.attribute = details.get('attribute') or None 40 | self.attribute_keypath = details.get('attribute_keypath') or None 41 | 42 | if self.attribute is None and self.power_keypath is None: 43 | # No specific key or keypath defined, assuming base state key provides power usage 44 | logging.debug(f"Defaulting to using base state value for power usage for {self.entity_id}") 45 | 46 | self.attribute_delta = self.attribute_max - self.attribute_min 47 | 48 | def parse_bulk_update(self, message): 49 | # Check for entity_id of interest 50 | if safekey(message, 'entity_id') != self.entity_id: 51 | return 52 | logging.debug(f"Entity update received: {message}") 53 | 54 | root_path = '' 55 | self.parse_update(root_path, message) 56 | 57 | def parse_incremental_update(self, message): 58 | # Check for entity_id of interest 59 | if safekey(message, 'entity_id') != self.entity_id: 60 | return 61 | logging.debug(f"Parsing incremental update for {self.entity_id}: {message}") 62 | 63 | root_path = 'new_state/' 64 | self.parse_update(root_path, message) 65 | 66 | def parse_update(self, root_path, message): 67 | # State path 68 | state_path = root_path + self.state_keypath 69 | # Figure out attribute path 70 | if self.power_keypath is not None: 71 | # Get value at power keypath as attribute 72 | attribute_path = root_path + self.power_keypath 73 | elif self.attribute is not None: 74 | # Get (single key) attribute 75 | attribute_path = root_path + 'attributes/' + self.attribute 76 | elif self.attribute_keypath is not None: 77 | # Get attribute at path specified 78 | attribute_path = root_path + self.attribute_keypath 79 | else: 80 | # Get the base state as the attribute (i.e. if power is reported directly as state) 81 | attribute_path = state_path 82 | 83 | # Pull values at determined paths 84 | state_value = safekey(message, state_path) 85 | attribute_value = get_float_at_path(message, attribute_path) 86 | 87 | # Try parsing values 88 | try: 89 | self.parse_update_values(state_value, attribute_value) 90 | except ValueError as err: 91 | logging.error(f'Error for entity {self.entity_id}: {err}, when parsing message: {message}') 92 | 93 | def parse_update_values(self, state_value, attribute_value): 94 | # Start with a None value for the resulting power 95 | parsed_power = None 96 | 97 | if state_value is not None: 98 | # Check if device is off as determined by state 99 | if state_value == self.off_state_value: 100 | # If user specifies a state value for OFF 101 | logging.debug(f"Entity {self.identifier} set to OFF based on state_value") 102 | # Device is off - set wattage appropriately 103 | parsed_power = self.off_usage 104 | self.state = False 105 | self.power = parsed_power 106 | logging.info(f"Updated wattage for {self.identifier}: {parsed_power}") 107 | # Do not continue execution, as attribute_value could still be populated 108 | # but this plug is defined to be OFF at this stage 109 | return 110 | 111 | # Check if device is on as determined by state (if on_state_value defined) 112 | if state_value == self.on_state_value: 113 | # If user specifies a state value for ON 114 | logging.debug(f"Entity {self.identifier} set to ON based on state_value") 115 | # Device is on - set power to max_wattage, but this may be overwritten 116 | # below if a valid attribute value is also found 117 | parsed_power = self.max_watts 118 | self.state = True 119 | 120 | # Try to get an attribute or power value 121 | if attribute_value is not None: 122 | if self.power_keypath is not None or self.attribute is None: 123 | if self.power_keypath is not None: 124 | # If using power_keypath, just use value for power update 125 | logging.debug(f'Pulling power from keypath: {self.power_keypath} for {self.identifier}') 126 | else: 127 | logging.debug(f'Pulling power from base state value for {self.identifier}') 128 | 129 | parsed_power = attribute_value 130 | 131 | # Assume off if reported power usage is close to off_usage 132 | if isclose(self.power, self.off_usage): 133 | self.state = False 134 | elif parsed_power is None: 135 | # A state-based power 136 | logging.debug(f'Determining power based on attribute for {self.identifier}') 137 | # Get attribute value and scale to provided values 138 | # Clamp to specified min/max 139 | clamp_attr = min(max(self.attribute_min, attribute_value), self.attribute_max) 140 | if attribute_value > clamp_attr or attribute_value < clamp_attr: 141 | logging.error(f"Attribute for entity {self.entity_id} outside expected values") 142 | 143 | # Use linear scaling (for now) 144 | self.on_fraction = (clamp_attr - self.attribute_min) / self.attribute_delta 145 | parsed_power = self.min_watts + self.on_fraction * self.delta_watts 146 | logging.debug(f"Attribute {self.entity_id} at fraction: {self.on_fraction}") 147 | 148 | if parsed_power is None: 149 | logging.info(f"Attribute update failure for {self.identifier}") 150 | raise ValueError(f'No valid attribute found for {self.identifier}') 151 | 152 | self.power = parsed_power 153 | logging.info(f"Updated wattage for {self.identifier}: {parsed_power}") 154 | 155 | @property 156 | def power(self): 157 | return self._power 158 | 159 | @power.setter 160 | def power(self, new_power): 161 | self._power = new_power 162 | 163 | 164 | if __name__ == "__main__": 165 | pass 166 | -------------------------------------------------------------------------------- /senselink/mqtt/__init__.py: -------------------------------------------------------------------------------- 1 | from .mqtt_controller import MQTTController 2 | from .mqtt_data_source import MQTTSource 3 | -------------------------------------------------------------------------------- /senselink/mqtt/mqtt_controller.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022, Charles Powell 2 | 3 | import logging 4 | import asyncio 5 | from aiomqtt import Client, MqttError 6 | from typing import Dict 7 | 8 | from .mqtt_listener import MQTTListener 9 | 10 | MQTT_LOGGER = logging.getLogger('mqtt') 11 | MQTT_LOGGER.setLevel(logging.WARNING) 12 | 13 | 14 | class MQTTController: 15 | client = None 16 | topics: Dict[str, MQTTListener] = None 17 | 18 | def __init__(self, host, port=1883, username=None, password=None): 19 | self.host = host 20 | self.port = port 21 | self.username = username 22 | self.password = password 23 | 24 | self.data_sources = [] 25 | self.listeners = {} 26 | 27 | self.listen_task = None 28 | async def connect(self): 29 | # Create task 30 | await self.client_handler() 31 | 32 | async def client_handler(self): 33 | # Add tasks for each data source handler 34 | for ds in self.data_sources: 35 | # Get handlers from data source 36 | ds_listeners = ds.listeners() 37 | # Iterate through data source listeners and convert to 38 | # 'prime' listeners for each topic 39 | for listener in ds_listeners: 40 | topic = listener.topic 41 | funcs = listener.handlers 42 | if topic in self.listeners: 43 | # Add these handlers to existing top level topic handler 44 | logging.debug(f'Adding handlers for existing prime Listener: {topic}') 45 | ext_topic = self.listeners[topic] 46 | ext_topic.handlers.extend(funcs) 47 | else: 48 | # Add this instance as a new top level handler 49 | logging.debug(f'Creating new prime Listener for topic: {topic}') 50 | self.listeners[topic] = MQTTListener(topic, funcs) 51 | 52 | logging.info(f"Starting MQTT client to URL: {self.host}") 53 | reconnect_interval = 10 # [seconds] 54 | loop = asyncio.get_event_loop() 55 | 56 | while True: 57 | try: 58 | # Listen for MQTT messages in (unawaited) asyncio task 59 | self.listen_task = loop.create_task(self.listen()) 60 | await self.listen_task 61 | except MqttError as error: 62 | logging.error(f'Disconnected from MQTT broker with error: {error}') 63 | logging.debug(f'MQTT client disconnected/ended, reconnecting in {reconnect_interval}...') 64 | # Cancel task and wait 65 | self.listen_task.cancel() 66 | await asyncio.sleep(reconnect_interval) 67 | except Exception as error: 68 | logging.error(f'Stopping MQTT client with error: {error}') 69 | logging.debug(f'MQTT client disconnected/ended, reconnecting in {reconnect_interval}...') 70 | # Cancel task and wait 71 | self.listen_task.cancel() 72 | await asyncio.sleep(reconnect_interval) 73 | except (KeyboardInterrupt, asyncio.CancelledError): 74 | return False 75 | 76 | async def listen(self): 77 | logging.info(f'MQTT client connected') 78 | async with Client(self.host, self.port, username=self.username, password=self.password) as client: 79 | async with client.messages() as messages: 80 | # Subscribe to specified topics 81 | for topic, handlers in self.listeners.items(): 82 | await client.subscribe(topic) 83 | # Handle messages that come in 84 | async for message in messages: 85 | topic = message.topic.value 86 | handlers = self.listeners[topic].handlers 87 | logging.debug(f'Got message for topic: {topic}') 88 | for func in handlers: 89 | # Decode to UTF-8 90 | payload = message.payload.decode() 91 | await func(payload) 92 | 93 | 94 | if __name__ == "__main__": 95 | pass 96 | -------------------------------------------------------------------------------- /senselink/mqtt/mqtt_data_source.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022, Charles Powell 2 | import logging 3 | import asyncio 4 | import json 5 | from math import isclose 6 | from senselink.common import * 7 | from senselink.data_source import DataSource 8 | from .mqtt_controller import MQTTController 9 | from .mqtt_listener import MQTTListener 10 | 11 | 12 | class MQTTSource(DataSource): 13 | # Primary output property 14 | _power = 0.0 15 | timer = None 16 | 17 | def add_controller(self, controller): 18 | # Add self to passed-in MQTT Data Controller 19 | if not isinstance(controller, MQTTController): 20 | raise TypeError( 21 | f"Incorrect controller type {type(self.controller).__name__} passed to MQTT Data Source") 22 | super().add_controller(controller) 23 | 24 | def __init__(self, identifier, details, controller): 25 | super().__init__(identifier, details, controller) 26 | 27 | if details is not None: 28 | # Min/max values for the wattage reference from the source (i.e. 0 to 255 brightness, 0 to 100%, etc) 29 | self.attribute_min = details.get('attribute_min') or 0.0 30 | self.attribute_max = details.get('attribute_max') or 0.0 31 | 32 | # MQTT Topics and handling 33 | self.power_topic = details.get('power_topic') or None 34 | self.power_topic_keypath = details.get('power_topic_keypath') or None 35 | self.state_topic = details.get('state_topic') or None 36 | self.state_topic_keypath = details.get('state_topic_keypath') or None 37 | self.on_state_value = details.get('on_state_value') or 'on' 38 | self.off_state_value = details.get('off_state_value') or 'off' 39 | self.attribute_topic = details.get('attribute_topic') or None 40 | self.attribute_topic_keypath = details.get('attribute_topic_keypath') or None 41 | self.timeout_duration = details.get('timeout_duration') or None 42 | 43 | if not any((self.attribute_topic, self.power_topic, self.state_topic)): 44 | # Need at least ONE topic 45 | raise AssertionError( 46 | f"At least one topic (power, attribute, or state) must be provided to monitor!") 47 | 48 | if all((self.attribute_topic, self.power_topic)): 49 | # Defining attribute AND power topics doesn't make sense! 50 | raise AssertionError( 51 | f"Power and Attribute topics cannot be set simultaneously!") 52 | 53 | self.attribute_delta = self.attribute_max - self.attribute_min 54 | 55 | async def timeout(self, timeout_value): 56 | # Sleep for specified time (seconds) 57 | await asyncio.sleep(timeout_value) 58 | # If we get here, set to off_usage 59 | logging.info(f'Update timeout reached for {self.identifier}, setting to off_usage') 60 | self.update_power(self.off_usage, timeout=False) 61 | self.state = False 62 | 63 | @property 64 | def power(self): 65 | return self._power 66 | 67 | def update_power(self, value, timeout=True): 68 | if self.power_topic_keypath is not None: 69 | logging.debug(f'Extracting power from JSON message, at key path {self.power_topic_keypath}') 70 | # Extract value from (assumed) JSON message at keypath 71 | message = json.loads(value) 72 | # Overwrite value variable with what is extracted from JSON 73 | value = safekey(message, self.power_topic_keypath) 74 | if value is None: 75 | logging.warning(f'Update on power topic failed to find value at power keypath ({self.power_topic_keypath})') 76 | return 77 | 78 | try: 79 | fval = float(value) 80 | except ValueError: 81 | logging.warning(f'Failed to convert power value ("{value}") for {self.identifier} to float, ignoring') 82 | return 83 | 84 | # Reset previous timer 85 | if self.timeout_duration is not None and timeout: 86 | if self.timer is not None: 87 | logging.debug(f'Cancelling prior MQTT timeout timer') 88 | self.timer.cancel() 89 | logging.debug(f'Created MQTT timer with duration {self.timeout_duration}') 90 | self.timer = asyncio.create_task(self.timeout(self.timeout_duration)) 91 | 92 | if not isclose(fval, self.power): 93 | self._power = fval 94 | # Assume off if reported power usage is close to off_usage 95 | if isclose(self.power, self.off_usage): 96 | self.state = False 97 | logging.debug(f'Power equal to off_usage for {self.identifier}, assuming off') 98 | logging.debug(f'Power updated for {self.identifier}: {round(fval, 4)}') 99 | 100 | async def power_handler(self, value): 101 | logging.debug(f'Power topic update for {self.identifier}: {value}') 102 | self.update_power(value) 103 | 104 | async def state_handler(self, value): 105 | logging.debug(f'State topic update for {self.identifier}: {value}') 106 | 107 | if self.state_topic_keypath is not None: 108 | logging.debug(f'Extracting state from JSON message, at key path {self.state_topic_keypath}') 109 | # Extract value from (assumed) JSON message at keypath 110 | message = json.loads(value) 111 | # Overwrite value variable with what is extracted from JSON 112 | value = safekey(message, self.state_topic_keypath) 113 | if value is None: 114 | logging.warning(f'Update on state topic failed to find value at state keypath ({self.state_topic_keypath})') 115 | return 116 | 117 | # Act immediate if state is being set to off 118 | if value == self.off_state_value: 119 | # Device is off 120 | self.state = False 121 | self.update_power(self.off_usage) 122 | logging.debug(f'State set to OFF for {self.identifier}') 123 | elif value == self.on_state_value: 124 | # Device is on, but action depends on if a attribute topic is also defined, 125 | # to distinguish between a state+attribute plug or a state-only plug 126 | if self.attribute_topic is not None: 127 | # Update state only, do not assume wattage because it may be updated separately 128 | # Wattage will be whatever the most recent wattage value was! 129 | self.state = True 130 | logging.debug(f'State set to ON for {self.identifier}, wattage to be set by attribute') 131 | else: 132 | # Update state and set to max_wattage for a binary type plug 133 | self.state = True 134 | self.update_power(self.max_watts) 135 | logging.debug(f'State set to ON for {self.identifier}, using max_watts for power value') 136 | else: 137 | # State does not match on or off values, so check if it's a float 138 | try: 139 | fstate = float(value) 140 | if self.power_topic is None: 141 | # No power topic defined, so use this numeric value as power 142 | logging.debug(f'State update is numeric and no power_topic defined, using as power value') 143 | self.update_power(fstate) 144 | except ValueError: 145 | logging.debug(f'State update ("{value}") is non-numeric and does not match on/off values, ignoring') 146 | 147 | async def attribute_handler(self, value): 148 | logging.debug(f'Attribute topic update for {self.identifier}: {value}') 149 | 150 | if self.attribute_topic_keypath is not None: 151 | logging.debug(f'Extracting attribute value from JSON message, at key path {self.attribute_topic_keypath}') 152 | # Extract value from (assumed) JSON message at keypath 153 | message = json.loads(value) 154 | # Overwrite value variable with what is extracted from JSON 155 | value = safekey(message, self.attribute_topic_keypath) 156 | if value is None: 157 | logging.warning(f'Update on attribute topic failed to find value at attribute keypath ({self.attribute_topic_keypath})') 158 | return 159 | 160 | # Get attribute value and scale to provided values 161 | try: 162 | attribute_value = float(value) 163 | except ValueError: 164 | logging.warning(f'Non-float value ("{value}") received for attribute update, unable to update!') 165 | self._power = self.off_usage 166 | self.state = False 167 | return 168 | 169 | # Clamp to specified min/max 170 | clamp_attr = min(max(self.attribute_min, attribute_value), self.attribute_max) 171 | if attribute_value > clamp_attr or attribute_value < clamp_attr: 172 | logging.error(f"Attribute for {self.identifier} outside expected values") 173 | 174 | # Use linear scaling (for now) 175 | self.on_fraction = (clamp_attr - self.attribute_min) / self.attribute_delta 176 | scaled_power = self.min_watts + self.on_fraction * self.delta_watts 177 | self.update_power(scaled_power) 178 | logging.debug(f"Attribute {self.identifier} at fraction: {self.on_fraction}") 179 | 180 | def listeners(self) -> [MQTTListener]: 181 | # Return MQTTListener objects (topic and function) 182 | logging.info(f'Generating listeners for {self.identifier}') 183 | listeners = [] 184 | if self.power_topic is not None: 185 | listeners.append(MQTTListener(self.power_topic, [self.power_handler])) 186 | if self.state_topic is not None: 187 | listeners.append(MQTTListener(self.state_topic, [self.state_handler])) 188 | if self.attribute_topic is not None: 189 | listeners.append(MQTTListener(self.attribute_topic, [self.attribute_handler])) 190 | 191 | return listeners 192 | -------------------------------------------------------------------------------- /senselink/mqtt/mqtt_listener.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Charles Powell 2 | 3 | class MQTTListener: 4 | def __init__(self, topic, hndls=None): 5 | self.topic = topic 6 | self.handlers = [] 7 | self.handlers.extend(hndls) 8 | -------------------------------------------------------------------------------- /senselink/plug_instance.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020, Charles Powell 2 | 3 | import random 4 | import logging 5 | from .data_source import DataSource 6 | 7 | from typing import Type 8 | from typing import Dict 9 | 10 | # Generator for random MAC address 11 | # Thanks to @pklaus: https://gist.github.com/pklaus/9638536 12 | 13 | 14 | def random_bytes(num=6): 15 | return [random.randrange(256) for _ in range(num)] 16 | 17 | 18 | def generate_mac(uaa=False, multicast=False, oui=None, separator=':', byte_fmt='%02x'): 19 | mac = random_bytes() 20 | if oui: 21 | if type(oui) == str: 22 | oui = [int(chunk, 16) for chunk in oui.split(separator)] 23 | mac = oui + random_bytes(num=6 - len(oui)) 24 | else: 25 | if multicast: 26 | mac[0] |= 1 # set bit 0 27 | else: 28 | mac[0] &= ~1 # clear bit 0 29 | if uaa: 30 | mac[0] &= ~(1 << 1) # clear bit 1 31 | else: 32 | mac[0] |= 1 << 1 # set bit 1 33 | return separator.join(byte_fmt % b for b in mac) 34 | 35 | 36 | def generate_deviceid(): 37 | deviceid_bytes = random_bytes(num=20) 38 | return ''.join('%02x' % b for b in deviceid_bytes) 39 | 40 | 41 | class PlugInstance: 42 | start_time = None 43 | data_source = None 44 | in_aggregate = False # Assume not in aggregate to start 45 | skip_rate = 0.0 46 | _response_counter = 0 47 | 48 | def __init__(self, identifier, alias=None, mac=None, device_id=None): 49 | self.identifier = identifier 50 | if mac is None: 51 | new_mac = generate_mac(oui='53:75:31') 52 | logging.info("Spoofed MAC: %s", new_mac) 53 | self.mac = new_mac 54 | else: 55 | self.mac = mac 56 | 57 | if device_id is None: 58 | new_device_id = generate_deviceid() 59 | logging.info("Spoofed Device ID: %s", new_device_id) 60 | self.device_id = new_device_id 61 | else: 62 | self.device_id = device_id 63 | 64 | if alias is None: 65 | self.alias = "Spoofed TP-Link Kasa HS110 " + self.device_id[0:8] 66 | else: 67 | self.alias = alias 68 | 69 | @classmethod 70 | # Convenience method to create a lot of plugs 71 | def configure_plugs(cls, plugs, data_source_class: Type[DataSource], data_controller=None) -> Dict: 72 | # Loop through all plugs 73 | instances = {} 74 | for plug in plugs: 75 | # Get specified identifier 76 | plug_id = next(iter(plug.keys())) 77 | # Get plug details 78 | details = plug.get(plug_id) 79 | if details is not None: 80 | # Define main details 81 | alias = details.get('alias') 82 | mac = details.get('mac') 83 | device_id = details.get('device_id') 84 | skip_rate = details.get('skip_rate') or 0.0 85 | 86 | # Create and configure instance 87 | instance = cls(plug_id, alias, mac, device_id) 88 | instance.skip_rate = skip_rate 89 | 90 | # Generate data source with details, and assign 91 | instance.data_source = data_source_class(plug_id, details, data_controller) 92 | 93 | # Check if this MAC has already been used 94 | if mac in instances.keys(): 95 | # Assertion error - can't use the same MAC twice! 96 | prev_id = instances[mac] 97 | raise AssertionError( 98 | f"Configuration Error: Two plugs configured with the same MAC address! ({prev_id}, {plug_id})") 99 | 100 | # Add this plug to list of instances 101 | instances[mac] = instance 102 | 103 | logging.debug(f"Added plug: {plug_id}") 104 | 105 | return instances 106 | 107 | @property 108 | def power(self): 109 | return self.data_source.power 110 | 111 | def generate_response(self): 112 | # Grab latest values from source 113 | power = self.data_source.power 114 | current = self.data_source.current 115 | voltage = self.data_source.voltage 116 | 117 | # Response dict 118 | response = { 119 | "emeter": { 120 | "get_realtime": { 121 | "current": current, 122 | "voltage": voltage, 123 | "power": power, 124 | "total": 0, # Unsure if this needs a value, appears not to 125 | "err_code": 0 # No errors here! 126 | } 127 | }, 128 | "system": { 129 | "get_sysinfo": { 130 | "err_code": 0, 131 | "sw_ver": "1.2.5 Build 171206 Rel.085954", 132 | "hw_ver": "1.0", 133 | "type": "IOT.SMARTPLUGSWITCH", 134 | "model": "HS110(US)", # Previously used 'SenseLink', but first-run issues were found, see https://github.com/cbpowell/SenseLink/issues/17 135 | "mac": self.mac.upper(), 136 | "deviceId": self.mac.upper(), 137 | "alias": self.alias, 138 | "relay_state": 1, # Assuming it's on, not sure it matters 139 | "updating": 0 140 | } 141 | } 142 | } 143 | 144 | return response 145 | 146 | def should_respond(self, apply_counter=True): 147 | if self._response_counter < 1: 148 | if apply_counter: 149 | self._response_counter = self.skip_rate 150 | return True 151 | else: 152 | if apply_counter: 153 | # Decrement counter 154 | self._response_counter = max(self._response_counter - 1, 0) 155 | return False 156 | 157 | 158 | if __name__ == "__main__": 159 | # Convenience function to generate a MAC address and Device ID 160 | gen_device_id = generate_deviceid() 161 | print(f"Generated Device ID: {gen_device_id.upper()}") 162 | gen_mac = generate_mac(oui='50:c7:bf') 163 | print(f"Generated MAC: {gen_mac.upper()}") 164 | -------------------------------------------------------------------------------- /senselink/senselink.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020, Charles Powell 2 | 3 | import yaml 4 | import asyncio 5 | import argparse 6 | import logging 7 | import dpath.util 8 | import json 9 | 10 | from .common import * 11 | from .data_source import * 12 | from .plug_instance import * 13 | from .tplink_encryption import * 14 | 15 | from senselink.mqtt import * 16 | from senselink.homeassistant import * 17 | 18 | STATIC_KEY = 'static' 19 | MUTABLE_KEY = 'mutable' 20 | HASS_KEY = 'hass' 21 | MQTT_KEY = 'mqtt' 22 | AGG_KEY = 'aggregate' 23 | PLUGS_KEY = 'plugs' 24 | 25 | 26 | class SenseLinkProtocol(asyncio.DatagramProtocol): 27 | transport = None 28 | target = None 29 | 30 | def __init__(self, instances, finished): 31 | self._instances = instances 32 | self.should_respond = True 33 | self.finished = finished 34 | 35 | def connection_made(self, transport): 36 | self.transport = transport 37 | 38 | def connection_lost(self, exc): 39 | pass 40 | 41 | def datagram_received(self, data, addr): 42 | # Decrypt request data 43 | decrypted_data = decrypt(data) 44 | # Determine target 45 | request_addr = self.target or addr[0] 46 | 47 | try: 48 | # Get JSON data 49 | json_data = json.loads(decrypted_data) 50 | 51 | # Sense requests the emeter and system parameters 52 | if keys_exist(json_data, "emeter", "get_realtime") and keys_exist(json_data, "system", "get_sysinfo"): 53 | # Check for non-empty values, to prevent echo storms 54 | if bool(safekey(json_data, 'emeter/get_realtime')): 55 | # This is a self-echo, common with Docker without --net=Host! 56 | logging.debug("Ignoring non-empty/non-Sense UDP request") 57 | return 58 | 59 | logging.debug(f"Broadcast received from {request_addr}: {json_data}") 60 | 61 | # Build and send responses 62 | for inst in self._instances.values(): 63 | # Check if this instance is in an aggregate 64 | if inst.in_aggregate: 65 | # Do not send individual response for this plug 66 | logging.debug(f"Plug '{inst.identifier}' in aggregate, not sending discrete response") 67 | continue 68 | 69 | # Build response 70 | response = inst.generate_response() 71 | json_str = json.dumps(response, separators=(',', ':')) 72 | encrypted_str = encrypt(json_str) 73 | # Strip leading 4 bytes for...some reason 74 | trun_str = encrypted_str[4:] 75 | 76 | # Allow disabling response, and rate limiting 77 | plug_respond = inst.should_respond() 78 | if self.should_respond and plug_respond: 79 | # Send response 80 | logging.debug(f"Sending response for plug {inst.identifier}: {response}") 81 | self.transport.sendto(trun_str, addr) 82 | elif not plug_respond: 83 | logging.debug(f'Plug {inst.identifier} response rate limited') 84 | else: 85 | # Do not send response, but log for debugging 86 | logging.debug( 87 | f"SENSE_RESPONSE disabled, plug {inst.identifier} response content would be: {response}") 88 | else: 89 | logging.debug(f"Ignoring non-emeter JSON from {request_addr}: {json_data}") 90 | 91 | # Appears to not be JSON 92 | except ValueError: 93 | logging.debug("Did not receive valid JSON message, ignoring") 94 | 95 | 96 | class SenseLink: 97 | transport = None 98 | protocol = None 99 | should_respond = True 100 | has_aggregate = False 101 | 102 | def __init__(self, config=None, port=9999): 103 | self.config = config 104 | self.port = port 105 | self.target = None 106 | self.server_task = None 107 | self.instances = {} 108 | self._agg_instances = {} 109 | self.tasks = set() 110 | 111 | def create_instances(self): 112 | config = yaml.load(self.config, Loader=yaml.FullLoader) 113 | logging.debug(f"Configuration loaded: {config}") 114 | sources = config.get('sources') 115 | self.target = config.get('target') or None 116 | aggregate = None 117 | 118 | for source in sources: 119 | # Get specified identifier 120 | source_id = next(iter(source.keys())) 121 | logging.debug(f"Adding {source_id} configuration") 122 | # Static value plugs 123 | if source_id.lower() == STATIC_KEY: 124 | # Static sources require no extra config 125 | static = source[STATIC_KEY] 126 | if static is None: 127 | logging.error(f"Configuration error for Source {source_id}") 128 | # Generate plug instances 129 | plugs = static[PLUGS_KEY] 130 | logging.info("Generating Static instances") 131 | instances = PlugInstance.configure_plugs(plugs, DataSource) 132 | self.add_instances(instances) 133 | 134 | # Mutable value plugs 135 | elif source_id.lower() == MUTABLE_KEY: 136 | mutable = source[MUTABLE_KEY] 137 | if mutable is None: 138 | logging.error(f"Configuration error for Source {source_id}") 139 | # Generate plug instances 140 | plugs = mutable[PLUGS_KEY] 141 | logging.info("Generating Mutable instances") 142 | instances = PlugInstance.configure_plugs(plugs, MutableSource) 143 | self.add_instances(instances) 144 | 145 | # HomeAssistant Plugs, using Websockets datasource 146 | elif source_id.lower() == HASS_KEY: 147 | # Configure this HASS Data source 148 | hass = source[HASS_KEY] 149 | if hass is None: 150 | logging.error(f"Configuration error for Source {source_id}") 151 | url = hass['url'] 152 | auth_token = hass['auth_token'] 153 | max_message_size = hass.get('max_message_size') or None 154 | hass_controller = HAController(url, auth_token, max_ws_message_size=max_message_size) 155 | 156 | # Generate plug instances 157 | plugs = hass[PLUGS_KEY] 158 | logging.info("Generating HASS instances") 159 | instances = PlugInstance.configure_plugs(plugs, HASource, hass_controller) 160 | self.add_instances(instances) 161 | 162 | # Start controller 163 | hass_task = hass_controller.connect() 164 | self.tasks.add(hass_task) 165 | 166 | # MQTT Plugs 167 | elif source_id.lower() == MQTT_KEY: 168 | # Configure this MQTT Data source 169 | mqtt_conf = source[MQTT_KEY] 170 | if mqtt_conf is None: 171 | logging.error(f"Configuration error for Source {source_id}") 172 | host = mqtt_conf['host'] 173 | port = mqtt_conf.get('port') or 1883 174 | username = mqtt_conf.get('username') or None 175 | password = mqtt_conf.get('password') or None 176 | mqtt_cont = MQTTController(host, port, username, password) 177 | 178 | # Generate plug instances 179 | plugs = mqtt_conf[PLUGS_KEY] 180 | logging.info("Generating MQTT instances") 181 | instances = PlugInstance.configure_plugs(plugs, MQTTSource, mqtt_cont) 182 | self.add_instances(instances) 183 | 184 | # Start controller 185 | mqtt_task = mqtt_cont.connect() 186 | self.tasks.add(mqtt_task) 187 | 188 | # Aggregate-type Plugs 189 | elif source_id.lower() == AGG_KEY: 190 | # Only one aggregate key allowed 191 | if self.has_aggregate: 192 | # Already defined, ignore this one 193 | logging.warning( 194 | f"""Multiple 'aggregate' groups defined - only one group is allowed. Ignoring this""" 195 | """and all subsequent!""") 196 | continue 197 | self.has_aggregate = True 198 | aggregate = source[AGG_KEY] 199 | else: 200 | logging.error(f"Source type '{source_id}' not recognized") 201 | 202 | if aggregate is not None: 203 | # Handle aggregate plugs, now that all instances are defined 204 | # Generate plug instances 205 | plugs = aggregate[PLUGS_KEY] 206 | logging.info("Generating Aggregate instances") 207 | instances = PlugInstance.configure_plugs(plugs, AggregateSource) 208 | for inst in instances.values(): 209 | # Grab data source for this instance 210 | ag_ds = inst.data_source 211 | # So that we can get the element IDs (i.e. plug_id's) 212 | element_ids = ag_ds.element_ids 213 | # Use those element_ids to get actual instances from global instance dict 214 | elements = [] 215 | for plug in self.instances.values(): 216 | if plug.identifier in element_ids: 217 | # Check if this plug is already in another aggregate 218 | if plug.in_aggregate: 219 | logging.warning(f"""Configuration adds plug {plug.identifier} to more than one Aggregate""" 220 | f""" plug. Usage in Aggregate {inst.identifier} will be ignored.""") 221 | continue 222 | # We want this plug 223 | elements.append(plug) 224 | plug.in_aggregate = True 225 | # Pass these elements (top-level plugs) back to Aggregate data source 226 | ag_ds.elements = elements 227 | # Add these aggregate plugs to the instance list 228 | self.add_instances(instances) 229 | 230 | def add_instances(self, instances): 231 | if instances is PlugInstance: 232 | # Single plug 233 | p_i = instances 234 | self.instances.update({p_i.identifier: p_i}) 235 | elif all(isinstance(p, PlugInstance) for p in instances): 236 | # List of plugs, convert to dict and add to storage 237 | new_instances = {p_i.identifier: p_i for p_i in instances} 238 | self.instances = {**self.instances, **new_instances} 239 | else: 240 | # Assume Dict of plugs 241 | # Check for duplicated MAC 242 | union_macs = [val for val in instances.keys() if val in self.instances.keys()] 243 | if any(union_macs): 244 | # Assertion error - can't use the same MAC twice! 245 | raise AssertionError( 246 | f"Configuration Error: Two plugs configured with the same MAC address! ({union_macs})") 247 | 248 | # Add to global instances 249 | self.instances = {**self.instances, **instances} 250 | 251 | def plug_for_mac(self, mac): 252 | return self.instances[mac] 253 | 254 | def print_instance_wattages(self): 255 | for inst in self.instances: 256 | logging.info(f"Plug {inst.identifier} power: {inst.power}") 257 | 258 | async def start(self): 259 | self.tasks.add(self.server_start()) 260 | await asyncio.gather(*self.tasks) 261 | 262 | async def server_start(self): 263 | loop = asyncio.get_running_loop() 264 | finished = loop.create_future() 265 | protocol = SenseLinkProtocol(self.instances, finished) 266 | protocol.should_respond = self.should_respond 267 | protocol.target = self.target 268 | 269 | logging.info("Starting UDP server") 270 | try: 271 | self.transport, self.protocol = await loop.create_datagram_endpoint( 272 | lambda: protocol, 273 | local_addr=('0.0.0.0', self.port)) 274 | except Exception as err: 275 | logging.error(f'Error creating endpoint {err}') 276 | 277 | try: 278 | await finished 279 | except KeyboardInterrupt: 280 | logging.info('Interrupt received, stopping server') 281 | finished.set_result(True) 282 | finally: 283 | self.transport.close() 284 | 285 | 286 | if __name__ == '__main__': 287 | pass 288 | -------------------------------------------------------------------------------- /senselink/tplink_encryption.py: -------------------------------------------------------------------------------- 1 | # Based on: https://github.com/softScheck/tplink-smartplug/blob/dcf978b970356c3edd941583d277612182381f2c/tplink_smartplug.py 2 | # 3 | # TP-Link Wi-Fi Smart Plug Protocol Client 4 | # For use with TP-Link HS-100 or HS-110 5 | # 6 | # by Lubomir Stroetmann 7 | # Copyright 2016 softScheck GmbH 8 | # 9 | # Licensed under the Apache License, Version 2.0 (the "License"); 10 | # you may not use this file except in compliance with the License. 11 | # You may obtain a copy of the License at 12 | # 13 | # http://www.apache.org/licenses/LICENSE-2.0 14 | # 15 | # Unless required by applicable law or agreed to in writing, software 16 | # distributed under the License is distributed on an "AS IS" BASIS, 17 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | # See the License for the specific language governing permissions and 19 | # limitations under the License. 20 | # 21 | # Modifications Copyright (C) 2020, Charles Powell 22 | 23 | from struct import pack 24 | 25 | 26 | def _generate_bytes(unencrypted): 27 | key = 171 28 | for unencryptedbyte in unencrypted: 29 | key = key ^ unencryptedbyte 30 | yield key 31 | 32 | 33 | def encrypt(string): 34 | unencrypted = string.encode() 35 | return pack(">I", len(unencrypted)) + bytes(_generate_bytes(unencrypted)) 36 | 37 | 38 | def decrypt(string): 39 | key = 171 40 | result = "" 41 | for i in string: 42 | a = key ^ i 43 | key = i 44 | result += chr(a) 45 | return result 46 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | with open("README.md", "r", encoding="utf-8") as fh: 4 | long_description = fh.read() 5 | 6 | setup( 7 | name='SenseLink', 8 | version='2.2.1', 9 | description='A tool to create virtual smart plugs and inform a Sense Home Energy Monitor about usage in your home', 10 | long_description=long_description, 11 | long_description_content_type="text/markdown", 12 | url='https://github.com/cbpowell/SenseLink', 13 | author='Charles Powell', 14 | author_email='cbpowell@gmail.com', 15 | license='MIT', 16 | packages=find_packages(), 17 | install_requires=['aiomqtt~=1.2', 18 | 'dpath~=2.1', 19 | 'paho-mqtt>=1.6.1', 20 | 'PyYAML~=6.0', 21 | 'websockets>=10.2' 22 | ], 23 | 24 | classifiers=[ 25 | 'Programming Language :: Python :: 3.7', 26 | 'Programming Language :: Python :: 3.8', 27 | 'Programming Language :: Python :: 3.9' 28 | ], 29 | ) --------------------------------------------------------------------------------