Open johntdyer opened 2 years ago
this project looks interesting https://github.com/tekkamanendless/iaqualink
I don't have one of those so unless this changes, I won't be working on adding support.
Happy to review patches though!
Hi Guys,
Glad I found this! I have the code, could you please help to put it in correctly?
import json
import datetime
from homeassistant.helpers.entity import Entity
from datetime import timedelta
from homeassistant.util import Throttle
URL_LOGIN="https://prod.zodiac-io.com/users/v1/login"
URL_GET_DEVICES="https://r-api.iaqualink.net/devices.json"
URL_GET_DEVICE_STATUS="https://prod.zodiac-io.com/devices/v1/"
SCAN_INTERVAL = timedelta(seconds=30)
def setup_platform(hass, config, add_devices, discovery_info=None):
"""Setup the sensor platform."""
add_devices([iaqualinkRobotSensor(config)])
class iaqualinkRobotSensor(Entity):
def __init__(self, config):
self._name = config.get('name')
self._username = config.get('username')
self._password = config.get('password')
self._api_key = config.get('api_key')
self._headers = {"Content-Type": "application/json; charset=utf-8", "Connection": "keep-alive", "Accept": "*/*" }
self._attributes = {}
# Apply throttling to methods using configured interval
self.update = Throttle(SCAN_INTERVAL)(self._update)
self._update()
@property
def name(self):
return self._name
@property
def username(self):
return self._username
@property
def frist_name(self):
return self._first_name
@property
def last_name(self):
return self._last_name
@property
def serial_number(self):
return self._serial_number
@property
def state(self):
"""Return the state of the sensor."""
return self._state
@property
def device_state_attributes(self):
"""Return device specific state attributes."""
return self._attributes
@property
def extra_state_attributes(self):
"""Return entity specific state attributes."""
return self._attributes
def _update(self):
self._attributes['username'] = self._username
url = URL_LOGIN
data = {"apikey": self._api_key, "email": self._username, "password": self._password}
data = json.dumps(data)
response = requests.post(url, headers = self._headers, data = data)
self._state = response.status_code
if response.status_code == 200:
data = response.json()
self._first_name = data["first_name"]
self._last_name = data["last_name"]
self._attributes['first_name'] = self._first_name
self._attributes['last_name'] = self._last_name
self._authentication_token = data["authentication_token"]
self._id = data["id"]
self._id_token = data["userPoolOAuth"]["IdToken"]
url = URL_GET_DEVICES
data = None
response = requests.get(url, headers = self._headers, params = {"authentication_token":self._authentication_token,"user_id":self._id,"api_key":self._api_key})
if response.status_code == 200:
data = response.json()
self._serial_number = data[0]["serial_number"] #assumption only 1 robot for now
self._attributes['serial_number'] = self._serial_number
url = URL_GET_DEVICE_STATUS + self._serial_number + "/shadow"
data = None
self._headers = {"Content-Type": "application/json; charset=utf-8", "Connection": "keep-alive", "Accept": "*/*", "Authorization" : self._id_token}
response = requests.get(url, headers = self._headers)
if response.status_code == 200:
data = response.json()
self._state = data["state"]["reported"]["aws"]["status"]
self._last_online = datetime_obj = datetime.datetime.fromtimestamp((data["state"]["reported"]["aws"]["timestamp"]/1000)) #Convert Epoch To Unix
self._attributes['last_online'] = self._last_online
self._temperature = data["state"]["reported"]["equipment"]["robot"]["sensors"]["sns_1"]["val"]
self._attributes['temperature'] = self._temperature
self._pressure = data["state"]["reported"]["equipment"]["robot"]["sensors"]["sns_2"]["state"]
self._attributes['pressure'] = self._pressure
self._total_hours = data["state"]["reported"]["equipment"]["robot"]["totalHours"]
self._attributes['total_hours'] = self._total_hours
self._error_state = data["state"]["reported"]["equipment"]["robot"]["errorState"]
self._attributes['error_state'] = self._error_state
self._lift_control = data["state"]["reported"]["equipment"]["robot"]["liftControl"]
self._attributes['lift_control'] = self._lift_control
self._equipment_id = data["state"]["reported"]["equipment"]["robot"]["equipmentId"]
self._attributes['equipment_id'] = self._equipment_id
self._cycle_start_time = datetime_obj = datetime.datetime.fromtimestamp(data["state"]["reported"]["equipment"]["robot"]["cycleStartTime"])
self._attributes['cycle_start_time'] = self._cycle_start_time
else:
self._state = response.text[:250]
else:
self._state = response.text[:250]
Config File:
- platform: iaqualink
username:
password:
api_key: EOOEMOW4YR6QNB07
name: Bobby
There's some commonalities with the exo code (namely the /shadow part). I don't have the equipment which makes testing of an unpublished API impossible.
The exo code is in a separate "exo" branch and hasn't been merged since testing by the community has been limited and the API broke at some point (I think it's working again?).
@galletn how about we create a new integration just for the robots ? I would be happy to help
hello! yes, that would be fine, I have a polaris and could help in whatever is needed.
I have a quite busy period at work, but let me see what I can do. I already have the separate one, but I did not publish it yet.
If you already want to test the basics, this should read out your robot.
add the extracted folder to your custom components folder. entry in the config File: sensor:
thanks @galletn! this part sensor:
directly in configuration.yaml?
gives the following error
Registrar: homeassistant.setup Source: setup.py:251 First Occurred 14:24:48 (1 occurrences) Last logged 14:24:48 Error in iaqualink custom integration setup: No setup or config entry setup function defined.
I have already managed to make it appear as an entity within home assistant. the robot's data appears. but there is this error message. {'message': "Authorization header requires 'Credential' parameter. Authorization header requires 'Signature' parameter. Authorization header requires 'SignedHeaders' parameter. Authorization header requires existence of either a 'X-Amz-Date' or a 'Date' header.
I'll try to send you my postman collection to see which of the URL's it is that gives you this error, for me it still works, so I think depending on the type of robot another URL might be needed to get some of the details. of your robot. You know how to work with postman?
thank you @galletn yes, I can manage with postman. I see that the app makes POST calls passing a signature, timestamp and user_id field. The url is https://r-api.iaqualink.net/v2/devices/{serialnumber}/control.json and adds the mentioned fields.
Sorry it took some time, but please find the postman collection included. I put $ where you should change some values. Iaqualink.zip
Hey guys I've been following along and am keen to help where I can - perhaps to test as I'm an amateur at development. I have a EX4000 iQ Robotic cleaner and although I cant seem to get the sensor working in HA, I have so far been able to (using postman) log in and get my device. For get device status, features, site etc , I can't seem to figure out what I'm supposed to use as the Authorization: $AuthKey. I assume its in the login response?? Your thoughts and guidance is appreciated.
Exactly! it asks for some kind of Authkey. Using Mitm I see that it sends a GET request (https://r-api.iaqualink.net/v2/devices.json?user_id=xxxxxx&signature=xxxxxxxxxxxxxxxxxxxxxxxxxx×tamp=1683565854) and passes three values. User_id, signature and Timestamp. These last two I think are calculated somehow?
I just got past this stage and can successfully run get device status. So now I can run, login, get devices, get device status, get device features and get device site. I still cant get the sensor to show up in HA though :(
@bertocea85 I used "RefreshToken" as the $AuthKey I received on login. I also changed the URL in GetDeviceFeatures to be; https://prod.zodiac-io.com/devices/v2/XXXMYSERIALNUMBERXXX/features
Where XXXMYSERIALNUMBERXXX=my robot serial number reported from the login stage. I mention this because the original postman collection indicated $deviceID$ which is an entirely different number. How did you get the sensor to show up in HA?
I added the extracted folder (iaqualink.zip) to my custom components folder. I added the following to my config.yaml:
- platform: iaqualink
username: !secret iaqualink_username
password: !secret iaqualink_password
api_key: EOOEMOW4YR6QNB07
name: Fred
What have I missed?
I understand that the iaqualink folder is inside custom_components (custom_components/iaqualink/xx.py). I have added in sensors.yaml
And in configuration.yaml at the end of the file: sensor: !include sensors.yaml Once restarted, in the part of Settings, Devices and services, in Entities, you should see the name that you have put as a sensor.
I understand that the iaqualink folder is inside custom_components (custom_components/iaqualink/xx.py). I have added in sensors.yaml
- platform: iaqualink username: !secret iaqualink_username password: !secret iaqualink_password api_key: EOOEMOW4YR6QNB07 name: XXX
And in configuration.yaml at the end of the file: sensor: !include sensors.yaml Once restarted, in the part of Settings, Devices and services, in Entities, you should see the name that you have put as a sensor.
I see it but other than the number 500 (state) its empty. Today for some reason I get an empty response when I run GetDevices in postman
Ok - I figured it out. I had to modify sensor.py. line 110 is trying to map
self._temperature = data["state"]["reported"]["equipment"]["robot"]["sensors"]["sns_1"]["val"]
but it should be (in my case)
self._temperature = data["state"]["reported"]["equipment"]["robot"]["sensors"]["sns_1"]["state"]
so now I get this
and here is where I got to
I added a few more attributed such as running state and canister empty/ok
and here is where I got to
I added a few more attributed such as running state and canister empty/ok
How did you get the canister and running state? thanks again all of you.
I modified sensor.py in the section where the response from the API call is being mapped into attributes (I think) here
self._headers = {"Content-Type": "application/json; charset=utf-8", "Connection": "keep-alive", "Accept": "*/*", ``"Authorization" : self._id_token}
response = requests.get(url, headers = self._headers)
if response.status_code == 200:
and added the following lines;
self._canister = data["state"]["reported"]["equipment"]["robot"]["canister"]
self._attributes['canister'] = self._canister
self._running = data["state"]["reported"]["equipment"]["robot"]["state"]
self._attributes['running'] = self._running
Then I created template sensors in the config.yaml to map 0/1 to empty/full or running/Idle
Then in the card, I created badges and animations to show when running/idle or when the canister needs emptying.
Couldnt have done it without the code from @galletn. Its a shame I don't know how to help make this an official component of the integration to help make it easier for others who might like to add this and save the many hours/days to figure it out.
The next part id like to start working on is executing commands from the card like lift or start cycle or change cycle but I think I need either a little help with figuring out how do that or need many more hours to study how to reverse engineer the ios app - I only dabble :)
https://github.com/flz/iaqualink-py/assets/91765182/950c3478-9fc8-4a1b-b9dd-6977317f75d9
I modified sensor.py in the section where the response from the API call is being mapped into attributes (I think) here
self._headers = {"Content-Type": "application/json; charset=utf-8", "Connection": "keep-alive", "Accept": "*/*",
"Authorization" : self._id_token}`
response = requests.get(url, headers = self._headers)`if response.status_code == 200:
and added the following lines;
self._canister = data["state"]["reported"]["equipment"]["robot"]["canister"]
self._attributes['canister'] = self._canister
self._running = data["state"]["reported"]["equipment"]["robot"]["state"]
self._attributes['running'] = self._running
Then I created template sensors in the config.yaml to map 0/1 to empty/full or running/Idle
Then in the card, I created badges and animations to show when running/idle or when the canister needs emptying.
Couldnt have done it without the code from @galletn. Its a shame I don't know how to help make this an official component of the integration to help make it easier for others who might like to add this and save the many hours/days to figure it out.
The next part id like to start working on is executing commands from the card like lift or start cycle or change cycle but I think I need either a little help with figuring out how do that or need many more hours to study how to reverse engineer the ios app - I only dabble :)
poolrobot.1.HB_proc.mp4
Perhaps I can investigate, I am a complete noob. Can you tell me what I should download to review the API or code? How can I look at it. Maybe can tinker and learn.
I would start with this : https://github.com/tekkamanendless/iaqualink . The part that I think will help is this : https://github.com/tekkamanendless/iaqualink#development. Once we understand how to structure the post and manually send commands via postman then we can progress.
I'll have a look to make a separate integration out of this somewhere this week, then we can commit and do whatever we need to make it work for all of us. @ppastur indeed, that is what should be done, but need to find the time to install the proxy stuff, I already have a proxy running so I think that just wiresharking on that one might work too. Keep you posted!
@galletn , I setup mitmproxy and managed to capture turning the robot on and off. It looks like its all websocket traffic which means Im a little out of my depth. I am however happy to work with you to provide you with the capture and also to test if that is helpful?
Here is the redacted websocket messages. Ive replaced userID, robot serial number and other serial/part numbers
SEND-->
{
"action": "subscribe",
"namespace": "authorization",
"payload": {
"userId": $USERID$
},
"service": "Authorization",
"target": "$ROBOTSERIALNUMBER$",
"version": 1
}
RECIEVE <--
{
"namespace": "authorization",
"payload": {
"data": [
{
"accelero": [
-85,
-31,
1049
],
"angleRotation": 183,
"cleanerPos": 1,
"cumulAngleRotation": 502,
"cycleId": 1684646687,
"floorBlockageCnt": 0,
"gyro": [
1,
-2,
1
],
"iPump": 2442,
"iTract1": 99,
"iTract2": 109,
"lastMoveLength": 13,
"loopCnt": 200,
"magneto": [
0,
0,
0
],
"movementId": 1,
"patternId": 0,
"pwmPump": 100,
"pwmTract1": 80,
"pwmTract2": 80,
"stairsCnt": 1764,
"tiltCnt": 32692,
"timestamp": 1684650932,
"totalHours": 348,
"vEbox": 28,
"vRobot": 28,
"vSensor": 11,
"wallCnt": 1660
}
],
"ota": {
"status": "UP_TO_DATE"
},
"robot": {
"metadata": {
"desired": {
"equipment": {
"robot": {}
}
},
"reported": {
"aws": {
"session_id": {
"timestamp": 1684751732
},
"status": {
"timestamp": 1684751732
},
"timestamp": {
"timestamp": 1684751732
}
},
"dt": {
"timestamp": 1681926072
},
"eboxData": {
"completeCleanerPn": {
"timestamp": 1681926072
},
"completeCleanerSn": {
"timestamp": 1681926072
},
"controlBoxPn": {
"timestamp": 1681926072
},
"controlBoxSn": {
"timestamp": 1681926072
},
"motorBlockSn": {
"timestamp": 1681926072
},
"powerSupplySn": {
"timestamp": 1681926072
},
"sensorBlockSn": {
"timestamp": 1681926072
}
},
"equipment": {
"robot": {
"canister": {
"timestamp": 1684571511
},
"customCyc": {
"timestamp": 1681926072
},
"customIntensity": {
"timestamp": 1681926072
},
"cycleStartTime": {
"timestamp": 1684752278
},
"durations": {
"customTim": {
"timestamp": 1681926072
},
"deepTim": {
"timestamp": 1681926072
},
"firstSmartTim": {
"timestamp": 1681926072
},
"quickTim": {
"timestamp": 1681926072
},
"smartTim": {
"timestamp": 1681926072
},
"waterTim": {
"timestamp": 1681926072
}
},
"equipmentId": {
"timestamp": 1681926073
},
"errorCode": {
"timestamp": 1681926072
},
"errorState": {
"timestamp": 1681926072
},
"firstSmrtFlag": {
"timestamp": 1681926072
},
"liftControl": {
"timestamp": 1681926072
},
"logger": {
"timestamp": 1681926072
},
"prCyc": {
"timestamp": 1684752605
},
"repeat": {
"timestamp": 1681926072
},
"rmt_ctrl": {
"timestamp": 1681926072
},
"scanTimeDuration": {
"timestamp": 1681926072
},
"schConf0Enable": {
"timestamp": 1681926072
},
"schConf0Hour": {
"timestamp": 1681926072
},
"schConf0Min": {
"timestamp": 1681926072
},
"schConf0Prt": {
"timestamp": 1681926072
},
"schConf0WDay": {
"timestamp": 1681926072
},
"schConf1Enable": {
"timestamp": 1681926072
},
"schConf1Hour": {
"timestamp": 1681926072
},
"schConf1Min": {
"timestamp": 1681926072
},
"schConf1Prt": {
"timestamp": 1681926072
},
"schConf1WDay": {
"timestamp": 1681926072
},
"schConf2Enable": {
"timestamp": 1681926072
},
"schConf2Hour": {
"timestamp": 1681926072
},
"schConf2Min": {
"timestamp": 1681926072
},
"schConf2Prt": {
"timestamp": 1681926072
},
"schConf2WDay": {
"timestamp": 1681926072
},
"schConf3Enable": {
"timestamp": 1681926072
},
"schConf3Hour": {
"timestamp": 1681926072
},
"schConf3Min": {
"timestamp": 1681926072
},
"schConf3Prt": {
"timestamp": 1681926072
},
"schConf3WDay": {
"timestamp": 1681926072
},
"schConf4Enable": {
"timestamp": 1681926072
},
"schConf4Hour": {
"timestamp": 1681926072
},
"schConf4Min": {
"timestamp": 1681926072
},
"schConf4Prt": {
"timestamp": 1681926072
},
"schConf4WDay": {
"timestamp": 1681926072
},
"schConf5Enable": {
"timestamp": 1681926072
},
"schConf5Hour": {
"timestamp": 1681926072
},
"schConf5Min": {
"timestamp": 1681926072
},
"schConf5Prt": {
"timestamp": 1681926072
},
"schConf5WDay": {
"timestamp": 1681926072
},
"schConf6Enable": {
"timestamp": 1681926072
},
"schConf6Hour": {
"timestamp": 1681926072
},
"schConf6Min": {
"timestamp": 1681926072
},
"schConf6Prt": {
"timestamp": 1681926072
},
"schConf6WDay": {
"timestamp": 1681926072
},
"sensors": {
"sns_1": {
"state": {
"timestamp": 1684752306
},
"type": {
"timestamp": 1681926072
}
},
"sns_2": {
"state": {
"timestamp": 1684752306
},
"type": {
"timestamp": 1681926072
}
},
"sns_3": {
"state": {
"timestamp": 1681926072
},
"type": {
"timestamp": 1681926072
}
}
},
"state": {
"timestamp": 1684752605
},
"stepper": {
"timestamp": 1681926072
},
"stepperAdjTime": {
"timestamp": 1681926072
},
"totalHours": {
"timestamp": 1684752280
},
"vr": {
"timestamp": 1681926073
}
}
},
"jobId": {
"timestamp": 1651189088
},
"sn": {
"timestamp": 1681926072
},
"vr": {
"timestamp": 1681926072
}
}
},
"state": {
"desired": {
"equipment": {
"robot": {}
}
},
"reported": {
"aws": {
"session_id": "7d0137d2-06bd-45f4-8be9-e3f503886d65",
"status": "connected",
"timestamp": 1684751732835
},
"dt": "vr",
"eboxData": {
"completeCleanerPn": "${some-part-number}",
"completeCleanerSn": "${some-serial-number}",
"controlBoxPn": "${some-part-number}",
"controlBoxSn": "${some-serial-number}",
"motorBlockSn": "${some-serial-number}",
"powerSupplySn": "${some-serial-number}",
"sensorBlockSn": ""
},
"equipment": {
"robot": {
"canister": 0,
"customCyc": 1,
"customIntensity": 0,
"cycleStartTime": 1684752276,
"durations": {
"customTim": 150,
"deepTim": 165,
"firstSmartTim": 150,
"quickTim": 75,
"smartTim": 111,
"waterTim": 45
},
"equipmentId": "JY21002990",
"errorCode": 0,
"errorState": 0,
"firstSmrtFlag": 0,
"liftControl": 0,
"logger": 0,
"prCyc": 1,
"repeat": 0,
"rmt_ctrl": 0,
"scanTimeDuration": 30,
"schConf0Enable": 0,
"schConf0Hour": 0,
"schConf0Min": 0,
"schConf0Prt": 1,
"schConf0WDay": 0,
"schConf1Enable": 0,
"schConf1Hour": 0,
"schConf1Min": 0,
"schConf1Prt": 1,
"schConf1WDay": 1,
"schConf2Enable": 0,
"schConf2Hour": 0,
"schConf2Min": 0,
"schConf2Prt": 1,
"schConf2WDay": 2,
"schConf3Enable": 0,
"schConf3Hour": 0,
"schConf3Min": 0,
"schConf3Prt": 1,
"schConf3WDay": 3,
"schConf4Enable": 0,
"schConf4Hour": 0,
"schConf4Min": 0,
"schConf4Prt": 1,
"schConf4WDay": 4,
"schConf5Enable": 0,
"schConf5Hour": 0,
"schConf5Min": 0,
"schConf5Prt": 1,
"schConf5WDay": 5,
"schConf6Enable": 0,
"schConf6Hour": 0,
"schConf6Min": 0,
"schConf6Prt": 1,
"schConf6WDay": 6,
"sensors": {
"sns_1": {
"state": 0,
"type": "temperature"
},
"sns_2": {
"state": 0,
"type": "pressure"
},
"sns_3": {
"state": 0,
"type": "compass"
}
},
"state": 0,
"stepper": 0,
"stepperAdjTime": 15,
"totalHours": 349,
"vr": "V32E47"
}
},
"jobId": "Job_VR_V32E47_V32C47",
"sn": "$ROBOTSERIALNUMBER$",
"vr": "V32C47"
}
},
"timestamp": 1684752680,
"version": 138294
}
},
"service": "Authorization",
"target": "$ROBOTSERIALNUMBER$"
}
SEND --> (start clean cycle on the app)
{
"action": "setCleanerState",
"namespace": "vr",
"payload": {
"clientToken": "$USERID$|Z6dmjtJcsJ79ANUfe1rYoh|cQ4zyywG4B2NSlv8PMpnZo",
"state": {
"desired": {
"equipment": {
"robot": {
"state": 1
}
}
}
}
},
"service": "StateController",
"target": "$ROBOTSERIALNUMBER$",
"version": 1
}
notice state:1
RECIEVE <-- (a bunch of these)
{
"event": "StateReported",
"payload": {
"clientToken": "$USERID$|Z6dmjtJcsJ79ANUfe1rYoh|cQ4zyywG4B2NSlv8PMpnZo",
"metadata": {
"desired": {
"equipment": {
"robot": {
"state": {
"timestamp": 1684752688
}
}
}
}
},
"state": {
"desired": {
"equipment": {
"robot": {
"state": 1
}
}
}
},
"timestamp": 1684752688,
"version": 138295
},
"service": "StateStreamer",
"target": "$ROBOTSERIALNUMBER$",
"version": 1
}
SEND --> (stop clean cycle on the app)
{
"action": "setCleanerState",
"namespace": "vr",
"payload": {
"clientToken": "$USERID$|Z6dmjtJcsJ79ANUfe1rYoh|8RhttxQIeSlg1q1nPj0xoe",
"state": {
"desired": {
"equipment": {
"robot": {
"state": 0
}
}
}
}
},
"service": "StateController",
"target": "$ROBOTSERIALNUMBER$",
"version": 1
}
notice state: 0
RECIEVE <-- (a bunch of these)
{
"event": "StateReported",
"payload": {
"clientToken": "$USERID$|Z6dmjtJcsJ79ANUfe1rYoh|8RhttxQIeSlg1q1nPj0xoe",
"metadata": {
"desired": {
"equipment": {
"robot": {
"state": {
"timestamp": 1684752705
}
}
}
}
},
"state": {
"desired": {
"equipment": {
"robot": {
"state": 0
}
}
}
},
"timestamp": 1684752705,
"version": 138300
},
"service": "StateStreamer",
"target": "$ROBOTSERIALNUMBER$",
"version": 1
}
I guess we can move to https://github.com/galletn/iaqualink prepping it to get into HACS now
I guess we can move to https://github.com/galletn/iaqualink prepping it to get into HACS now
How can I help guys?
@ppastur @LOki61 please join the converstation here:
https://github.com/galletn/iaqualink/issues/1
@ppastur can you repost your proxy logs there? also did you capture the URL's that were triggered? can you also post the changes you made to the code to make it work for you? I'll have a look to make it generic for all robots.
@LOki61 you could help by sending your changes too, I'll also try to fit these in. Then we have a nice clean start for all. If you could also try the HACS install? For me it worked but ... better double check 👍
Would he awesome to support Jandy integrated pool vacuums, at the very least I would love to stop / start and see time to completion... Is the Jandy api documented anywhere?