Skip to content

Commit

Permalink
Implement e-mail/password login flow so users don't need to scrape th…
Browse files Browse the repository at this point in the history
…eir API token (#7)
  • Loading branch information
dalinicus authored Aug 24, 2023
1 parent ca3ec02 commit ea370ba
Show file tree
Hide file tree
Showing 9 changed files with 106 additions and 78 deletions.
13 changes: 0 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,3 @@ Follow [this guide](https://hacs.xyz/docs/faq/custom_repositories/) to add this

### Manual Installation
Copy `custom_components/acinfinity` into your Home Assistant `$HA_HOME/config` directory, then restart Home Assistant

## Configuration

Once installed, install the integration as normal. The only configuration you'll need to provide is your account's API key.

### Obtaining your API Key
In order to obtain your API key, you'll need to intercept traffic from the AC Infinity app. I recommend downloading Telerek's Fiddler and starting a free trial. You can follow this guide to proxy your phone's internet traffic (The guide is for iOS, but the fiddler setup would be the same for android)

https://www.telerik.com/blogs/how-to-capture-ios-traffic-with-fiddler

Once you have your phone connected to fiddler, open the AC Infinity app (make sure you're already logged in). Look for a request to www.acinfinityserver.com, open up the request headers, and find a value labeled "token". This is your API token.

![Screen Shot](/images/fiddler.png)
8 changes: 5 additions & 3 deletions custom_components/ac_infinity/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import logging

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant

from .acinfinity import ACInfinity
Expand All @@ -18,8 +18,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

hass.data.setdefault(DOMAIN, {})

apiKey = entry.data[CONF_API_KEY]
hass.data[DOMAIN][entry.entry_id] = ACInfinity(apiKey)
ac_infinity = ACInfinity(entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD])
await ac_infinity.update_data()

hass.data[DOMAIN][entry.entry_id] = ac_infinity

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

Expand Down
51 changes: 6 additions & 45 deletions custom_components/ac_infinity/acinfinity.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
from datetime import timedelta

import aiohttp
import async_timeout
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.update_coordinator import UpdateFailed
from homeassistant.util import Throttle

from .client import ACInfinityClient
from .const import (
DEVICE_LABEL,
DEVICE_MAC_ADDR,
Expand All @@ -24,13 +22,16 @@
class ACInfinity:
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5)

def __init__(self, userId) -> None:
self._client = ACInfinityClient(userId)
def __init__(self, email: str, password: str) -> None:
self._client = ACInfinityClient(email, password)
self._data: dict = {}

@Throttle(MIN_TIME_BETWEEN_UPDATES)
async def update_data(self):
try:
if not self._client.is_logged_in():
await self._client.login()

devices = {}
for device in await self._client.get_all_device_info():
macAddr = device["devMacAddr"]
Expand Down Expand Up @@ -79,43 +80,3 @@ def get_sensor_data(self, macAddr: str, sensorKey: str):
if macAddr in self._data:
return self._data[macAddr][sensorKey]
return None


class ACInfinityClient:
HOST = "http://www.acinfinityserver.com"
GET_DEVICE_INFO_LIST_ALL = "/api/user/devInfoListAll"

def __init__(self, userId) -> None:
self._userId = userId
self._headers = {
"User-Agent": "ACController/1.8.2 (com.acinfinity.humiture; build:489; iOS 16.5.1) Alamofire/5.4.4",
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
"token": userId,
}

async def get_all_device_info(self):
json = await self.__post(
self.GET_DEVICE_INFO_LIST_ALL, f"userId={self._userId}"
)
return json["data"]

async def __post(self, path, post_data):
async with async_timeout.timeout(10), aiohttp.ClientSession(
raise_for_status=False, headers=self._headers
) as session, session.post(f"{self.HOST}/{path}", data=post_data) as response:
if response.status != 200:
raise CannotConnect

json = await response.json()
if json["code"] != 200:
raise InvalidAuth

return json


class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""


class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth."""
70 changes: 70 additions & 0 deletions custom_components/ac_infinity/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import aiohttp
import async_timeout
from homeassistant.exceptions import HomeAssistantError

HOST = "http://www.acinfinityserver.com"

API_URL_LOGIN = "/api/user/appUserLogin"
API_URL_GET_DEVICE_INFO_LIST_ALL = "/api/user/devInfoListAll"


class ACInfinityClient:
def __init__(self, email: str, password: str) -> None:
self._email = email
self._password = password

self._user_id: (str | None) = None

async def login(self):
headers = self.__create_headers(use_auth_token=False)
response = await self.__post(
API_URL_LOGIN,
{"appEmail": self._email, "appPasswordl": self._password},
headers,
)
self._user_id = response["data"]["appId"]

def is_logged_in(self):
return True if self._user_id else False

async def get_all_device_info(self):
if not self.is_logged_in():
raise ACInfinityClientCannotConnect("Aerogarden client is not logged in.")

headers = self.__create_headers(use_auth_token=True)
json = await self.__post(
API_URL_GET_DEVICE_INFO_LIST_ALL, {"userId": self._user_id}, headers
)
return json["data"]

async def __post(self, path, post_data, headers):
async with async_timeout.timeout(10), aiohttp.ClientSession(
raise_for_status=False, headers=headers
) as session, session.post(f"{HOST}/{path}", data=post_data) as response:
if response.status != 200:
raise ACInfinityClientCannotConnect

json = await response.json()
if json["code"] != 200:
raise ACInfinityClientInvalidAuth

return json

def __create_headers(self, use_auth_token: bool) -> dict:
headers: dict = {
"User-Agent": "ACController/1.8.2 (com.acinfinity.humiture; build:489; iOS 16.5.1) Alamofire/5.4.4",
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
}

if use_auth_token:
headers["token"] = self._user_id

return headers


class ACInfinityClientCannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""


class ACInfinityClientInvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth."""
30 changes: 18 additions & 12 deletions custom_components/ac_infinity/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,22 @@

import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_API_TOKEN
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.data_entry_flow import FlowResult

from .acinfinity import ACInfinityClient, CannotConnect, InvalidAuth
from .client import (
ACInfinityClient,
ACInfinityClientCannotConnect,
ACInfinityClientInvalidAuth,
)
from .const import DOMAIN

_LOGGER = logging.getLogger(__name__)

DATA_SCHEMA = vol.Schema(
CONFIG_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_TOKEN): str,
vol.Required(CONF_EMAIL): str,
vol.Required(CONF_PASSWORD): str,
}
)

Expand All @@ -34,26 +39,27 @@ async def async_step_user(
errors: dict[str, str] = {}
if user_input is not None:
try:
apiKey = user_input[CONF_API_TOKEN]
client = ACInfinityClient(apiKey)
client = ACInfinityClient(
user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
)
await client.login()
_ = await client.get_all_device_info()

except CannotConnect:
except ACInfinityClientCannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
except ACInfinityClientInvalidAuth:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
truncatedId = apiKey[-6:]
await self.async_set_unique_id(f"acinfinity-{truncatedId}")
await self.async_set_unique_id(f"ac_infinity-{user_input[CONF_EMAIL]}")
self._abort_if_unique_id_configured()

return self.async_create_entry(
title=f"AC Infinity ({truncatedId})", data=user_input
title=f"AC Infinity ({user_input[CONF_EMAIL]})", data=user_input
)

return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
step_id="user", data_schema=CONFIG_SCHEMA, errors=errors
)
6 changes: 3 additions & 3 deletions custom_components/ac_infinity/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/dalinicus/ac-infinity-ha",
"documentation": "https://github.com/dalinicus/homeassistant-acinfinity",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/dalinicus/ac-infinity-ha/issues",
"issue_tracker": "https://github.com/dalinicus/homeassistant-acinfinity",
"requirements": [],
"version": "0.1.0"
"version": "1.0.0"
}
3 changes: 2 additions & 1 deletion custom_components/ac_infinity/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"step": {
"user": {
"data": {
"api_token": "[%key:common::config_flow::data::api_token%]"
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
}
}
},
Expand Down
3 changes: 2 additions & 1 deletion custom_components/ac_infinity/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"step": {
"user": {
"data": {
"api_token": "API Token"
"email": "Email",
"password": "Password"
}
}
}
Expand Down
Binary file removed images/fiddler.png
Binary file not shown.

0 comments on commit ea370ba

Please sign in to comment.