From d01222f973d35778253a196d56bd27d33a874687 Mon Sep 17 00:00:00 2001 From: Mips2648 Date: Wed, 26 Feb 2025 16:47:23 +0100 Subject: [PATCH] migrate config & refactoring --- core/php/jeekroomba.php | 2 +- resources/kroomba/config.py | 38 --- resources/kroomba/irobot/configs.py | 340 +++++++++++++++++++ resources/kroomba/irobot/const.py | 3 + resources/kroomba/irobot/getcloudpassword.py | 199 ----------- resources/kroomba/irobot/irobot.py | 237 ++++++------- resources/kroomba/irobot/password.py | 285 ---------------- resources/kroomba/kroombad.py | 112 ++++-- 8 files changed, 519 insertions(+), 697 deletions(-) delete mode 100644 resources/kroomba/config.py create mode 100644 resources/kroomba/irobot/configs.py create mode 100644 resources/kroomba/irobot/const.py delete mode 100644 resources/kroomba/irobot/getcloudpassword.py delete mode 100644 resources/kroomba/irobot/password.py diff --git a/core/php/jeekroomba.php b/core/php/jeekroomba.php index 6df7c1d..ff1f865 100644 --- a/core/php/jeekroomba.php +++ b/core/php/jeekroomba.php @@ -50,7 +50,7 @@ )); } } elseif (isset($result['msg'])) { - if ($result['msg'] == 'NO_ROOMBA') { + if ($result['msg'] == 'NO_ROBOT') { message::add('kroomba', __('Aucun robot configuré, veuillez lancer une découverte depuis la page de gestion des équipements du plugin', __FILE__), '', 'kroomba_no_robot'); } } diff --git a/resources/kroomba/config.py b/resources/kroomba/config.py deleted file mode 100644 index 6a33093..0000000 --- a/resources/kroomba/config.py +++ /dev/null @@ -1,38 +0,0 @@ -from jeedomdaemon.base_config import BaseConfig - - -class iRobotConfig(BaseConfig): - def __init__(self): - super().__init__() - - self.add_argument("--host", help="mqtt host ip", type=str, default='127.0.0.1') - self.add_argument("--port", help="mqtt host port", type=int, default=1883) - self.add_argument("--user", help="mqtt username", type=str) - self.add_argument("--password", help="mqtt password", type=str) - self.add_argument("--topic_prefix", help="topic_prefix", type=str, default='kroomba') - self.add_argument("--excluded_blid", type=str) - - @property - def mqtt_host(self): - return str(self._args.host) - - @property - def mqtt_port(self): - return int(self._args.port) - - @property - def mqtt_user(self): - return str(self._args.user) - - @property - def mqtt_password(self): - return str(self._args.password) - - @property - def topic_prefix(self): - return str(self._args.topic_prefix) - - @property - def excluded_blid(self): - blids = str(self._args.excluded_blid) - return [str(x) for x in blids.split(',') if x != ''] diff --git a/resources/kroomba/irobot/configs.py b/resources/kroomba/irobot/configs.py new file mode 100644 index 0000000..4c4788d --- /dev/null +++ b/resources/kroomba/irobot/configs.py @@ -0,0 +1,340 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from __future__ import annotations + +import asyncio +from pathlib import Path +from pprint import pformat +import json +import logging +import socket +import ssl +from ast import literal_eval +import configparser +import requests + +from .const import BROADCAST_IP, DEFAULT_TIMEOUT + + +class iRobotConfig(object): + def __init__(self, blid: str, data: dict): + self.__blid: str = blid + self.__data: dict = data + + self.__password: str | None = self.__data.get('password', None) + self.__ip: str = str(self.__data.get('ip', '')) + self.__name: str = str(self.__data.get('robotname', 'unknown')) + + @property + def blid(self): + return self.__blid + + @property + def password(self): + return self.__password + + @password.setter + def password(self, value): + self.__password = value + + @property + def ip(self): + return self.__ip + + @ip.setter + def ip(self, value): + self.__ip = value + + @property + def name(self): + return self.__name + + @name.setter + def name(self, value: str): + self.__name = value + + @property + def version(self): + return int(self.__data.get('ver', 3)) + + def toJSON(self): + return self.__data + + +class iRobotConfigs: + ''' + Manage the configuration of the iRobot devices + ''' + + config_dicts = ['data', 'mapsize', 'pmaps', 'regions'] + + def __init__(self, path: Path): + self.__path = path + self._logger = logging.getLogger() + + ini_file = self.__path/'config.ini' + self.__json_file = self.__path/'config.json' + + self.__robots: dict[str, iRobotConfig] = {} + + if ini_file.exists(): + self.__convert_config(ini_file) + ini_file.unlink() + + self.__load_config() + + def __convert_config(self, file: Path): + Config = configparser.ConfigParser() + old_configs = {} + try: + Config.read(file) + self._logger.info("convert config file %s", file) + old_configs = {s: {k: literal_eval(v) if k in self.config_dicts else v for k, v in Config.items(s)} for s in Config.sections()} + except Exception as e: + self._logger.exception(e) + + new_configs = {} + for ip, value in old_configs.items(): + if value['blid'] in new_configs.keys(): + continue + new_configs[value['blid']] = iRobotConfig(value['blid'], value['data']).toJSON() + + self.__json_file.write_text(json.dumps(new_configs, indent=2), encoding='utf-8') + + def __load_config(self): + if self.__json_file.exists(): + self._logger.info("Load config file %s", self.__json_file) + configs = json.loads(self.__json_file.read_text(encoding='utf-8')) + for blid, data in configs.items(): + self.__robots[blid] = iRobotConfig(blid, data) + + def __save_config_file(self): + data = {} + for robot in self.__robots.values(): + data[robot.blid] = robot.toJSON() + self.__json_file.write_text(json.dumps(data, indent=2), encoding='utf-8') + return True + + @property + def robots(self): + return self.__robots + + async def __receive_udp(self, timeout: int = DEFAULT_TIMEOUT, address: str = BROADCAST_IP): + # set up UDP socket to receive data from robot + port = 5678 + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(timeout) + if address == BROADCAST_IP: + s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + s.bind(("", port)) # bind all interfaces to port + self._logger.debug("waiting on port: %s for data", port) + message = 'irobotmcs' + s.sendto(message.encode(), (address, port)) + configs: dict[str, iRobotConfig] = {} + while True: + try: + udp_data, addr = s.recvfrom(1024) # wait for udp data + if udp_data and udp_data.decode() != message: + try: + parsedMsg = json.loads(udp_data.decode()) + blid = self.__parse_blid(parsedMsg) + if blid not in configs.keys(): + s.sendto(message.encode(), (address, port)) + self._logger.debug('Robot at IP: %s Data: %s', addr[0], json.dumps(parsedMsg)) + new_robot = iRobotConfig(blid, parsedMsg) + if new_robot.version < 2: + self._logger.warning("%s robot at address %s does not have the correct firmware version. Your version info is: %s", new_robot.name, new_robot.ip, new_robot.version) + continue + self._logger.info("Found robot %s at IP %s", new_robot.name, new_robot.ip) + configs[blid] = new_robot + except Exception as e: + self._logger.info("json decode error: %s", e) + self._logger.info('RECEIVED: %s', pformat(udp_data)) + + except socket.timeout: + break + s.close() + return configs + + def __parse_blid(self, payload: dict): + return payload.get('robotid', payload.get("hostname", "").split('-')[1]) + + async def discover(self, address: str = BROADCAST_IP, cloud_login: str = None, cloud_password: str = None): + ''' + Discover robots on the network, retrieve their password from the cloud if not already known and save the configuration + ''' + self._logger.info("Discovering robots on network...") + + discovered_robots = await self.__receive_udp(timeout=15, address=address) + if len(discovered_robots) == 0: + if address == BROADCAST_IP: + self._logger.warning("No robots found on network, make sure your robots are powered on (green lights on) and connected on the same network then try again...") + return False + self._logger.info("Found %i robots on network", len(discovered_robots)) + + robots_with_missing_pswd: dict[str, iRobotConfig] = {} + + for robot in discovered_robots.values(): + + if robot.blid in self.__robots.keys(): + self._logger.info("Robot %s already configured, updating ip & name", robot.name) + self.__robots[robot.blid].ip = robot.ip + self.__robots[robot.blid].name = robot.name + else: + if robot.password is None: + robots_with_missing_pswd[robot.blid] = robot + else: + self._logger.info("Robot %s added to configuration with password received during discovery: %s", robot.name, robot.password) + self.__robots[robot.blid] = robot + + if len(robots_with_missing_pswd) > 0: + if cloud_login and cloud_password: + try: + self._logger.info("Try to get missing robots password from cloud...") + cloud_data = await self.__get_passwords_from_cloud(cloud_login, cloud_password) + except requests.HTTPError as e: + self._logger.error("Error getting cloud data: %s", e) + else: + self._logger.debug("Got cloud data: %s", json.dumps(cloud_data)) + self._logger.info("Found %i robots defined in the cloud", len(cloud_data)) + for id, data in cloud_data.items(): + if id in robots_with_missing_pswd.keys(): + robots_with_missing_pswd[id].password = data.get('password') + self._logger.info("Robot %s added to configuration with password from cloud", robot.name) + self.__robots[id] = robots_with_missing_pswd[id] + else: + for robot in robots_with_missing_pswd.values(): + self._logger.info("To add/update your robot details," + "make sure your robot (%s) at IP %s is on the Home Base and " + "powered on (green lights on). Then press and hold the HOME " + "button on your robot until it plays a series of tones " + "(about 2 seconds). Release the button and your robot will " + "flash WIFI light.", robot.name, robot.ip) + await asyncio.sleep(10) + data = await self.__get_password_from_robot(robot.ip) + if len(data) <= 7: + self._logger.warning('Cannot get password for robot %s at ip %s, received %i bytes. Follow the instructions and try again.', robot.name, robot.ip, len(data)) + continue + # Convert password to str + robot.password = str(data[7:].decode().rstrip('\x00')) + self._logger.info("Robot %s added to configuration with password from robot", robot.name) + self.__robots[robot.blid] = robot + + return self.__save_config_file() + + async def __get_password_from_robot(self, ip): + ''' + Send MQTT magic packet to addr + this is 0xf0 (mqtt reserved) 0x05(data length) 0xefcc3b2900 (data) + Should receive 37 bytes containing the password for robot at addr + This is is 0xf0 (mqtt RESERVED) length (0x23 = 35) 0xefcc3b2900 (magic packet), + followed by 0xXXXX... (30 bytes of password). so 7 bytes, followed by 30 bytes of password + total of 37 bytes + Uses 10 second timeout for socket connection + ''' + data = b'' + packet = bytes.fromhex('f005efcc3b2900') + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(10) + + # context = ssl.SSLContext(ssl.PROTOCOL_TLS) + context = ssl.SSLContext() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + # context.set_ciphers('DEFAULT@SECLEVEL=1:HIGH:!DH:!aNULL') + wrappedSocket = context.wrap_socket(sock) + + try: + wrappedSocket.connect((ip, 8883)) + self._logger.debug('Connection Successful') + wrappedSocket.send(packet) + self._logger.debug('Waiting for data') + + while len(data) < 37: + data_received = wrappedSocket.recv(1024) + data += data_received + if len(data_received) == 0: + self._logger.info("socket closed") + break + + wrappedSocket.close() + return data + + except socket.timeout as e: + self._logger.error('Connection Timeout Error (for %s): %s', ip, e) + except (ConnectionRefusedError, OSError) as e: + if e.errno == 111: # errno.ECONNREFUSED + self._logger.error('Robot %s found but connection is refused, make sure nothing else is connected (app?), as only one connection at a time is allowed', ip) + elif e.errno == 113: # errno.No Route to Host + self._logger.error('Unable to contact robot on ip %s; Is the ip correct?', ip) + else: + self._logger.error("Connection Error (for %s): %s", ip, e) + except Exception as e: + self._logger.exception(e) + + self._logger.error('Unable to get password from robot') + return data + + async def __get_passwords_from_cloud(self, login: str, password: str) -> dict: + r = requests.get("https://disc-prod.iot.irobotapi.com/v1/discover/endpoints?country_code=US") + r.raise_for_status() + response = r.json() + deployment = response['deployments'][next(iter(response['deployments']))] + self.httpBase = deployment['httpBase'] + # iotBase = deployment['httpBaseAuth'] + # iotUrl = urllib.parse.urlparse(iotBase) + # self.iotHost = iotUrl.netloc + # region = deployment['awsRegion'] + + self.apikey = response['gigya']['api_key'] + self.gigyaBase = response['gigya']['datacenter_domain'] + + data = {"apiKey": self.apikey, + "targetenv": "mobile", + "loginID": login, + "password": password, + "format": "json", + "targetEnv": "mobile", + } + + self._logger.debug("Post accounts.login request") + r = requests.post("https://accounts.%s/accounts.login" % self.gigyaBase, data=data) + r.raise_for_status() + response = r.json() + self._logger.debug("response: %s", response) + ''' + data = {"timestamp": int(time.time()), + "nonce": "%d_%d" % (int(time.time()), random.randint(0, 2147483647)), + "oauth_token": response.get('sessionInfo', {}).get('sessionToken', ''), + "targetEnv": "mobile"} + ''' + uid = response['UID'] + uidSig = response['UIDSignature'] + sigTime = response['signatureTimestamp'] + + data = { + "app_id": "ANDROID-C7FB240E-DF34-42D7-AE4E-A8C17079A294", + "assume_robot_ownership": "0", + "gigya": { + "signature": uidSig, + "timestamp": sigTime, + "uid": uid, + } + } + + header = { + "Content-Type": "application/json", + "host": "unauth1.prod.iot.irobotapi.com" + } + + self._logger.debug("Post login request to %s with data %s", self.httpBase, data) + r = requests.post("%s/v2/login" % self.httpBase, json=data, headers=header) + r.raise_for_status() + response = r.json() + self._logger.debug("response: %s", response) + # access_key = response['credentials']['AccessKeyId'] + # secret_key = response['credentials']['SecretKey'] + # session_token = response['credentials']['SessionToken'] + + return response['robots'] diff --git a/resources/kroomba/irobot/const.py b/resources/kroomba/irobot/const.py new file mode 100644 index 0000000..bf451dd --- /dev/null +++ b/resources/kroomba/irobot/const.py @@ -0,0 +1,3 @@ + +BROADCAST_IP = "255.255.255.255" +DEFAULT_TIMEOUT = 15 diff --git a/resources/kroomba/irobot/getcloudpassword.py b/resources/kroomba/irobot/getcloudpassword.py deleted file mode 100644 index 5038920..0000000 --- a/resources/kroomba/irobot/getcloudpassword.py +++ /dev/null @@ -1,199 +0,0 @@ -#!/usr/bin/env python3 - -# Copyright 2021 Matthew Garrett -# -# Portions Copyright 2010-2019 Amazon.com, Inc. or its affiliates. All -# Rights Reserved. -# -# This file is licensed under the Apache License, Version 2.0 (the -# "License"). You may not use this file except in compliance with the -# License. A copy of the License is located at -# -# http://aws.amazon.com/apache2.0/ -# -# This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR -# CONDITIONS OF ANY KIND, either express or implied. See the License for the -# specific language governing permissions and limitations under the License. - -import datetime -import hashlib -import hmac -import requests -import urllib.parse -import logging - - -class awsRequest: - def __init__(self, region, access_key, secret_key, session_token, service): - self.region = region - self.access_key = access_key - self.secret_key = secret_key - self.session_token = session_token - self.service = service - - def sign(self, key, msg): - return hmac.new(key, msg.encode('utf-8'), hashlib.sha256).digest() - - def getSignatureKey(self, key, dateStamp, regionName, serviceName): - kDate = self.sign(('AWS4' + key).encode('utf-8'), dateStamp) - kRegion = self.sign(kDate, regionName) - kService = self.sign(kRegion, serviceName) - kSigning = self.sign(kService, 'aws4_request') - return kSigning - - def get(self, host, uri, query=""): - method = "GET" - - # Create a date for headers and the credential string - t = datetime.datetime.utcnow() - amzdate = t.strftime('%Y%m%dT%H%M%SZ') - datestamp = t.strftime('%Y%m%d') # Date w/o time, used in credential scope - - canonical_uri = uri - canonical_querystring = query - canonical_headers = 'host:' + host + '\n' + 'x-amz-date:' + amzdate + '\n' + 'x-amz-security-token:' + self.session_token + '\n' - signed_headers = 'host;x-amz-date;x-amz-security-token' - payload_hash = hashlib.sha256(('').encode('utf-8')).hexdigest() - canonical_request = method + '\n' + canonical_uri + '\n' + canonical_querystring + '\n' + canonical_headers + '\n' + signed_headers + '\n' + payload_hash - - algorithm = 'AWS4-HMAC-SHA256' - credential_scope = datestamp + '/' + self.region + '/' + self.service + '/' + 'aws4_request' - string_to_sign = algorithm + '\n' + amzdate + '\n' + credential_scope + '\n' + hashlib.sha256(canonical_request.encode('utf-8')).hexdigest() - - signing_key = self.getSignatureKey(self.secret_key, datestamp, self.region, self.service) - signature = hmac.new(signing_key, (string_to_sign).encode('utf-8'), hashlib.sha256).hexdigest() - - authorization_header = algorithm + ' ' + 'Credential=' + self.access_key + '/' + credential_scope + ', ' + 'SignedHeaders=' + signed_headers + ', ' + 'Signature=' + signature - headers = {'x-amz-security-token': self.session_token, 'x-amz-date': amzdate, 'Authorization': authorization_header} - - req = "https://%s%s" % (host, uri) - if query != "": - req += "?%s" % query - return requests.get(req, headers=headers) - - -class irobotAuth: - def __init__(self, username, password, logger=None): - self.username = username - self.password = password - if logger: - self._logger = logger - else: - self._logger = logging.getLogger() - - def login(self): - r = requests.get("https://disc-prod.iot.irobotapi.com/v1/discover/endpoints?country_code=US") - response = r.json() - deployment = response['deployments'][next(iter(response['deployments']))] - self.httpBase = deployment['httpBase'] - iotBase = deployment['httpBaseAuth'] - iotUrl = urllib.parse.urlparse(iotBase) - self.iotHost = iotUrl.netloc - region = deployment['awsRegion'] - - self.apikey = response['gigya']['api_key'] - self.gigyaBase = response['gigya']['datacenter_domain'] - - data = {"apiKey": self.apikey, - "targetenv": "mobile", - "loginID": self.username, - "password": self.password, - "format": "json", - "targetEnv": "mobile", - } - - self._logger.debug("Post accounts.login request") - r = requests.post("https://accounts.%s/accounts.login" % self.gigyaBase, data=data) - response = r.json() - self._logger.debug("response: %s", response) - ''' - data = {"timestamp": int(time.time()), - "nonce": "%d_%d" % (int(time.time()), random.randint(0, 2147483647)), - "oauth_token": response.get('sessionInfo', {}).get('sessionToken', ''), - "targetEnv": "mobile"} - ''' - uid = response['UID'] - uidSig = response['UIDSignature'] - sigTime = response['signatureTimestamp'] - - data = { - "app_id": "ANDROID-C7FB240E-DF34-42D7-AE4E-A8C17079A294", - "assume_robot_ownership": "0", - "gigya": { - "signature": uidSig, - "timestamp": sigTime, - "uid": uid, - } - } - - self._logger.debug("Post login request to %s with data %s", self.httpBase, data) - r = requests.post("%s/v2/login" % self.httpBase, json=data) - - response = r.json() - self._logger.debug("response: %s", response) - access_key = response['credentials']['AccessKeyId'] - secret_key = response['credentials']['SecretKey'] - session_token = response['credentials']['SessionToken'] - - self.data = response - - self.amz = awsRequest(region, access_key, secret_key, session_token, "execute-api") - - def get_robots(self): - return self.data['robots'] - - def get_maps(self, robot): - return self.amz.get(self.iotHost, '/dev/v1/%s/pmaps' % robot, query="activeDetails=2").json() - - def get_newest_map(self, robot): - maps = self.get_maps(robot) - latest = "" - latest_time = 0 - for map in maps: - if map['create_time'] > latest_time: - latest_time = map['create_time'] - latest = map - return latest - - -def main(): - import argparse - import logging - import json - loglevel = logging.DEBUG - LOG_FORMAT = "%(asctime)s %(levelname)s [%(name)s] %(message)s" - LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S" - logging.basicConfig(format=LOG_FORMAT, datefmt=LOG_DATE_FORMAT, level=loglevel) - - # -------- Command Line ----------------- - parser = argparse.ArgumentParser( - description='Get password and map data from iRobot aws cloud service') - parser.add_argument( - 'login', - nargs='*', - action='store', - type=str, - default=[], - help='iRobot Account Login and Password (default: None)') - parser.add_argument( - '-m', '--maps', - action='store_true', - default=False, - help='List maps (default: %(default)s)') - - arg = parser.parse_args() - - if len(arg.login) >= 2: - irobot = irobotAuth(arg.login[0], arg.login[1]) - irobot.login() - robots = irobot.get_robots() - logging.info("Robot ID and data: %s", json.dumps(robots, indent=2)) - if arg.maps: - for robot in robots.keys(): - logging.info("Robot ID %s, MAPS: %s", robot, json.dumps(irobot.get_maps(robot), indent=2)) - else: - logging.error("Please enter iRobot account login and password") - - -if __name__ == '__main__': - main() diff --git a/resources/kroomba/irobot/irobot.py b/resources/kroomba/irobot/irobot.py index 739fed5..219df8a 100644 --- a/resources/kroomba/irobot/irobot.py +++ b/resources/kroomba/irobot/irobot.py @@ -6,7 +6,6 @@ import asyncio from collections.abc import Mapping -from irobot.password import Password import datetime import json import logging @@ -16,21 +15,15 @@ import paho.mqtt.client as mqtt from functools import cache +from .configs import iRobotConfig -class iRobot(object): + +class iRobot: ''' - This is a Class for Roomba WiFi connected Vacuum cleaners and mops - Requires firmware version 2.0 and above (not V1.0). Tested with Roomba 980, s9 - and braava M6. - username (blid) and password are required, and can be found using the - Password() class (in password.py - or can be auto discovered) + This is a Class for iRobot WiFi connected Vacuum cleaners and mops + Most of the underlying info was obtained from here: https://github.com/koalazak/dorita980 many thanks! - The values received from the Roomba as stored in a dictionary called - master_state, and can be accessed at any time, the contents are live, and - will build with time after connection. - This is not needed if the forward to mqtt option is used, as the events will - be decoded and published on the designated mqtt client topic. ''' VERSION = __version__ = "3.0" @@ -136,39 +129,32 @@ class iRobot(object): 216: "Charging base bag full", } - def __init__(self, address=None, blid=None, password=None, topic="#", - roombaName="", file="./config.ini"): + def __init__(self, config: iRobotConfig): ''' - address is the IP address of the Roomba, - leave topic as is, unless debugging (# = all messages). - if a python standard logging object called 'Roomba' exists, - it will be used for logging, - or pass a logging object + Initialize the iRobot object ''' self.loop = asyncio.get_running_loop() self.debug = False - self._logger = logging.getLogger(f"Roomba.{roombaName if roombaName else __name__}") + self._logger = logging.getLogger() if self._logger.getEffectiveLevel() == logging.DEBUG: self.debug = True - self.address = address - self.roomba_port = 8883 - self.blid = blid - self.password = password - self.roombaName = roombaName - self.file = file - self.get_passwd = Password(file=file) - self.topic = topic - self.mqttc = None + + if not all([config.name, config.ip, config.blid, config.password]): + self._logger.critical('Could not configure iRobot') + raise ValueError('Missing parameter, could not configure iRobot') + + self._config = config + self.port = 8883 + self.local_mqtt_client = None self.local_mqtt = False - self.exclude = "" - self.roomba_connected = False + self.connected = False + self.try_to_connect = True self.raw = False self.mapSize = None self.current_state = None self.master_state = {} self.update_seconds = 300 # update with all values every 5 minutes self.robot_mqtt_client = None - self.roombas_config = {} # Roomba configuration loaded from config file self.history = {} self.timers = {} self.flags = {} @@ -182,14 +168,13 @@ def __init__(self, address=None, blid=None, password=None, topic="#", self.loop.create_task(self.process_command_queue()) self.update = self.loop.create_task(self.periodic_update()) - if not all([self.address, self.blid, self.password]): - if not self.configure_roomba(): - self._logger.critical('Could not configure Roomba') - else: - self.roombas_config = {self.address: { - "blid": self.blid, - "password": self.password, - "roomba_name": self.roombaName}} + @property + def name(self): + return self._config.name + + @property + def ip(self): + return self._config.ip async def event_wait(self, evt, timeout): ''' @@ -201,21 +186,6 @@ async def event_wait(self, evt, timeout): pass return evt.is_set() - def configure_roomba(self): - self._logger.info('configuring Roomba from file %s', self.file) - self.roombas_config = self.get_passwd.get_roombas() - for ip, robot in self.roombas_config.items(): - if any([self.address == ip, self.blid == robot['blid'], robot['roomba_name'] == self.roombaName]): - self.roombaName = robot['roomba_name'] - self.address = ip - self.blid = robot['blid'] - self.password = robot['password'] - self.max_sqft = robot.get('max_sqft', 0) - return True - - self._logger.warning('No Roomba specified, or found, exiting') - return False - @cache def generate_tls_context(self) -> ssl.SSLContext: """Generate TLS context. @@ -230,21 +200,18 @@ def generate_tls_context(self) -> ssl.SSLContext: ssl_context.options |= getattr(ssl, "OP_LEGACY_SERVER_CONNECT", 0x4) return ssl_context - def setup_client(self): + async def setup_client(self): if self.robot_mqtt_client is None: self.robot_mqtt_client = mqtt.Client( callback_api_version=mqtt.CallbackAPIVersion.VERSION2, - client_id=self.blid, + client_id=self._config.blid, clean_session=True, protocol=mqtt.MQTTv311) # Assign event callbacks - self.robot_mqtt_client.on_message = self.on_robot_message - self.robot_mqtt_client.on_connect = self.on_robot_connect - self.robot_mqtt_client.on_subscribe = self.on_robot_subscribe - self.robot_mqtt_client.on_disconnect = self.on_robot_disconnect - - # Uncomment to enable debug messages - # self.client.on_log = self.on_log + self.robot_mqtt_client.on_message = self.on_robot_mqtt_message + self.robot_mqtt_client.on_connect = self.on_robot_mqtt_connect + self.robot_mqtt_client.on_subscribe = self.on_robot_mqtt_subscribe + self.robot_mqtt_client.on_disconnect = self.on_robot_mqtt_disconnect self._logger.info("Setting TLS") try: @@ -255,7 +222,7 @@ def setup_client(self): self._logger.exception("Error setting TLS: %s", e) # disables peer verification - self.robot_mqtt_client.username_pw_set(self.blid, self.password) + self.robot_mqtt_client.username_pw_set(self._config.blid, self._config.password) self._logger.info("Setting TLS - OK") return True return False @@ -268,21 +235,17 @@ def connect(self): async def async_connect(self): ''' - Connect to Roomba MQTT server + Connect to iRobot MQTT server ''' - if not all([self.address, self.blid, self.password]): - self._logger.critical("Invalid address, blid, or password! All these " - "must be specified!") - return False count = 0 max_retries = 3 retry_timeout = 1 - while not self.roomba_connected: + while not self.connected and self.try_to_connect: try: if self.robot_mqtt_client is None: - self._logger.info("Connecting...") - self.setup_client() - await self.loop.run_in_executor(None, self.robot_mqtt_client.connect, self.address, self.roomba_port, 60) + self._logger.info("Try to connect to %s with ip %s", self._config.name, self._config.ip) + await self.setup_client() + await self.loop.run_in_executor(None, self.robot_mqtt_client.connect, self._config.ip, self.port, 60) else: self._logger.info("Attempting to Reconnect...") self.robot_mqtt_client.loop_stop() @@ -291,9 +254,9 @@ async def async_connect(self): await self.event_wait(self.is_connected, 1) # wait for MQTT on_connect to fire (timeout 1 second) except (ConnectionRefusedError, OSError) as e: if e.errno == 111: # errno.ECONNREFUSED - self._logger.error('Unable to Connect to robot %s, make sure nothing else is connected (app?), as only one connection at a time is allowed', self.roombaName) + self._logger.error('Robot %s found but connection is refused, make sure nothing else is connected (app?), as only one connection at a time is allowed', self._config.name) elif e.errno == 113: # errno.No Route to Host - self._logger.error('Unable to contact robot %s on ip %s', self.roombaName, self.address) + self._logger.error('Unable to contact robot %s on ip %s', self._config.name, self._config.ip) else: self._logger.error("Connection Error: %s ", e) @@ -314,43 +277,38 @@ async def async_connect(self): if count >= max_retries: break - if not self.roomba_connected: - self._logger.error("Unable to connect to %s", self.roombaName) - return self.roomba_connected - - def disconnect(self): - self.loop.create_task(self._disconnect()) + if not self.connected: + self._logger.error("Unable to connect to %s", self._config.name) + return self.connected - async def _disconnect(self): + async def disconnect(self): try: self.robot_mqtt_client.disconnect() if self.local_mqtt: - self.mqttc.loop_stop() + self.local_mqtt_client.loop_stop() except Exception as e: self._logger.warning("Some exception occured during mqtt disconnect: %s", e) - self._logger.info('%s disconnected', self.roombaName) + self._logger.info('%s disconnected', self._config.name) - def connected(self, state): - self.roomba_connected = state - self.publish('status', 'Online' if self.roomba_connected else f"Offline at {time.ctime()}") + def _set_connected(self, state: bool): + self.connected = state + self.publish('status', 'Online' if self.connected else f"Offline at {time.ctime()}") - def on_robot_connect(self, client, userdata, flags, reason_code, properties): - self._logger.info("Roomba Connected") + def on_robot_mqtt_connect(self, client, userdata, flags, reason_code, properties): if reason_code == 0: - self.connected(True) - self.robot_mqtt_client.subscribe(self.topic) + self._logger.info("%s connected", self.name) + self._set_connected(True) + self.robot_mqtt_client.subscribe('#') self.robot_mqtt_client.subscribe("$SYS/#") else: self._logger.error("Connected with result code %s", reason_code) - self._logger.error("Please make sure your blid and password are correct for Roomba %s", self.roombaName) - self.connected(False) + self._logger.error("Please make sure your blid and password are correct for robot %s", self._config.name) + self.try_to_connect = False + self._set_connected(False) self.robot_mqtt_client.disconnect() self.loop.call_soon_threadsafe(self.is_connected.set) - def on_robot_message(self, client, userdata, message: mqtt.MQTTMessage): - if self.exclude != "" and self.exclude in message.topic: - return - + def on_robot_mqtt_message(self, client, userdata, message: mqtt.MQTTMessage): asyncio.run_coroutine_threadsafe(self.robot_msg_queue.put(message), self.loop) async def process_robot_msg_queue(self): @@ -370,7 +328,7 @@ async def process_robot_msg_queue(self): json_data = self.decode_payload(msg.topic, msg.payload) self.dict_merge(self.master_state, json_data) - self._logger.debug("Received Roomba Data: %s, %s", str(msg.topic), str(msg.payload)) + self._logger.debug("Received data: %s, %s", str(msg.topic), str(msg.payload)) if self.raw: self.publish(msg.topic, msg.payload) @@ -390,29 +348,31 @@ async def periodic_update(self): publish status peridically ''' while True: - # default every 5 minutes - await asyncio.sleep(self.update_seconds) - if self.roomba_connected: - self._logger.info("Publishing master_state") - await self.loop.run_in_executor(None, self.decode_topics, self.master_state) + try: + # default every 5 minutes + await asyncio.sleep(self.update_seconds) + if self.connected: + self._logger.info("Publishing %s master_state", self.name) + await self.loop.run_in_executor(None, self.decode_topics, self.master_state) + except asyncio.CancelledError: + break + except Exception as e: + self._logger.exception(e) - def on_robot_subscribe(self, client, userdata, mid, reason_codes, properties): + def on_robot_mqtt_subscribe(self, client, userdata, mid, reason_codes, properties): self._logger.debug("Subscribed: %s %s", mid, reason_codes) - def on_robot_disconnect(self, client, userdata, flags, reason_code, properties): + def on_robot_mqtt_disconnect(self, client, userdata, flags, reason_code, properties): self.loop.call_soon_threadsafe(self.is_connected.clear) - self.connected(False) + self._set_connected(False) if reason_code != 0: self._logger.warning("Unexpected Disconnect! - reconnecting") else: self._logger.info("Disconnected") - def on_log(self, client, userdata, level, buf): - self._logger.info(buf) - def set_mqtt_topic(self, topic, subscribe=False): - if self.blid: - topic = f"{topic}/{self.blid}{'/#' if subscribe else ''}" + if self._config.blid: + topic = f"{topic}/{self._config.blid}{'/#' if subscribe else ''}" return topic def setup_mqtt_client(self, broker=None, @@ -446,20 +406,20 @@ def _setup_mqtt_client(self, broker=None, self.brokerSetting = self.set_mqtt_topic(brokerSetting, True) # connect to broker - self.mqttc = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) + self.local_mqtt_client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) # Assign event callbacks - self.mqttc.on_message = self.broker_on_message - self.mqttc.on_connect = self.broker_on_connect - self.mqttc.on_disconnect = self.broker_on_disconnect + self.local_mqtt_client.on_message = self.broker_on_message + self.local_mqtt_client.on_connect = self.broker_on_connect + self.local_mqtt_client.on_disconnect = self.broker_on_disconnect if user and passwd: - self.mqttc.username_pw_set(user, passwd) - self.mqttc.connect(broker, port, 60) - self.mqttc.loop_start() + self.local_mqtt_client.username_pw_set(user, passwd) + self.local_mqtt_client.connect(broker, port, 60) + self.local_mqtt_client.loop_start() self.local_mqtt = True except socket.error: self._logger.error("Unable to connect to MQTT Broker") - self.mqttc = None - return self.mqttc + self.local_mqtt_client = None + return self.local_mqtt_client def broker_on_connect(self, client: mqtt.Client, userdata, flags, reason_code, properties): self._logger.debug("Broker Connected with result code %s", reason_code) @@ -559,7 +519,7 @@ def send_region_command(self, command): } command is json string, or dictionary. need 'regions' defined, or else whole map will be cleaned. - if 'pmap_id' is not specified, the first pmap_id found in roombas list is used. + if 'pmap_id' is not specified, the first pmap_id found in robots list is used. ''' pmaps = self.get_property('pmaps') self._logger.info('pmaps: %s', pmaps) @@ -624,24 +584,24 @@ def _set_preference(self, preference, setting): Command = {"state": {preference: val}} myCommand = json.dumps(Command) - self._logger.info(f"Publishing Roomba {self.roombaName} Setting :{myCommand}") + self._logger.info(f"Publishing {self._config.name} Setting :{myCommand}") self.robot_mqtt_client.publish("delta", myCommand) def _set_cleanSchedule(self, setting): - self._logger.info("Received Roomba %s cleanSchedule:", self.roombaName) + self._logger.info("Received %s cleanSchedule", self._config.name) sched = "cleanSchedule" if self.is_setting("cleanSchedule2"): sched = "cleanSchedule2" Command = {"state": {sched: setting}} myCommand = json.dumps(Command) - self._logger.info("Publishing Roomba %s %s : %s", self.roombaName, sched, myCommand) + self._logger.info("Publishing %s %s : %s", self._config.name, sched, myCommand) self.robot_mqtt_client.publish("delta", myCommand) def publish(self, topic, message): - if self.mqttc is not None and message is not None: + if self.local_mqtt_client is not None and message is not None: topic = f"{self.brokerFeedback}/{topic}" self._logger.debug("Publishing item: %s: %s", topic, message) - self.mqttc.publish(topic, message) + self.local_mqtt_client.publish(topic, message) def set_callback(self, cb=None): self.cb = cb @@ -966,7 +926,7 @@ def when_run(self, name): def timer(self, name, value=False, duration=10): self.timers.setdefault(name, {}) self.timers[name]['value'] = value - self._logger.info('Set %s to: %s', name, value) + self._logger.debug('Set %s to: %s', name, value) if self.timers[name].get('reset'): self.timers[name]['reset'].cancel() if value: @@ -989,7 +949,7 @@ def roomba_type(self, type): def update_state_machine(self, new_state=None): ''' - Roomba progresses through states (phases), current identified states + iRobot progresses through states (phases), current identified states are: "" : program started up, no state yet "run" : running on a Cleaning Mission @@ -997,7 +957,7 @@ def update_state_machine(self, new_state=None): "hmMidMsn" : need to recharge "hmPostMsn" : mission completed "charge" : charging - "stuck" : Roomba is stuck + "stuck" : robot is stuck "stop" : Stopped "pause" : paused "evac" : emptying bin @@ -1075,13 +1035,16 @@ def update_state_machine(self, new_state=None): if self.debug: self.timer('ignore_coordinates') - self._logger.info('current_state: %s, current phase: %s, mission: %s, mission_min: %s, recharge_min: %s, co-ords changed: %s', - self.current_state, - phase, - mission, - self.mssnM, - self.rechrgM, - self.changed('pose')) + self._logger.debug( + '%s current_state: %s, current phase: %s, mission: %s, mission_min: %s, recharge_min: %s, co-ords changed: %s', + self.name, + self.current_state, + phase, + mission, + self.mssnM, + self.rechrgM, + self.changed('pose') + ) if self.current_state == self.states["new"] and phase != 'run': self._logger.info('waiting for run state for New Missions') diff --git a/resources/kroomba/irobot/password.py b/resources/kroomba/irobot/password.py deleted file mode 100644 index ce1bfb4..0000000 --- a/resources/kroomba/irobot/password.py +++ /dev/null @@ -1,285 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -__version__ = "2.0a" -''' -Python 3.6 -Quick Program to get blid and password from roomba - -Nick Waterton 5th May 2017: V 1.0: Initial Release -Nick Waterton 22nd Dec 2020: V2.0: Updated for i and S Roomba versions, update to minimum python version 3.6 -''' - -from pprint import pformat -import json -import logging -import socket -import ssl -from ast import literal_eval -import configparser - - -class Password(object): - ''' - Get Roomba blid and password - only V2 firmware supported - if IP is not supplied, class will attempt to discover the Roomba IP first. - Results are written to a config file, default ".\config.ini" - V 1.2.3 NW 9/10/2018 added support for Roomba i7 - V 1.2.5 NW 7/10/2019 changed PROTOCOL_TLSv1 to PROTOCOL_TLS to fix i7 software connection problem - V 1.2.6 NW 12/11/2019 add cipher to ssl to avoid dh_key_too_small issue - V 2.0 NW 22nd Dec 2020 updated for S and i versions plus braava jet m6, min version of python 3.6 - V 2.1 NW 9th Dec 2021 Added getting password from aws cloud. - ''' - - VERSION = __version__ = "2.1" - - config_dicts = ['data', 'mapsize', 'pmaps', 'regions'] - - def __init__(self, address='255.255.255.255', file=".\config.ini", login=[]): - self.address = address - self.file = file - self.login = None - self.password = None - if len(login) >= 2: - self.login = login[0] - self.password = login[1] - self.log = logging.getLogger(f"Roomba.{__class__.__name__}") - self.log.info("Using Password version %s", self.__version__) - - def read_config_file(self): - # read config file - Config = configparser.ConfigParser() - roombas = {} - try: - Config.read(self.file) - self.log.info("reading/writing info from config file %s", self.file) - roombas = {s: {k: literal_eval(v) if k in self.config_dicts else v for k, v in Config.items(s)} for s in Config.sections()} - except Exception as e: - self.log.exception(e) - return roombas - - def receive_udp(self): - # set up UDP socket to receive data from robot - port = 5678 - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.settimeout(10) - if self.address == '255.255.255.255': - s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) - s.bind(("", port)) # bind all interfaces to port - self.log.info("waiting on port: %s for data", port) - message = 'irobotmcs' - s.sendto(message.encode(), (self.address, port)) - roomba_dict = {} - while True: - try: - udp_data, addr = s.recvfrom(1024) # wait for udp data - if udp_data and udp_data.decode() != message: - try: - # if self.address != addr[0]: - # self.log.warning( - # "supplied address {} does not match " - # "discovered address {}, using discovered " - # "address...".format(self.address, addr[0])) - - parsedMsg = json.loads(udp_data.decode()) - if addr[0] not in roomba_dict.keys(): - s.sendto(message.encode(), (self.address, port)) - roomba_dict[addr[0]] = parsedMsg - self.log.info('Robot at IP: %s Data: %s', addr[0], json.dumps(parsedMsg)) - except Exception as e: - self.log.info("json decode error: %s", e) - self.log.info('RECEIVED: %s', pformat(udp_data)) - - except socket.timeout: - break - s.close() - return roomba_dict - - def add_cloud_data(self, cloud_data, roombas): - for k, v in roombas.copy().items(): - robotid = v.get('robotid', v.get("hostname", "").split('-')[1]) - for id, data in cloud_data.items(): - if robotid == id: - roombas[k]["password"] = data.get('password') - return roombas - - def get_password(self): - # load roombas from config file - file_roombas = self.read_config_file() - cloud_roombas = {} - # get roomba info - roombas = self.receive_udp() - if self.login and self.password: - self.log.info("Getting Roomba information from iRobot aws cloud...") - from .getcloudpassword import irobotAuth - iRobot = irobotAuth(self.login, self.password, self.log) - iRobot.login() - self.log.info("Login done, getting robots from iRobot aws cloud...") - cloud_roombas = iRobot.get_robots() - self.log.info("Got cloud info: %s", json.dumps(cloud_roombas)) - self.log.info("Found %i roombas defined in the cloud", len(cloud_roombas)) - if len(cloud_roombas) > 0 and len(roombas) > 0: - roombas = self.add_cloud_data(cloud_roombas, roombas) - - if len(roombas) == 0: - self.log.warning("No Roombas found on network, try again...") - return False - - self.log.info("%i robot(s) already defined in file %s, found %i robot(s) on network", len(file_roombas), self.file, len(roombas)) - - for addr, parsedMsg in roombas.items(): - blid = parsedMsg.get('robotid', parsedMsg.get("hostname", "").split('-')[1]) - robotname = parsedMsg.get('robotname', 'unknown') - if int(parsedMsg.get("ver", "3")) < 2: - self.log.info("Roombas at address: %s does not have the correct firmware version. Your version info is: %s", addr, json.dumps(parsedMsg)) - continue - - password = parsedMsg.get('password') - if password is None: - self.log.info("To add/update Your robot details," - "make sure your robot (%s) at IP %s is on the Home Base and " - "powered on (green lights on). Then press and hold the HOME " - "button on your robot until it plays a series of tones " - "(about 2 seconds). Release the button and your robot will " - "flash WIFI light.", robotname, addr) - else: - self.log.info("Configuring robot (%s) at IP %s from cloud data, blid: %s, password: %s", robotname, addr, blid, password) - - if password is None: - self.log.info("Roomba (%s) IP address is: %s", robotname, addr) - data = self.get_password_from_roomba(addr) - - if len(data) <= 7: - self.log.error('Error getting password for robot %s at ip %s, received %i bytes. Follow the instructions and try again.', robotname, addr, len(data)) - continue - # Convert password to str - password = str(data[7:].decode().rstrip('\x00')) # for i7 - has null termination - self.log.info("blid is: %s", blid) - self.log.info('Password=> %s <= Yes, all this string.', password) - - file_roombas.setdefault(addr, {}) - file_roombas[addr]['blid'] = blid - file_roombas[addr]['password'] = password - file_roombas[addr]['data'] = parsedMsg - return self.save_config_file(file_roombas) - - def get_password_from_roomba(self, addr): - ''' - Send MQTT magic packet to addr - this is 0xf0 (mqtt reserved) 0x05(data length) 0xefcc3b2900 (data) - Should receive 37 bytes containing the password for roomba at addr - This is is 0xf0 (mqtt RESERVED) length (0x23 = 35) 0xefcc3b2900 (magic packet), - followed by 0xXXXX... (30 bytes of password). so 7 bytes, followed by 30 bytes of password - total of 37 bytes - Uses 10 second timeout for socket connection - ''' - data = b'' - packet = bytes.fromhex('f005efcc3b2900') - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(10) - - # context = ssl.SSLContext(ssl.PROTOCOL_TLS) - context = ssl.SSLContext() - context.check_hostname = False - context.verify_mode = ssl.CERT_NONE - # context.set_ciphers('DEFAULT@SECLEVEL=1:HIGH:!DH:!aNULL') - wrappedSocket = context.wrap_socket(sock) - - try: - wrappedSocket.connect((addr, 8883)) - self.log.debug('Connection Successful') - wrappedSocket.send(packet) - self.log.debug('Waiting for data') - - while len(data) < 37: - data_received = wrappedSocket.recv(1024) - data += data_received - if len(data_received) == 0: - self.log.info("socket closed") - break - - wrappedSocket.close() - return data - - except socket.timeout as e: - self.log.error('Connection Timeout Error (for %s): %s', addr, e) - except (ConnectionRefusedError, OSError) as e: - if e.errno == 111: # errno.ECONNREFUSED - self.log.error('Unable to Connect to roomba at ip %s, make sure nothing else is connected (app?), as only one connection at a time is allowed', addr) - elif e.errno == 113: # errno.No Route to Host - self.log.error('Unable to contact roomba on ip %s is the ip correct?', addr) - else: - self.log.error("Connection Error (for %s): %s", addr, e) - except Exception as e: - self.log.exception(e) - - self.log.error('Unable to get password from roomba') - return data - - def save_config_file(self, roomba): - Config = configparser.ConfigParser() - if roomba: - for addr, data in roomba.items(): - Config.add_section(addr) - for k, v in data.items(): - Config.set(addr, k, pformat(v) if k in self.config_dicts else v) - # write config file - with open(self.file, 'w') as cfgfile: - Config.write(cfgfile) - self.log.info('Configuration saved to %s', self.file) - else: - return False - return True - - def get_roombas(self): - roombas = self.read_config_file() - if not roombas: - self.log.warn("No roomba or config file defined, I will attempt to " - "discover Roombas, please put the Roomba on the dock " - "and follow the instructions:") - self.get_password() - return self.get_roombas() - self.log.info("%i Roombas Found", len(roombas)) - for ip in roombas.keys(): - roombas[ip]["roomba_name"] = roombas[ip]['data']['robotname'] - return roombas - - -def main(): - import argparse - loglevel = logging.DEBUG - LOG_FORMAT = "%(asctime)s %(levelname)s [%(name)s] %(message)s" - LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S" - logging.basicConfig(format=LOG_FORMAT, datefmt=LOG_DATE_FORMAT, level=loglevel) - - # -------- Command Line ----------------- - parser = argparse.ArgumentParser( - description='Get Robot passwords and update config file') - parser.add_argument( - 'login', - nargs='*', - action='store', - type=str, - default=[], - help='iRobot Account Login and Password (default: None)') - parser.add_argument( - '-f', '--configfile', - action='store', - type=str, - default="./config.ini", - help='config file name, (default: %(default)s)') - parser.add_argument( - '-R', '--roombaIP', - action='store', - type=str, - default='255.255.255.255', - help='ipaddress of Roomba (default: %(default)s)') - - arg = parser.parse_args() - - get_passwd = Password(arg.roombaIP, file=arg.configfile, login=arg.login) - get_passwd.get_password() - - -if __name__ == '__main__': - main() diff --git a/resources/kroomba/kroombad.py b/resources/kroomba/kroombad.py index 7ef6d3f..bcfa670 100644 --- a/resources/kroomba/kroombad.py +++ b/resources/kroomba/kroombad.py @@ -1,62 +1,100 @@ import os +from pathlib import Path from irobot.irobot import iRobot -from irobot.password import Password +from irobot.configs import iRobotConfigs from jeedomdaemon.base_daemon import BaseDaemon +from jeedomdaemon.base_config import BaseConfig -from config import iRobotConfig + +class DaemonConfig(BaseConfig): + def __init__(self): + super().__init__() + + self.add_argument("--host", help="mqtt host ip", type=str, default='127.0.0.1') + self.add_argument("--port", help="mqtt host port", type=int, default=1883) + self.add_argument("--user", help="mqtt username", type=str) + self.add_argument("--password", help="mqtt password", type=str) + self.add_argument("--topic_prefix", help="topic_prefix", type=str, default='iRobot') + self.add_argument("--excluded_blid", type=str) + + @property + def mqtt_host(self): + return str(self._args.host) + + @property + def mqtt_port(self): + return int(self._args.port) + + @property + def mqtt_user(self): + return str(self._args.user) + + @property + def mqtt_password(self): + return str(self._args.password) + + @property + def topic_prefix(self): + return str(self._args.topic_prefix) + + @property + def excluded_blid(self): + blids = str(self._args.excluded_blid) + return [str(x) for x in blids.split(',') if x != ''] class kroomba(BaseDaemon): def __init__(self) -> None: - self._config = iRobotConfig() + self._config = DaemonConfig() super().__init__(self._config, self.on_start, self.on_message, self.on_stop) - self.set_logger_log_level('Roomba') - basedir = os.path.dirname(__file__) - self._roomba_configFile = os.path.abspath(basedir + '/../../data/config.ini') - self._get_password = Password(file=self._roomba_configFile) - self._robots: dict[str, iRobot] = {} + # self.set_logger_log_level('iRobot') + + self._robot_configs: iRobotConfigs = None + self._robots: list[iRobot] = [] async def on_start(self): - all_robots = self._get_password.read_config_file() - if not all_robots: + basedir = os.path.dirname(__file__) + configFile = Path(os.path.abspath(basedir + '/../../data')) + self._robot_configs = iRobotConfigs(path=configFile) + + if len(self._robot_configs.robots) == 0: + self._logger.info('No robot configured, trying auto discovery') + await self._robot_configs.discover() + + if len(self._robot_configs.robots) == 0: self._logger.warning('No robot or config file defined, please run discovery from plugin page') - await self.send_to_jeedom({'msg': "NO_ROOMBA"}) - else: - for ip in all_robots.keys(): - data = all_robots[ip] - if data.get('blid') in self._config.excluded_blid: - self._logger.debug("Exclude blid: %s", data.get('blid')) - continue - - new_roomba = iRobot(address=ip, file=self._roomba_configFile) - new_roomba.setup_mqtt_client( - self._config.mqtt_host, - self._config.mqtt_port, - self._config.mqtt_user, - self._config.mqtt_password, - brokerFeedback=self._config.topic_prefix+'/feedback', - brokerCommand=self._config.topic_prefix+'/command', - brokerSetting=self._config.topic_prefix+'/setting' - ) - self._logger.info("Try to connect to iRobot %s with ip %s", new_roomba.roombaName, new_roomba.address) - await new_roomba.async_connect() - self._robots[new_roomba.address] = new_roomba + await self.send_to_jeedom({'msg': "NO_ROBOT"}) + + for robot_config in self._robot_configs.robots.values(): + if robot_config.blid in self._config.excluded_blid: + self._logger.debug("Exclude robot: %s", robot_config.name) + continue + + new_robot = iRobot(robot_config) + new_robot.setup_mqtt_client( + self._config.mqtt_host, + self._config.mqtt_port, + self._config.mqtt_user, + self._config.mqtt_password, + brokerFeedback=self._config.topic_prefix+'/feedback', + brokerCommand=self._config.topic_prefix+'/command', + brokerSetting=self._config.topic_prefix+'/setting' + ) + if await new_robot.async_connect(): + self._robots.append(new_robot) async def on_stop(self): - for robot in self._robots.values(): - robot.disconnect() + for robot in self._robots: + await robot.disconnect() self._robots.clear() async def on_message(self, message: list): if message['action'] == 'discover': try: - self._get_password.login = message['login'] - self._get_password.password = message['password'] - self._get_password.address = message['address'] - result = self._get_password.get_password() + result = await self._robot_configs.discover(message['address'], message['login'], message['password']) await self.send_to_jeedom({'discover': result}) except Exception as e: self._logger.error('Error during discovery: %s', e)