diff --git a/.gitignore b/.gitignore index 03a11c3..852d205 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ env/ config/ config/config.yaml config/smart-home-key.json +config/token/* diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index d2d31a8..0000000 --- a/.travis.yml +++ /dev/null @@ -1,16 +0,0 @@ -language: python -branches: - only: - - master - - beta -python: - - "3.5" - - "3.6" # current default Python on Travis CI - - "3.8" -# command to install dependencies -install: - - pip3 install -r requirements/pip-requirements.txt -# command to run tests -script: - - cd .. - - python3 Domoticz-Google-Assistant diff --git a/README.md b/README.md index 5fe1c7e..42a33e5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![GitHub release (latest by date)](https://img.shields.io/github/v/release/dewgew/Domoticz-Google-Assistant?logo=github) [![Discord](https://img.shields.io/discord/664815298284748830?logo=discord)](https://discordapp.com/invite/AmJV6AC) +![GitHub release (latest by date)](https://img.shields.io/github/v/release/dewgew/Domoticz-Google-Assistant?logo=github) [![Discord](https://img.shields.io/discord/664815298284748830?logo=discord)](https://discordapp.com/invite/AmJV6AC) [![Python Package](https://github.com/DewGew/Domoticz-Google-Assistant/actions/workflows/python-app.yml/badge.svg?branch=master)](https://github.com/DewGew/Domoticz-Google-Assistant/actions/workflows/python-app.yml) [![Docker Image CI](https://github.com/DewGew/Domoticz-Google-Assistant/actions/workflows/docker-image.yml/badge.svg?branch=master)](https://github.com/DewGew/Domoticz-Google-Assistant/actions/workflows/docker-image.yml) # Domoticz-Google-Assistant drawing diff --git a/__init__.py b/__init__.py index 58bece4..d3f5a12 100644 --- a/__init__.py +++ b/__init__.py @@ -1,3 +1 @@ - - diff --git a/__main__.py b/__main__.py index 4d7d65b..7c06b00 100644 --- a/__main__.py +++ b/__main__.py @@ -4,18 +4,18 @@ from server import * from smarthome import * -use_ssl = ('use_ssl' in configuration and configuration['use_ssl'] == True) +use_ssl = ('use_ssl' in configuration and configuration['use_ssl'] is True) if use_ssl: import ssl -if 'ngrok_tunnel' in configuration and configuration['ngrok_tunnel'] == True: +if 'ngrok_tunnel' in configuration and configuration['ngrok_tunnel'] is True: from pyngrok import ngrok tunnel = PUBLIC_URL def secure(server): - key = configuration['ssl_key'] if 'ssl_key' in configuration else None + key = configuration['ssl_key'] if 'ssl_key' in configuration else None cert = configuration['ssl_cert'] if 'ssl_cert' in configuration else None if key is None or cert is None: @@ -23,7 +23,7 @@ def secure(server): return logger.info('Using SSL connection') - server.socket = ssl.wrap_socket (server.socket, keyfile=key, certfile=cert, server_side=True, ssl_version=ssl.PROTOCOL_TLSv1_2) + server.socket = ssl.wrap_socket(server.socket, keyfile=key, certfile=cert, server_side=True, ssl_version=ssl.PROTOCOL_TLSv1_2) class ThreadingSimpleServer(socketserver.ThreadingMixIn, http.server.HTTPServer): pass @@ -48,7 +48,7 @@ def startServer(): # Create a web server and define the handler to manage the # incoming request server = ThreadingSimpleServer(('', configuration['port_number']), AogServer) - if(use_ssl): + if (use_ssl): secure(server) logger.info('========') diff --git a/auth.py b/auth.py index 50af2e4..59890ed 100644 --- a/auth.py +++ b/auth.py @@ -54,7 +54,7 @@ def login_post(self, s): user = self.getUser(s.form.get("username", None), s.form.get("password", None)) if user is None: - if s.headers['X-Forwarded-For'] == None: + if s.headers['X-Forwarded-For'] is None: logger.error("Failed login from %s", s.address_string()) else: logger.error("Failed login from %s", s.headers['X-Forwarded-For']) @@ -89,7 +89,7 @@ def login_post(self, s): # * &response_type=token # * &grant_type=refresh_token # * &refresh_token=REFRESH_TOKEN - # */ + # */ def token_post(self, s): client_id = s.query_components.get("client_id", s.form.get("client_id", None)) client_secret = s.query_components.get("client_secret", s.form.get("client_secret", None)) @@ -155,7 +155,7 @@ def handleAuthCode(self, s): # * token_type: "bearer", # * access_token: "ACCESS_TOKEN", # * } - # */ + # */ def handleRefreshToken(self, s): client_id = s.query_components.get("client_id", s.form.get("client_id", None)) client_secret = s.query_components.get("client_secret", s.form.get("client_secret", None)) diff --git a/config/default_config b/config/default_config index f38c722..793b779 100644 --- a/config/default_config +++ b/config/default_config @@ -29,8 +29,6 @@ ssl_cert: # /path/to/fullchain.pem # Login on Google Home app and configuration interface auth_user: 'admin' auth_pass: 'admin' -# If you change authToken you need to disconnect and reconnect to Google Assistant -authToken: 'ZsokmCwKjdhk7qHLeYd2' # Google Assistant Settings: ClientID: 'clientid_from aog' diff --git a/const.py b/const.py index 8c1d8ed..fafd544 100644 --- a/const.py +++ b/const.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """Constants for Google Assistant.""" -VERSION = '1.22.32' +VERSION = '1.23.2' PUBLIC_URL = 'https://[your public url]' CONFIGFILE = 'config/config.yaml' LOGFILE = 'dzga.log' diff --git a/helpers.py b/helpers.py index f4aa571..ae03e4f 100644 --- a/helpers.py +++ b/helpers.py @@ -8,6 +8,8 @@ import time import subprocess import sys +import string +import random import requests import yaml @@ -50,15 +52,19 @@ def saveFile(filename, text): file.write(text) file.close() return code - - + +# Random string generator +def random_string(stringLength=8): + chars = string.ascii_letters + string.digits + return ''.join(random.choice(chars) for i in range(stringLength)) + try: print('Loading configuration...') with open(os.path.join(FILE_DIR, CONFIGFILE), 'r') as conf: configuration = yaml.safe_load(conf) -except yaml.YAMLError as exc: +except yaml.YAMLError: print('ERROR: Please check config.yaml') -except FileNotFoundError as err: +except FileNotFoundError: print('No config.yaml found...') print('Loading default configuration...') content = readFile(os.path.join(FILE_DIR, 'config/default_config')) @@ -84,6 +90,22 @@ def saveFile(filename, text): ch = logging.StreamHandler() ch.setLevel(loglevel) logger.addHandler(ch) + +# Generate and save random token with username +if 'authToken' not in configuration: + try: + with open(os.path.join(FILE_DIR, 'config/.token/.'+ configuration['auth_user']), 'r') as t: + configuration['authToken'] = t.read() + t.close() + except FileNotFoundError: + logger.info('Generating token...') + access_token = random_string(20) + os.makedirs(os.path.join(FILE_DIR, 'config/.token'), exist_ok=True) + with open(os.path.join(FILE_DIR, 'config/.token/.'+ configuration['auth_user']), 'w+') as f: + f.write(access_token) + configuration['authToken'] = f + f.close() + # Log to file if 'pathToLogFile' not in configuration or configuration['pathToLogFile'] == '': logfilepath = FILE_DIR @@ -123,8 +145,6 @@ def saveFile(filename, text): configuration['ClientID'] = 'sampleClientId' if 'ClientSecret' not in configuration: configuration['ClientSecret'] = 'sampleClientSecret' -if 'authToken' not in configuration: - configuration['authToken'] = 'ZsokmCwKjdhk7qHLeYd2' Auth = { 'clients': { @@ -140,12 +160,6 @@ def saveFile(filename, text): 'refreshToken': configuration['authToken'], 'userAgentId': '1234', }, - 'bfrrLnxxWdULSh3Y9IU2cA5pw8s4ub': { - 'uid': '2345', - 'accessToken': 'bfrrLnxxWdULSh3Y9IU2cA5pw8s4ub', - 'refreshToken': 'bfrrLnxxWdULSh3Y9IU2cA5pw8s4ub', - 'userAgentId': '2345' - }, }, 'users': { '1234': { diff --git a/smarthome.py b/smarthome.py index 3b75e1e..584d591 100644 --- a/smarthome.py +++ b/smarthome.py @@ -6,8 +6,6 @@ import subprocess import sys import yaml -import random -import string from collections.abc import Mapping from itertools import product from pid import PidFile @@ -56,7 +54,8 @@ logger, ReportState, Auth, - logfilepath + logfilepath, + random_string ) from jinja2 import Environment, FileSystemLoader @@ -98,7 +97,8 @@ r = requests.get( DOMOTICZ_URL + '/json.htm?type=command¶m=addlogmessage&message=Connected to Google Assistant with DZGA v' + VERSION, auth=CREDITS, timeout=(2, 5)) -except Exception as e: + r.raise_for_status() +except requests.exceptions.HTTPError as e: logger.error('Connection to Domoticz refused with error: %s' % e) try: @@ -749,12 +749,11 @@ def notification_post(self, s): if token is None: raise SmartHomeError(ERR_PROTOCOL_ERROR, 'not authorized access!!') - event_id = ''.join(random.choices(string.ascii_uppercase + string.ascii_lowercase + - string.digits, k=10)) - - request_id = ''.join(random.choices(string.digits, k=20)) + event_id = random_string(10) + request_id = random_string(20) message = s.body + if '|' in message: message = message.replace('|', ' ').split() if '>>' in message: message.remove('>>') devid = message[0] @@ -788,7 +787,7 @@ def notification_post(self, s): } } } - ReportState.call_homegraph_api(REPORT_STATE_BASE_URL, data) + ReportState.call_homegraph_api(REPORT_STATE_BASE_URL, data) elif aog.domain in DOMAINS['smokedetector']: data = { 'requestId': str(request_id), @@ -798,7 +797,7 @@ def notification_post(self, s): 'devices': { 'states': { devid: { - 'on': (True if state.lower() in ['on'] else False) + 'on': (True if state.lower() in ['on', 'alarm/fire'] else False) }, }, 'notifications': { @@ -1033,8 +1032,7 @@ def smarthome_query(self, payload, token): """ response = {} devices = {} - #getDevices() - + for device in payload.get('devices', []): devid = device['id'] _GoogleEntity(aogDevs.get(devid, None)).async_update() @@ -1069,6 +1067,7 @@ def smarthome_exec(self, payload, token): for device, execution in product(command['devices'], command['execution']): entity_id = device['id'] + _GoogleEntity(aogDevs.get(entity_id, None)).async_update() # Get states before execution # Happens if error occurred. Skip entity for further processing if entity_id in results: diff --git a/templates/devices.html b/templates/devices.html index f701d8b..ef1d236 100644 --- a/templates/devices.html +++ b/templates/devices.html @@ -1,3 +1,9 @@ + -
-
-
- -
-
-
+
+
- - - Create account at ngrok.com and paste the token here. + + + This is your authToken to use with notifications.
@@ -128,10 +116,10 @@

Domoticz Settings

-
+
- +
@@ -151,7 +139,7 @@

Log Settings

-
+
-
+
-
+
-
- -
-
+
-
-
-
+
+
@@ -294,6 +278,7 @@

Advanced Settings

+
@@ -303,12 +288,25 @@

Advanced Settings

+
-
+
+
+ +
+
+
- - - This is your authToken to use with notifications. + + + Create account at ngrok.com and paste the token here.
diff --git a/trait.py b/trait.py index a207225..d87307f 100644 --- a/trait.py +++ b/trait.py @@ -91,8 +91,8 @@ def register_trait(trait): def _google_temp_unit(units): """Return Google temperature unit.""" if units: - return "F" - return "C" + return 'F' + return 'C' class _Trait: @@ -143,7 +143,6 @@ def supported(domain, features): DOMAINS['color'], DOMAINS['cooktop'], DOMAINS['dishwasher'], - DOMAINS['doorbell'], DOMAINS['dryer'], DOMAINS['fan'], DOMAINS['group'], @@ -168,7 +167,7 @@ def sync_attributes(self): """Return OnOff attributes for a sync request.""" domain = self.state.domain response = {} - if domain in [DOMAINS['sensor'], DOMAINS['doorbell']]: + if domain in [DOMAINS['sensor']]: response['queryOnlyOnOff'] = True return response @@ -190,15 +189,32 @@ def query_attributes(self): def execute(self, command, params): """Execute an OnOff command.""" domain = self.state.domain + state = self.state.state protected = self.state.protected if domain not in [DOMAINS['sensor']]: if domain == DOMAINS['group']: url = DOMOTICZ_URL + '/json.htm?type=command¶m=switchscene&idx=' + self.state.id + '&switchcmd=' + ( 'On' if params['on'] else 'Off') + url = DOMOTICZ_URL + '/json.htm?type=command¶m=switchscene&idx=' + self.state.id + '&switchcmd=' + if params['on'] is True and state == 'Off': + url += 'On' + elif params['on'] is False and state != 'Off': + url += 'Off' + else: + raise SmartHomeError(ERR_ALREADY_IN_STATE, + 'Unable to execute {} for {}. Already in state '.format(command, self.state.entity_id)) else: url = DOMOTICZ_URL + '/json.htm?type=command¶m=switchlight&idx=' + self.state.id + '&switchcmd=' + ( 'On' if params['on'] else 'Off') + url = DOMOTICZ_URL + '/json.htm?type=command¶m=switchlight&idx=' + self.state.id + '&switchcmd=' + if params['on'] is True and state == 'Off': + url += 'On' + elif params['on'] is False and state != 'Off': + url += 'Off' + else: + raise SmartHomeError(ERR_ALREADY_IN_STATE, + 'Unable to execute {} for {}. Already in state '.format(command, self.state.entity_id)) if protected: url = url + '&passcode=' + configuration['Domoticz']['switchProtectionPass'] @@ -210,8 +226,7 @@ def execute(self, command, params): if err == 'ERROR': raise SmartHomeError(ERR_WRONG_PIN, 'Unable to execute {} for {} check your settings'.format(command, - self.state.entity_id)) - + self.state.entity_id)) @register_trait class SceneTrait(_Trait): @@ -241,8 +256,9 @@ def query_attributes(self): def execute(self, command, params): """Execute a scene command.""" protected = self.state.protected - - url = DOMOTICZ_URL + '/json.htm?type=command¶m=switchscene&idx=' + self.state.id + '&switchcmd=On' + + if params['deactivate'] is False: + url = DOMOTICZ_URL + '/json.htm?type=command¶m=switchscene&idx=' + self.state.id + '&switchcmd=On' if protected: url = url + '&passcode=' + configuration['Domoticz']['switchProtectionPass'] @@ -341,8 +357,7 @@ def supported(domain, features): DOMAINS['door'], DOMAINS['window'], DOMAINS['gate'], - DOMAINS['garage'], - DOMAINS['valve'] + DOMAINS['garage'] ) def sync_attributes(self): @@ -351,8 +366,6 @@ def sync_attributes(self): domain = self.state.domain response = {} - if domain != DOMAINS['blinds']: - response['queryOnlyOpenClose'] = True if features & ATTRS_PERCENTAGE != True: response['discreteOnlyOpenClose'] = True @@ -393,12 +406,14 @@ def execute(self, command, params): url = DOMOTICZ_URL + '/json.htm?type=command¶m=switchlight&idx=' + self.state.id + '&switchcmd=' - if p == 100 and state in ['Closed', 'Stopped', 'On']: - # open - url += 'Open' - elif p == 0 and state in ['Open', 'Stopped', 'Off']: - # close - url += 'Close' + if p == 100 and state in ['Closed', 'Stopped']: + url += 'Open' + elif p == 100 and state == 'On': + url += 'Off' + elif p == 0 and state in ['Open', 'Stopped']: + url += 'Close' + elif p == 0 and state == 'Off': + url += 'On' else: raise SmartHomeError(ERR_ALREADY_IN_STATE, 'Unable to execute {} for {}. Already in state '.format(command, @@ -632,14 +647,17 @@ def sync_attributes(self): minThree = -100 maxThree = 100 response = {} - response = {"temperatureUnitForUX": _google_temp_unit(units)} - response["temperatureRange"] = { + response = {'temperatureUnitForUX': _google_temp_unit(units)} + response['temperatureRange'] = { 'minThresholdCelsius': minThree, 'maxThresholdCelsius': maxThree} if self.state.merge_thermo_idx is not None: response = {"temperatureStepCelsius": 1} + # if domain in [DOMAINS['temperature']]: + # response = {'queryOnlyTemperatureControl': True} + return response def query_attributes(self): @@ -647,18 +665,24 @@ def query_attributes(self): domain = self.state.domain units = self.state.tempunit response = {} + + if self.state.battery <= configuration['Low_battery_limit']: + response['exceptionCode'] = 'lowBattery' if self.state.merge_thermo_idx is not None: - if self.state.battery <= configuration['Low_battery_limit']: - response['exceptionCode'] = 'lowBattery' - current_temp = float(self.state.temp) if current_temp is not None: response['temperatureAmbientCelsius'] = current_temp setpoint = float(self.state.setpoint) if setpoint is not None: response['temperatureSetpointCelsius'] = setpoint - + + # elif domain in [DOMAINS['temperature']]: + # current_temp = float(self.state.temp) + # if current_temp is not None: + # response['temperatureAmbientCelsius'] = current_temp + # response['temperatureSetpointCelsius'] = current_temp + return response def execute(self, command, params): @@ -1305,12 +1329,13 @@ def query_attributes(self): """Return the attributes of this trait for this entity.""" domain = self.state.domain state = self.state.state + if state is not None: return { 'currentSensorStateData': [ { 'name': 'SmokeLevel', - 'currentSensorState': ('smoke detekted' if state == 'on' else 'no smoke detected'), + 'currentSensorState': ('smoke detekted' if state == 'On' else 'no smoke detected'), } ] }