Skip to content

Commit

Permalink
Big repo-wide refactor! (#30)
Browse files Browse the repository at this point in the history
* large refactor to async and config flow

* adding tests

* added config tests

* add more tests

* fixes and debug logs
  • Loading branch information
jacobdonenfeld authored Jul 1, 2024
1 parent aa783a3 commit 66e9cd3
Show file tree
Hide file tree
Showing 19 changed files with 834 additions and 463 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
python-version: '3.12'
- name: Install flake8
run: pip install flake8 flake8-async flake8-warnings
- name: Run flake8
Expand All @@ -34,5 +34,5 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: '3.9'
python-version: '3.12'
- uses: pre-commit/action@v3.0.0
12 changes: 7 additions & 5 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,17 @@ jobs:
steps:
- name: Check out code
uses: "actions/checkout@v3"
- name: Set up Python 3.8
- name: Set up Python 3.12
uses: "actions/setup-python@v4"
with:
python-version: 3.8
- name: Prepare test env
run: bash tests/setup.sh
python-version: 3.12
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install pytest
python -m pip install -r requirements.test.txt
- name: Run tests
run: |
pip install pytest
pytest --cov=custom_components/aerogarden tests
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v3
Expand Down
28 changes: 27 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,22 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.5.0
hooks:
- id: ruff
args:
- --fix
- id: ruff-format
files: ^((homeassistant|pylint|script|tests)/.+)?[^/]+\.(py|pyi)$
- repo: https://github.com/codespell-project/codespell
rev: v2.3.0
hooks:
- id: codespell
args:
- --ignore-words-list=astroid,checkin,currenty,hass,iif,incomfort,lookin,nam,NotIn,pres,ser,ue
- --skip="./.*,*.csv,*.json,*.ambr"
- --quiet-level=2
exclude_types: [csv, json, html]
exclude: ^tests/fixtures/generated/|tests/components/.*/snapshots/
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
Expand All @@ -12,7 +30,15 @@ repos:
rev: 24.4.2
hooks:
- id: black
language_version: python3.9
language_version: python3.12
# - repo: https://github.com/pre-commit/mirrors-mypy
# rev: "v1.5.1"
# hooks:
# - id: mypy
# args:
# - --explicit-package-bases
# - --ignore-missing-imports
# - --check-untyped-defs
- repo: https://github.com/PyCQA/isort
rev: 5.13.2
hooks:
Expand Down
6 changes: 6 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"files.associations": {
"*.yaml": "home-assistant"
},
"git.ignoreLimitWarning": true
}
262 changes: 32 additions & 230 deletions custom_components/aerogarden/__init__.py
Original file line number Diff line number Diff line change
@@ -1,250 +1,52 @@
import base64
import json
import logging
import re
import urllib
from datetime import timedelta

import homeassistant.helpers.config_validation as cv
import requests
import voluptuous as vol
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.discovery import load_platform
from homeassistant.util import Throttle
from requests import RequestException

_LOGGER = logging.getLogger(__name__)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant

DOMAIN = "aerogarden"
DEFAULT_HOST = "https://app4.aerogarden.com"

MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30)

CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
}
)
},
extra=vol.ALLOW_EXTRA,
)


# cleanPassword assumes there is one or zero instances of password in the text
# replaces the password with <password>
def cleanPassword(text, password):
passwordLen = len(password)
if passwordLen == 0:
return text
replaceText = "<password>"
for i in range(len(text) + 1 - passwordLen):
if text[i : (i + passwordLen)] == password:
restOfString = text[(i + passwordLen) :]
text = text[:i] + replaceText + restOfString
break
return text


def postAndHandle(url, post_data, headers):
try:
r = requests.post(url, data=post_data, headers=headers)
except RequestException as ex:
_LOGGER.exception("Error communicating with aerogarden servers:\n %s", str(ex))
return False
from .api import AerogardenAPI
from .const import CONF_PASSWORD, CONF_USERNAME, DEFAULT_HOST, DOMAIN

try:
response = r.json()
except ValueError as ex:
# Remove password before printing
_LOGGER.exception(
"error: Could not marshall post request to json.\nexception:\n%s",
str(r),
ex,
)
return False
return response


class AerogardenAPI:
def __init__(self, username, password, host=None):
self._username = urllib.parse.quote(username)
self._password = urllib.parse.quote(password)
self._host = host
self._userid = None
self._error_msg = None
self._data = None

self._login_url = "/api/Admin/Login"
self._status_url = "/api/CustomData/QueryUserDevice"
self._update_url = "/api/Custom/UpdateDeviceConfig"

self._headers = {
"User-Agent": "HA-Aerogarden/0.1",
"Content-Type": "application/x-www-form-urlencoded",
}

self.login()

@property
def error(self):
return self._error_msg

def login(self):
post_data = "mail=" + self._username + "&userPwd=" + self._password
url = self._host + self._login_url

response = postAndHandle(url, post_data, self._headers)
_LOGGER.debug(
"Login URL: %s, post data: %s, headers: %s "
% (url, cleanPassword(str(post_data), self._password), self._headers)
)

if not response:
_LOGGER.exception("Issue logging into aerogarden servers.")
return False

userid = response["code"]
if userid > 0:
self._userid = str(userid)
else:
error_msg = "Login api call returned %s" % (response["code"])
self._error_msg = error_msg

_LOGGER.exception(error_msg)

def is_valid_login(self):
if self._userid:
return True
_LOGGER.debug("Could not find valid login")
return

def garden_name(self, macaddr):
multi_garden = self.garden_property(macaddr, "chooseGarden")
if not multi_garden:
return self.garden_property(macaddr, "plantedName")
multi_garden_label = "left" if multi_garden == 0 else "right"
return self.garden_property(macaddr, "plantedName") + "_" + multi_garden_label

def garden_property(self, macaddr, field):
if macaddr not in self._data:
return None

if field not in self._data[macaddr]:
return None

return self._data[macaddr].get(field, None)

def light_toggle(self, macaddr):
"""light_toggle:
Toggles between Bright, Dimmed, and Off.
I couldn't find any way to set a specific state, it just cycles between the three.
"""
if macaddr not in self._data:
_LOGGER.debug(
"light_toggle called for macaddr %s, on struct %s, but struct doesn't have addr",
vars(self),
)
return None

post_data = json.dumps(
{
"airGuid": macaddr,
"chooseGarden": self.garden_property(macaddr, "chooseGarden"),
"userID": self._userid,
"plantConfig": '{ "lightTemp" : %d }'
% (self.garden_property(macaddr, "lightTemp")),
# TODO: Light Temp may not matter, check.
}
)
url = self._host + self._update_url
_LOGGER.debug(f"Sending POST data to toggle light: {post_data}")

results = postAndHandle(url, post_data, self._headers)
if not results:
return False

if "code" in results:
if results["code"] == 1:
return True

self._error_msg = "Didn't get code 1 from update API call: %s" % (
results["msg"]
)
self.update(no_throttle=True)
_LOGGER = logging.getLogger(__name__)

PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LIGHT]

return False

@property
def gardens(self):
return self._data.keys()

@Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self):
data = {}
if not self.is_valid_login():
return

url = self._host + self._status_url
post_data = "userID=" + self._userid

garden_data = postAndHandle(url, post_data, self._headers)
if not garden_data:
return False

if "Message" in garden_data:
error_msg = "Couldn't get data for garden (correct macaddr?): %s" % (
garden_data["Message"]
)
self._error_msg = error_msg
_LOGGER.exception(error_msg)
return False

for garden in garden_data:
if "plantedName" in garden:
garden["plantedName"] = base64.b64decode(garden["plantedName"]).decode(
"utf-8"
)

# Seems to be for multigarden config, untested, adapted from
# https://github.com/JeremyKennedy/homeassistant-aerogarden/commit/5854477c35103d724b86490b90e286b5d74f6660
garden_id = garden.get("configID", None)
garden_mac = (
garden["airGuid"] + "-" + ("" if garden_id is None else str(garden_id))
)
data[garden_mac] = garden

_LOGGER.debug("Updating data {}".format(data))
self._data = data
return True


def setup(hass, config: dict):
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Setup the aerogarden platform"""

domain_config = config.get(DOMAIN)
"""Set up Aerogarden from a config entry."""
hass.data.setdefault(DOMAIN, {})

username = domain_config.get(CONF_USERNAME)
password = domain_config.get(CONF_PASSWORD)
username = entry.data[CONF_USERNAME]
password = entry.data[CONF_PASSWORD]

host = domain_config.get(CONF_HOST, DEFAULT_HOST)
# Use the username and password to set up aerogarden

ag = AerogardenAPI(username, password, host)
# If the setup is successful:
hass.data[DOMAIN][entry.entry_id] = {
"username": username,
"password": password,
}

ag = AerogardenAPI(hass, username, password, DEFAULT_HOST)
if not ag.is_valid_login():
_LOGGER.error("Invalid login: %s" % ag.error)
return
return False

ag.update()
_ = await ag.update()

# store the aerogarden API object into hass data system
hass.data[DOMAIN] = ag
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

load_platform(hass, "sensor", DOMAIN, {}, config)
load_platform(hass, "binary_sensor", DOMAIN, {}, config)
load_platform(hass, "light", DOMAIN, {}, config)
_LOGGER.debug("Done adding components.")

await hass.config_entries.async_forward_entry_setups(entry, ["sensor"])
return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
Loading

0 comments on commit 66e9cd3

Please sign in to comment.