Skip to content

Commit

Permalink
First commit
Browse files Browse the repository at this point in the history
Signed-off-by: Aitor Iturrioz <[email protected]>
  • Loading branch information
bodiroga committed Dec 30, 2019
0 parents commit 19ebc79
Show file tree
Hide file tree
Showing 12 changed files with 537 additions and 0 deletions.
8 changes: 8 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.git
.vscode
__pycache__
*.pyc
*.pyo
*.pyd
.Python
venv
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.vscode
__pycache__
venv
13 changes: 13 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
FROM python:3.7-alpine

COPY src/requirements.txt /

RUN apk update && apk upgrade
RUN apk add --no-cache python3-dev gcc libc-dev linux-headers

RUN pip install -r requirements.txt

COPY src/ /app
WORKDIR /app

CMD ["python", "main.py"]
21 changes: 21 additions & 0 deletions LICENSE.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2019 Aitor Iturrioz

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.
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# vSmart2Homie gateway

This service reads boiler and thermostat values from vSmart platform (Vaillant) and exposes the data to an MQTT broker following the Homie V4 convention.

The data is modeled as:

- vSmart (Device)
- Thermostat (Node)
- Current Temperature
- Setpoint Temperature
- Setpoint Mode
- System Mode
- Battery
- Outdoor (Node)
- Outdoor Temperature
- Boiler (Node)
- eBus Error
- Boiler Error
- Maintenance Status
- Refill Water

To connect to the vSmart platform the app needs the following required environment variables ([instructions](https://github.com/pjmaenh/home-assistant-vaillant#installation-and-configuration)):

- CLIENT_ID
- CLIENT_SECRET
- USERNAME
- PASSWORD

Aditionally, the (optional) MQTT connection parameters can be stablished using:

- MQTT_BROKER (default: localhost)
- MQTT_PORT (default: 1883)
- MQTT_USER (default: None)
- MQTT_PASSWORD (default: None)
- MQTT_CLIENT_ID (default: vsmart2homie)

Finally, the project is based on the following libraries:

- [Homie4](https://github.com/mjcumming/homie4) Thanks @mjcumming!
- [pyvaillant](https://github.com/pjmaenh/pyvaillant) Thanks @pjmaenh!
65 changes: 65 additions & 0 deletions src/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import logging
import os
import time
import sys

from pyvaillant.client_auth import ClientAuth
from pyvaillant.vaillant_data import VaillantData
from vsmart_device import vSmartDevice

logging.basicConfig(format='%(asctime)s %(message)s', level=logging.INFO)

logger = logging.getLogger()

mqtt_settings = {
"MQTT_BROKER" : "localhost",
"MQTT_PORT" : 1883,
"MQTT_USERNAME": None,
"MQTT_PASSWORD": None,
"MQTT_CLIENT_ID": "vsmart2homie"
}

if __name__ == "__main__":
try:
# Get vSmart connection values
client_id = os.environ.get("CLIENT_ID")
client_secret = os.environ.get("CLIENT_SECRET")
username = os.environ.get("USERNAME")
password = os.environ.get("PASSWORD")

if not client_id or not client_secret or not username or not password:
logger.error("Not all vSmart parameters are set")
sys.exit(0)

# Get mqtt connection values
for value in ["MQTT_BROKER", "MQTT_PORT", "MQTT_USERNAME", "MQTT_PASSWORD", "MQTT_CLIENT_ID"]:
mqtt_settings[value] = os.environ.get(value) or mqtt_settings[value]

clientAuth = ClientAuth(client_id, client_secret, username, password)
vaillant_connection = VaillantData(clientAuth)

# Get data interval value
data_interval = int(os.environ.get("DATA_INTERVAL")) or 300

system_name = "vSmart ({})".format(vaillant_connection.station_name)
vsmart = vSmartDevice(device_id="vsmart", name=system_name, mqtt_settings=mqtt_settings, connection=vaillant_connection)

while True:
vaillant_connection.update()
vsmart.get_node("thermostat").set_property_value("current-temp", vaillant_connection.current_temp)
vsmart.get_node("thermostat").set_property_value("setpoint-temp", vaillant_connection.setpoint_temp)
vsmart.get_node("thermostat").set_property_value("setpoint-mode", vaillant_connection.setpoint_mode)
vsmart.get_node("thermostat").set_property_value("system-mode", vaillant_connection.system_mode)
vsmart.get_node("thermostat").set_property_value("battery", vaillant_connection.battery)

vsmart.get_node("outdoor").set_property_value("outdoor-temp", vaillant_connection.outdoor_temperature)

vsmart.get_node("boiler").set_property_value("ebus-error", vaillant_connection.ebus_error)
vsmart.get_node("boiler").set_property_value("boiler-error", vaillant_connection.boiler_error)
vsmart.get_node("boiler").set_property_value("maintenance-status", vaillant_connection.maintenance_status)
vsmart.get_node("boiler").set_property_value("refill-water", vaillant_connection.refill_water)

time.sleep(data_interval)

except (KeyboardInterrupt, SystemExit):
logger.info("Exiting.")
21 changes: 21 additions & 0 deletions src/pyvaillant/LICENSE.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2018 Hugo DUPRAS

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.
49 changes: 49 additions & 0 deletions src/pyvaillant/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import logging
import requests
import time

logger = logging.getLogger(__name__)

# Common definitions
_BASE_URL = "https://api.netatmo.com/"


class NoDevice(Exception):
pass

class NoValidMode(Exception):
pass

class NoValidSystemMode(Exception):
pass

# Utilities routines


def postRequest(url, params=None, timeout=10):
resp = requests.post(url, data=params, timeout=timeout)
if not resp.ok:
logger.error("The Netatmo API returned %s", resp.status_code)
try:
return (
resp.json()
if "application/json" in resp.headers.get("content-type")
else resp.content
)
except TypeError:
logger.debug("Invalid response %s", resp)
return None


def toTimeString(value):
return time.strftime("%Y-%m-%d_%H:%M:%S", time.localtime(int(value)))


def toEpoch(value):
return int(time.mktime(time.strptime(value, "%Y-%m-%d_%H:%M:%S")))


def todayStamps():
today = time.strftime("%Y-%m-%d")
today = int(time.mktime(time.strptime(today, "%Y-%m-%d")))
return today, today + 3600 * 24
89 changes: 89 additions & 0 deletions src/pyvaillant/client_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Python library for managing a Vaillant / Bulex MiGo Thermostat
# Based on the pyatmo library by Philippe Larduinat
# Revised July 2019
# Public domain source code
"""
This API provides access to the Vaillant / Bulex smart thermostat
This package can be used with Python3 applications
PythonAPI Vaillant/Bulex REST data access
"""
import logging
import time

from . import _BASE_URL, NoDevice, postRequest

LOG = logging.getLogger(__name__)

# Common definitions
_AUTH_REQ = _BASE_URL + "oauth2/token"
_WEBHOOK_URL_ADD = _BASE_URL + "api/addwebhook"
_WEBHOOK_URL_DROP = _BASE_URL + "api/dropwebhook"
_DEFAULT_SCOPE = "read_station read_camera access_camera read_thermostat write_thermostat read_presence access_presence"
_DEFAULT_APP_VERSION = "1.0.4.0"
_DEFAULT_USER_PREFIX = "vaillant"

class ClientAuth(object):
"""
Request authentication and keep access token available through token method. Renew it automatically if necessary
Args:
clientId (str): Application clientId delivered by Netatmo on dev.netatmo.com
clientSecret (str): Application Secret key delivered by Netatmo on dev.netatmo.com
username (str)
password (str)
scope (Optional[str]): Default value is 'read_station'
read_station: to retrieve weather station data (Getstationsdata, Getmeasure)
read_camera: to retrieve Welcome data (Gethomedata, Getcamerapicture)
access_camera: to access the camera, the videos and the live stream.
read_thermostat: to retrieve thermostat data (Getmeasure, Getthermostatsdata)
write_thermostat: to set up the thermostat (Syncschedule, Setthermpoint)
read_presence: to retrieve Presence data (Gethomedata, Getcamerapicture)
read_homecoach: to retrieve Home Coache data (Gethomecoachsdata)
access_presence: to access the live stream, any video stored on the SD card and to retrieve Presence's lightflood status
Several value can be used at the same time, ie: 'read_station read_camera'
"""

def __init__(
self, clientId, clientSecret, username, password, scope=_DEFAULT_SCOPE, app_version=_DEFAULT_APP_VERSION, user_prefix=_DEFAULT_USER_PREFIX
):
postParams = {
"grant_type": "password",
"client_id": clientId,
"client_secret": clientSecret,
"username": username,
"password": password,
"scope": scope,
}

if user_prefix:
postParams.update({"user_prefix": user_prefix})
if app_version:
postParams.update({"app_version": app_version})

resp = postRequest(_AUTH_REQ, postParams)
self._clientId = clientId
self._clientSecret = clientSecret
try:
self._accessToken = resp["access_token"]
except (KeyError):
LOG.error("Netatmo API returned %s", resp["error"])
raise NoDevice("Authentication against Netatmo API failed")
self.refreshToken = resp["refresh_token"]
self._scope = resp["scope"]
self.expiration = int(resp["expire_in"] + time.time() - 1800)


@property
def accessToken(self):

if self.expiration < time.time(): # Token should be renewed
postParams = {
"grant_type": "refresh_token",
"refresh_token": self.refreshToken,
"client_id": self._clientId,
"client_secret": self._clientSecret,
}
resp = postRequest(_AUTH_REQ, postParams)
self._accessToken = resp["access_token"]
self.refreshToken = resp["refresh_token"]
self.expiration = int(resp["expire_in"] + time.time() - 1800)
return self._accessToken
Loading

0 comments on commit 19ebc79

Please sign in to comment.