Merge PR #407: New plugin: Neato botvac

master
Jenkins nymea 2021-04-28 13:54:40 +02:00
commit ba5c391cb6
10 changed files with 693 additions and 0 deletions

17
debian/control vendored
View File

@ -480,6 +480,22 @@ Description: nymea.io plugin for a generic MQTT client
This package will install a generic MQTT client plugin for nymea.io
Package: nymea-plugin-neatobotvac
Architecture: any
Depends: ${shlibs:Depends},
${misc:Depends},
nymea-plugins-translations,
python3-pip,
Description: nymea.io plugin for neato
The nymea daemon is a plugin based IoT (Internet of Things) server. The
server works like a translator for devices, things and services and
allows them to interact.
With the powerful rule engine you are able to connect any device available
in the system and create individual scenes and behaviors for your environment.
.
This package will install the nymea.io plugin for Neato Botvac robots.
Package: nymea-plugin-netatmo
Architecture: any
Depends: ${shlibs:Depends},
@ -1163,6 +1179,7 @@ Depends: nymea-plugin-anel,
nymea-plugin-texasinstruments,
nymea-plugin-telegram,
nymea-plugin-nanoleaf,
nymea-plugin-neatobotvac,
nymea-plugin-netatmo,
nymea-plugin-networkdetector,
nymea-plugin-nuki,

View File

@ -0,0 +1,3 @@
neatobotvac/integrationpluginneatobotvac.json usr/lib/@DEB_HOST_MULTIARCH@/nymea/plugins/neatobotvac/
neatobotvac/integrationpluginneatobotvac.py usr/lib/@DEB_HOST_MULTIARCH@/nymea/plugins/neatobotvac/
neatobotvac/requirements.txt usr/lib/@DEB_HOST_MULTIARCH@/nymea/plugins/neatobotvac/

17
neatobotvac/README.md Normal file
View File

@ -0,0 +1,17 @@
# Neato Botvac
This plugin allows to interact with your Neato Botvac cleaning robots. Each robot linkd to your account will appear automatically in the system, once the account is added to nymea.
## Supported Things
* Neato D7 (Tested)
* Other Neato Dx should also work, but haven't been tested
## Requirements
* The robot must be added to a personal Neato account, that can be created via the Neato Robotics website.
* The package “nymea-plugin-neatobotvac” must be installed
## More
[Neato Robotics](https://neatorobotics.com/)

View File

@ -0,0 +1,170 @@
{
"name": "neato",
"displayName": "Neato",
"id": "4f6ecb6f-a7fe-4fdb-b8d8-45b1f235110c",
"apiKeys": ["neato"],
"vendors": [
{
"name": "neato",
"displayName": "Neato Robotics",
"id": "d2a234a5-0aeb-4c04-98d5-6428cd266433",
"thingClasses": [
{
"id": "fe594fb0-b712-4f23-8267-649eb459747b",
"name": "account",
"displayName": "Neato account",
"createMethods": ["User"],
"interfaces": ["account"],
"setupMethod": "oauth",
"settingsTypes": [
],
"stateTypes":[
{
"id": "e8f47781-e3fd-416f-a9ac-51ef942d0573",
"name": "connected",
"displayName": "Connected",
"displayNameEvent": "Connected/disconnected",
"type": "bool",
"defaultValue": false,
"cached": false
},
{
"id": "b0db7079-49f0-444a-9c55-4bb4c764f3cb",
"name": "loggedIn",
"displayName": "Logged in",
"displayNameEvent": "Logged in or out",
"type": "bool",
"defaultValue": false
}
],
"actionTypes": [
]
},
{
"id": "b924c87a-f783-4f45-a3af-929684c24aea",
"name": "robot",
"displayName": "Neato robot",
"createMethods": ["auto"],
"interfaces":["cleaningrobot", "battery", "connectable"],
"browsable": true,
"paramTypes": [
{
"id": "def9a4bb-7a7e-4e3a-a63c-c55a105abb5e",
"name": "serial",
"displayName": "Robot Serial",
"type": "QString"
},
{
"id": "3793e48b-043e-43cb-b672-7c1e2e90bc8e",
"name": "secret",
"displayName": "Secret",
"type": "QString"
}
],
"settingsTypes": [
{
"id": "dabaafd3-908f-4f06-8039-5a7a729346da",
"name": "eco",
"displayName": "Eco",
"type": "bool",
"defaultValue": true
},
{
"id": "86694abb-5633-4e62-bd6c-325eb246c683",
"name": "care",
"displayName": "Extra Care",
"type": "bool",
"defaultValue": false
},
{
"id": "f72bcfbd-a262-44b3-ad75-9bb094aa2bb1",
"name": "noGoLines",
"displayName": "No-go Lines Enabled",
"type": "bool",
"defaultValue": true
}
],
"stateTypes":[
{
"id": "4c319e4b-9206-48ed-9d24-56a536c58d61",
"name": "connected",
"displayName": "Connected",
"displayNameEvent": "Connected changed",
"type": "bool",
"defaultValue": false,
"cached": false
},
{
"id": "dce4f7f3-a0a6-46bb-9216-c9089d9e9b0d",
"name": "robotState",
"displayName": "Cleaning state",
"displayNameEvent": "Cleaning state changed",
"type": "QString",
"possibleValues": ["docked", "cleaning", "paused", "traveling", "stopped", "error"],
"defaultValue": "docked"
},
{
"id": "cb22b48c-1c21-4d52-bde6-847287435685",
"name": "errorMessage",
"displayName": "Error message",
"displayNameEvent": "Error message changes",
"type": "QString",
"defaultValue": "no error"
},
{
"id": "1b8abd35-8276-44ba-8c75-a647877b2e11",
"name": "charging",
"displayName": "Charging",
"displayNameEvent": "Started or stopped charging",
"type": "bool",
"defaultValue": true
},
{
"id": "1985ce98-f387-47e0-a5f3-9b807f532ca1",
"name": "batteryCritical",
"displayName": "Battery critical",
"displayNameEvent": "Battery entered or left critical state",
"type": "bool",
"defaultValue": false
},
{
"id": "20ed8767-806f-4ec2-8626-842cd398f9df",
"name": "batteryLevel",
"displayName": "Battery level",
"displayNameEvent": "Battery level changed",
"type": "int",
"unit": "Percentage",
"defaultValue": 0,
"minValue": 0,
"maxValue": 100
}
],
"actionTypes": [
{
"id": "1f774998-5fa7-4e3b-8ab0-a8402dd561bb",
"name": "startCleaning",
"displayName": "Start cleaning"
},
{
"id": "e731faa6-88c9-406d-b505-f89b5f0868b0",
"name": "pauseCleaning",
"displayName": "Pause/resume cleaning"
},
{
"id": "5178a803-5696-4ee1-80a4-2c7c20a5043a",
"name": "returnToBase",
"displayName": "Return to base"
},
{
"id": "30775042-55a7-4f1b-9042-a9bdeadc4b0d",
"name": "stopCleaning",
"displayName": "Stop cleaning"
}
]
}
]
}
]
}

View File

@ -0,0 +1,328 @@
import nymea
import time
import threading
from pybotvac import Account, Neato, OAuthSession, PasswordlessSession, PasswordSession, Vorwerk, Robot
import json
# pybotvac library: https://github.com/stianaske/pybotvac
oauthSessions = {}
accountsMap = {}
thingsAndRobots = {}
pollTimer = None
def startPairing(info):
# Start OAuth2 session
apiKey = apiKeyStorage().requestKey("neato")
oauthSession = OAuthSession(client_id=apiKey.data("clientId"), client_secret=apiKey.data("clientSecret"), redirect_uri="https://127.0.0.1:8888", vendor=Neato())
oauthSessions[info.transactionId] = oauthSession;
authorizationUrl = oauthSession.get_authorization_url()
info.oAuthUrl = authorizationUrl
info.finish(nymea.ThingErrorNoError)
def confirmPairing(info, username, secret):
# The user has successfully logged in at neato. Obtain the token from the OAuth session
token = oauthSessions[info.transactionId].fetch_token(secret)
pluginStorage().beginGroup(info.thingId)
pluginStorage().setValue("token", json.dumps(token))
pluginStorage().endGroup();
del oauthSessions[info.transactionId]
info.finish(nymea.ThingErrorNoError)
def setupThing(info):
# Setup for the account
if info.thing.thingClassId == accountThingClassId:
logger.log("SetupThing for account:", info.thing.name)
pluginStorage().beginGroup(info.thing.id)
token = json.loads(pluginStorage().value("token"))
logger.log("setup", token)
pluginStorage().endGroup();
try:
oAuthSession = OAuthSession(token=token)
# Login went well, finish the setup
info.finish(nymea.ThingErrorNoError)
except Exception as e:
# Login error
logger.warn("Error setting up neato account:", str(e))
info.finish(nymea.ThingErrorAuthenticationFailure, str(e))
return
# Mark the account as logged-in and connected
info.thing.setStateValue(accountLoggedInStateTypeId, True)
info.thing.setStateValue(accountConnectedStateTypeId, True)
# Create an account session on the session to get info about the login
account = Account(oAuthSession)
accountsMap[info.thing] = account
# List all robots associated with account
logger.log("account created. Robots:", account.robots);
thingDescriptors = []
for robot in account.robots:
logger.log("Found new robot:", robot.serial)
# Check if this robot is already added in nymea
found = False
for thing in myThings():
logger.log("Comparing to existing robot:", thing.name)
if thing.thingClassId == robotThingClassId and thing.paramValue(robotThingSerialParamTypeId) == robot.serial:
logger.log("Already have this robot in the system")
# Yep, already here... skip it
found = True
break
if found:
continue
logger.log("Adding new robot to the system with parent", info.thing.id)
thingDescriptor = nymea.ThingDescriptor(robotThingClassId, robot.name, parentId=info.thing.id)
thingDescriptor.params = [
nymea.Param(robotThingSerialParamTypeId, robot.serial),
nymea.Param(robotThingSecretParamTypeId, robot.secret)
]
thingDescriptors.append(thingDescriptor)
# And let nymea know about all the users robots
autoThingsAppeared(thingDescriptors)
# If no poll timer is set up yet, start it now
logger.log("Creating polltimer")
global pollTimer
if pollTimer is None:
pollTimer = threading.Timer(5, pollService)
pollTimer.start()
return
# Setup for the robots
if info.thing.thingClassId == robotThingClassId:
logger.log("SetupThing for robot:", info.thing.name)
serial = info.thing.paramValue(robotThingSerialParamTypeId)
secret = info.thing.paramValue(robotThingSecretParamTypeId)
try:
robot = Robot(serial, secret, info.thing.name)
thingsAndRobots[info.thing] = robot;
refreshRobot(info.thing)
except Exception as e:
logger.warn("Error setting up robot:", e)
info.finish(nymea.ThingErrorHardwareFailure, str(e))
return
info.thing.setStateValue(robotConnectedStateTypeId, True)
# set up polling for robot status
info.finish(nymea.ThingErrorNoError)
return
def refreshRobot(thing):
robot = thingsAndRobots[thing]
logger.log("Refreshing robot:", robot)
# Get robot state
rbtState = thingsAndRobots[thing].get_robot_state()
rbtStateJson = rbtState.json()
logger.log("Robot state for %s: %s" % (thing.name, rbtStateJson))
# Set robot docked/charging state
rbtStateDetails = rbtStateJson['details']
thing.setStateValue(robotChargingStateTypeId, rbtStateDetails['isCharging'])
thing.setStateValue(robotBatteryLevelStateTypeId, rbtStateDetails['charge'])
# Set robot cleaning/paused state
rbtStateCommands = rbtStateJson['availableCommands']
rbtStartAv = rbtStateCommands['start']
rbtPauseAv = rbtStateCommands['pause']
rbtResumeAv = rbtStateCommands['resume']
if rbtStateJson['error'] == None:
rbtError = "no error"
else:
rbtError = rbtStateJson['error']
# alert state hasn't been implemented yet (not sure what would trigger an alert, haven't seen one yet)
if rbtStateJson['alert'] == None:
rbtAlert = "no alert"
else:
rbtAlert = rbtStateJson['alert']
logger.log("error: %s: -- alert: %s" % (rbtError, rbtAlert))
if rbtStateDetails['isDocked'] == True:
thing.setStateValue(robotRobotStateStateTypeId, "docked")
elif rbtPauseAv == True:
thing.setStateValue(robotRobotStateStateTypeId, "cleaning")
elif rbtResumeAv == True:
thing.setStateValue(robotRobotStateStateTypeId, "paused")
elif rbtStartAv == True:
thing.setStateValue(robotRobotStateStateTypeId, "stopped")
else:
thing.setStateValue(robotRobotStateStateTypeId, "error")
thing.setStateValue(robotErrorMessageStateTypeId, rbtError)
def pollService():
# Poll all robots we know
for thing in myThings():
if thing.thingClassId == robotThingClassId:
try:
refreshRobot(thing)
except:
logger.warn("Error refreshing robot state")
# restart the timer for next poll
global pollTimer
pollTimer = threading.Timer(60, pollService)
pollTimer.start()
def executeAction(info):
if info.actionTypeId == robotStartCleaningActionTypeId:
refreshRobot(info.thing)
if info.thing.stateValue(robotRobotStateStateTypeId) == "paused":
thingsAndRobots[info.thing].resume_cleaning()
else:
cleanWithRobot(info.thing, None, None)
refreshRobot(info.thing)
info.finish(nymea.ThingErrorNoError)
return
if info.actionTypeId == robotPauseCleaningActionTypeId:
refreshRobot(info.thing)
if info.thing.stateValue(robotRobotStateStateTypeId) == "paused":
thingsAndRobots[info.thing].resume_cleaning()
else:
thingsAndRobots[info.thing].pause_cleaning()
refreshRobot(info.thing)
info.finish(nymea.ThingErrorNoError)
return
if info.actionTypeId == robotReturnToBaseActionTypeId:
thingsAndRobots[info.thing].send_to_base()
refreshRobot(info.thing)
info.finish(nymea.ThingErrorNoError)
return
if info.actionTypeId == robotStopCleaningActionTypeId:
thingsAndRobots[info.thing].stop_cleaning()
refreshRobot(info.thing)
info.finish(nymea.ThingErrorNoError)
return
def cleanWithRobot(robotThing, mapID, boundaryID):
# To do: add a parameter to the start action which takes a zone id --> this should now be represented by mapID & boundaryID
robot = thingsAndRobots[robotThing]
logger.log("Cleaning with robot:", robot, robotThing)
boolEco = robotThing.setting(robotSettingsEcoParamTypeId)
boolCare = robotThing.setting(robotSettingsCareParamTypeId)
boolNogo = robotThing.setting(robotSettingsNoGoLinesParamTypeId)
if boolEco == False:
intEco = 2
else:
intEco = 1
if boolCare == False:
intCare = 1
else:
intCare = 2
if boolNogo == False:
intNogo = 2
else:
intNogo = 4
logger.log("Settings: Eco:", boolEco, "Care:", boolCare, "Enable nogo:", boolNogo, "mapID:", mapID, "boundaryID:", boundaryID)
thingsAndRobots[robotThing].start_cleaning(mode=intEco, navigation_mode=intCare, category=intNogo, boundary_id=boundaryID, map_id=mapID)
refreshRobot(robotThing)
def cleanWithRobot(robotThing, mapID, boundaryID):
# To do: add a parameter to the start action which takes a zone id --> this should now be represented by mapID & boundaryID
robot = thingsAndRobots[robotThing]
logger.log("Cleaning with robot:", robot, robotThing, mapID, boundaryID)
boolEco = robotThing.setting(robotSettingsEcoParamTypeId)
boolCare = robotThing.setting(robotSettingsCareParamTypeId)
boolNogo = robotThing.setting(robotSettingsNoGoLinesParamTypeId)
if boolEco == False:
intEco = 2
else:
intEco = 1
if boolCare == False:
intCare = 1
else:
intCare = 2
if boolNogo == False:
intNogo = 2
else:
intNogo = 4
logger.log("Settings: Eco:", boolEco, "Care:", boolCare, "Enable nogo:", boolNogo, "mapID:", mapID, "boundaryID:", boundaryID)
thingsAndRobots[robotThing].start_cleaning(mode=intEco, navigation_mode=intCare, category=intNogo, boundary_id=boundaryID, map_id=mapID)
def browseThing(browseResult):
robotThing = browseResult.thing
robot = thingsAndRobots[browseResult.thing]
account = None
for thing in myThings():
logger.log("checking thing", thing.name, thing.id, robotThing.parentId)
if thing.id == robotThing.parentId:
account = accountsMap[thing]
break
if account is None:
logger.warn("Cannot find account for robot", robotThing.name)
browseResult.finish(nymea.ThingErrorAuthenticationFailure)
return;
# Top level entries -> return maps
if browseResult.itemId == "" or browseResult.itemId == "maps":
maps = account.persistent_maps
logger.log("maps", type(maps), maps)
for map in maps[robot.serial]:
logger.log("Type mapInfo: ", type(map))
logger.log("map:", map)
browseResult.addItem(nymea.BrowserItem("map-" + map["id"], map["name"], browsable=True, thumbnail=map["url"]))
browseResult.finish(nymea.ThingErrorNoError)
return
# browsing boundaries for a map
if browseResult.itemId.startswith("map-"):
mapId = browseResult.itemId[4:]
try:
boundaries = robot.get_map_boundaries(mapId)
except Exception as e:
logger.warn("Error fetching boundaries from robot:", e)
info.finish(nymea.ThingErrorHardwareFailure, "Unable to fetch boundaries from robot.")
logger.log("boundaries", type(boundaries), boundaries.json())
for boundary in boundaries.json()["data"]["boundaries"]:
if boundary["type"] == "polygon":
logger.log("vertices:", boundary)
browseResult.addItem(nymea.BrowserItem("boundary-" + mapId + ";" + boundary["id"], boundary["name"], json.dumps(boundary), executable=True, disabled=not boundary["enabled"], icon=nymea.BrowserIconFavorites))
browseResult.finish(nymea.ThingErrorNoError)
return
def executeBrowserItem(info):
logger.log("Browser item clicked:", info.itemId)
if info.itemId.startswith("boundary-"):
ids = info.itemId[9:];
logger.log("IDS:", ids)
mapId = ids.split(";")[0]
boundaryId = ids.split(";")[1]
logger.log("Cleaning boundary:", mapId, boundaryId)
cleanWithRobot(info.thing, mapId, boundaryId)
refreshRobot(info.thing)
info.finish(nymea.ThingErrorNoError)
return
logger.warn("Can't execute browser item:", info.itemId)
info.finish(nymea.ThingErrorItemNotExecutable)
def deinit():
global pollTimer
# If we started a poll timer, cancel it on shutdown.
if pollTimer is not None:
pollTimer.cancel()

14
neatobotvac/meta.json Normal file
View File

@ -0,0 +1,14 @@
{
"title": "Neato Botvac",
"tagline": "Connect to and control your Neato Botvac connected cleaning robots.",
"icon": "neato.png",
"stability": "consumer",
"offline": true,
"technologies": [
"network"
],
"categories": [
"account",
"cleaningrobot"
]
}

BIN
neatobotvac/neato.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@ -0,0 +1,5 @@
TEMPLATE = aux
OTHER_FILES = integrationpluginneatobotvac.json \
integrationpluginneatobotvac.py

View File

@ -0,0 +1,138 @@
pybotvac==0.0.20 \
--hash=sha256:d4d9d348ee2f3b7472b78bd80f9653406af6e351aa0362ac191055a304930b33 \
--hash=sha256:e49a3258f251da0d56764797efb01ba730537b6344ff03b57542142f0640b273
setuptools==54.1.1 \
--hash=sha256:1ce82798848a978696465866bb3aaab356003c42d6143e1111fcf069ac838274 \
--hash=sha256:75c5c4479f4961f1ffdb597c98aa4e4077e6813685025e8bdebf7598aa84e859
requests-oauthlib==1.3.0 \
--hash=sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d \
--hash=sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a \
--hash=sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc
oauthlib==3.1.0 \
--hash=sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889 \
--hash=sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea
requests==2.25.1 \
--hash=sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804 \
--hash=sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e
voluptuous==0.12.1 \
--hash=sha256:663572419281ddfaf4b4197fd4942d181630120fb39b333e3adad70aeb56444b \
--hash=sha256:8ace33fcf9e6b1f59406bfaf6b8ec7bcc44266a9f29080b4deb4fe6ff2492386
urllib3==1.26.3 \
--hash=sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80 \
--hash=sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73
chardet==4.0.0 \
--hash=sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa \
--hash=sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5
certifi==2020.12.5 \
--hash=sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c \
--hash=sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830
launchpadlib==1.10.13 \
--hash=sha256:5804d68ec93247194449d17d187e949086da0a4d044f12155fad269ef8515435 \
--hash=sha256:f61a591aff60a9315da09131eeb000bfb8287b788d1899a161e727ca22b1989f
httplib2==0.19.0 \
--hash=sha256:749c32603f9bf16c1277f59531d502e8f1c2ca19901ae653b49c4ed698f0820e \
--hash=sha256:e0d428dad43c72dbce7d163b7753ffc7a39c097e6788ef10f4198db69b92f08e
keyring==23.0.0 \
--hash=sha256:237ff44888ba9b3918a7dcb55c8f1db909c95b6f071bfb46c6918f33f453a68a \
--hash=sha256:29f407fd5509c014a6086f17338c70215c8d1ab42d5d49e0254273bc0a64bbfc
lazr.uri==1.0.5 \
--hash=sha256:f36e7e40d5f8f2cf20ff2c81784a14a546e6c19c216d40a6617ebe0c96c92c49 \
--hash=sha256:71f2faf04b148cf63d78da08ee5d8d6a7a7dbda8c9016b389a16f790d080c06f
six==1.15.0 \
--hash=sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259 \
--hash=sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced
testresources==2.0.1 \
--hash=sha256:67a361c3a2412231963b91ab04192209aa91a1aa052f0ab87245dbea889d1282 \
--hash=sha256:ee9d1982154a1e212d4e4bac6b610800bfb558e4fb853572a827bc14a96e4417
wadllib==1.3.5 \
--hash=sha256:84fecbaec2fef5ae2d7717a8115d271f18c6b5441eac861c58be8ca57f63c1d3 \
--hash=sha256:67d3102b40eefdd6c3007cfbcc4c07f6948fec0666ba5c17d703eab21f054692
lazr.restfulclient==0.14.3 \
--hash=sha256:9f28bbb7c00374159376bd4ce36b4dacde7c6b86a0af625aa5e3ae214651a690 \
--hash=sha256:2320e6d132c9a5148895e85be03274bc9305e4605439b03541ee3a618e00fb94
pyparsing==2.4.7 \
--hash=sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1 \
--hash=sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b
jeepney==0.6.0 \
--hash=sha256:7d59b6622675ca9e993a6bd38de845051d315f8b0c72cca3aef733a20b648657 \
--hash=sha256:aec56c0eb1691a841795111e184e13cad504f7703b9a64f63020816afa79a8ae
importlib-metadata==3.7.0 \
--hash=sha256:24499ffde1b80be08284100393955842be4a59c7c16bbf2738aad0e464a8e0aa \
--hash=sha256:c6af5dbf1126cd959c4a8d8efd61d4d3c83bddb0459a17e554284a077574b614
pbr==5.5.1 \
--hash=sha256:5fad80b613c402d5b7df7bd84812548b2a61e9977387a80a5fc5c396492b13c9 \
--hash=sha256:b236cde0ac9a6aedd5e3c34517b423cd4fd97ef723849da6b0d2231142d89c00
SecretStorage==3.3.1 \
--hash=sha256:422d82c36172d88d6a0ed5afdec956514b189ddbfb72fefab0c8a1cee4eaf71f \
--hash=sha256:fd666c51a6bf200643495a04abb261f83229dcb6fd8472ec393df7ffc8b6f195
distro==1.5.0 \
--hash=sha256:0e58756ae38fbd8fc3020d54badb8eae17c5b9dcbed388b17bb55b8a5928df92 \
--hash=sha256:df74eed763e18d10d0da624258524ae80486432cd17392d9c3d96f5e83cd2799
zipp==3.4.1 \
--hash=sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76 \
--hash=sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098
cffi==1.14.5 \
--hash=sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813 \
--hash=sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06 \
--hash=sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea \
--hash=sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee \
--hash=sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396 \
--hash=sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73 \
--hash=sha256:29314480e958fd8aab22e4a58b355b629c59bf5f2ac2492b61e3dc06d8c7a315 \
--hash=sha256:34eff4b97f3d982fb93e2831e6750127d1355a923ebaeeb565407b3d2f8d41a1 \
--hash=sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49 \
--hash=sha256:3d3dd4c9e559eb172ecf00a2a7517e97d1e96de2a5e610bd9b68cea3925b4892 \
--hash=sha256:43e0b9d9e2c9e5d152946b9c5fe062c151614b262fda2e7b201204de0b99e482 \
--hash=sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058 \
--hash=sha256:51182f8927c5af975fece87b1b369f722c570fe169f9880764b1ee3bca8347b5 \
--hash=sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53 \
--hash=sha256:5de7970188bb46b7bf9858eb6890aad302577a5f6f75091fd7cdd3ef13ef3045 \
--hash=sha256:65fa59693c62cf06e45ddbb822165394a288edce9e276647f0046e1ec26920f3 \
--hash=sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5 \
--hash=sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e \
--hash=sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c \
--hash=sha256:72d8d3ef52c208ee1c7b2e341f7d71c6fd3157138abf1a95166e6165dd5d4369 \
--hash=sha256:8ae6299f6c68de06f136f1f9e69458eae58f1dacf10af5c17353eae03aa0d827 \
--hash=sha256:8b198cec6c72df5289c05b05b8b0969819783f9418e0409865dac47288d2a053 \
--hash=sha256:99cd03ae7988a93dd00bcd9d0b75e1f6c426063d6f03d2f90b89e29b25b82dfa \
--hash=sha256:9cf8022fb8d07a97c178b02327b284521c7708d7c71a9c9c355c178ac4bbd3d4 \
--hash=sha256:9de2e279153a443c656f2defd67769e6d1e4163952b3c622dcea5b08a6405322 \
--hash=sha256:9e93e79c2551ff263400e1e4be085a1210e12073a31c2011dbbda14bda0c6132 \
--hash=sha256:9ff227395193126d82e60319a673a037d5de84633f11279e336f9c0f189ecc62 \
--hash=sha256:a465da611f6fa124963b91bf432d960a555563efe4ed1cc403ba5077b15370aa \
--hash=sha256:ad17025d226ee5beec591b52800c11680fca3df50b8b29fe51d882576e039ee0 \
--hash=sha256:afb29c1ba2e5a3736f1c301d9d0abe3ec8b86957d04ddfa9d7a6a42b9367e396 \
--hash=sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e \
--hash=sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991 \
--hash=sha256:cbde590d4faaa07c72bf979734738f328d239913ba3e043b1e98fe9a39f8b2b6 \
--hash=sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1 \
--hash=sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406 \
--hash=sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d \
--hash=sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c \
--hash=sha256:9338beed13d880320450d95c9e07ccf839faa3ea7b75d788f4ed46d845044a71
pycparser==2.20 \
--hash=sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0 \
--hash=sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705
idna==2.5 \
--hash=sha256:3cb5ce08046c4e3a560fc02f138d0ac63e00f8ce5901a56b32ec8b7994082aab \
--hash=sha256:cc19709fd6d0cbfed39ea875d29ba6d4e22c0cebc510a76d6302a28385e8bb70
typing-extensions==3.7.4.3 \
--hash=sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918 \
--hash=sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c \
--hash=sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f
cryptography==3.3 \
--hash=sha256:1366e6fff96bb1d320e3ef3c531b0428cb780c517b6059ffe8820e2a30bf5858 \
--hash=sha256:2c28e69e8c2620869425420e466b224d351d5dc242d3e3293062cffa7fcdd276 \
--hash=sha256:402273e7f78e01f5c42452acef56bd52fa73fa0f312e4160db7ad29bbc90335d \
--hash=sha256:41892759f13c7dfc329573cabd3a513f1e7b5d309ca55c931ffefc3b2f304899 \
--hash=sha256:4935d0603b118dc036c477917e5e8a020f7309bc11c363a4d572407dcbd53c80 \
--hash=sha256:55574bd84ff551fd6f7617e5eeda0b2d129f84788340ab57904f0c7f13f8f149 \
--hash=sha256:651eff09297e4518287f711b4e28523567cbde7beaa794d06d0b35ac9adc1172 \
--hash=sha256:66f41aa7642f97d35976750a2c0a6d5915733ba6c9ca7a0f327e56f037c1236e \
--hash=sha256:9518da854136181407d946b971dd27a0e941d5d07f87f87a44c598b3da7a77d2 \
--hash=sha256:ad8dc319f876273b474a59797344d5986beaaaf18f7cc0ab9255155da057d979 \
--hash=sha256:d9f1e520f2ee08c5a88e1ae0b31159bdb13da40a486bea3e9f7d338564850ea6 \
--hash=sha256:ee0084f6d62f083316b08111b122dc4fbe16e534059b92b5ddc3d73dc52dd39c \
--hash=sha256:f43d6e72e3cdf983b5e5e65938c0519ce4eeb42b6af766f714b1414ae7b9f8ef \
--hash=sha256:f95ca692fafea80f1815bbfecf57c6833c0b21432d17026a077341debee76e79 \
--hash=sha256:19c2cbff0434b4e345d08737a7c706d0ba3c2aeecb89936525481e866fd71cea

View File

@ -33,6 +33,7 @@ PLUGIN_DIRS = \
lifx \
mailnotification \
mqttclient \
neatobotvac \
nanoleaf \
netatmo \
networkdetector \