Add Fresh Harvest integration scaffold

Implements the freshharvest.com two-step login handshake, config flow,
6-hour polling coordinator, and four delivery sensors. Portal page parsing
is not implemented; async_get_next_delivery raises until the account HTML
is mapped against a signed-in session.
This commit is contained in:
flan
2026-08-03 17:05:17 +00:00
commit eb50a9ecc1
12 changed files with 519 additions and 0 deletions
@@ -0,0 +1,40 @@
"""The Fresh Harvest integration."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .api import FreshHarvestClient
from .coordinator import FreshHarvestCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
type FreshHarvestConfigEntry = ConfigEntry[FreshHarvestCoordinator]
async def async_setup_entry(
hass: HomeAssistant, entry: FreshHarvestConfigEntry
) -> bool:
"""Set up Fresh Harvest from a config entry."""
# Dedicated session: the login tokens are bound to its cookie jar.
session = async_create_clientsession(hass)
client = FreshHarvestClient(
session, entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD]
)
coordinator = FreshHarvestCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: FreshHarvestConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+144
View File
@@ -0,0 +1,144 @@
"""HTTP client for the freshharvest.com customer portal.
The site is server-rendered (no JSON API), so this client drives the same form
flow a browser does and parses HTML out the other side.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
from datetime import date
import aiohttp
from yarl import URL
_LOGGER = logging.getLogger(__name__)
BASE = URL("https://freshharvest.com")
LOGIN_FORM = "/s/popup/login"
LOGIN_SUBMIT = "/s/submit/login"
# Browser UA: the portal serves a reduced/blocked page to obvious scripts.
USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0 Safari/537.36"
)
# Hidden anti-replay fields, minted fresh on every GET of the login form and
# only valid for the session cookie they were issued against.
_HIDDEN_RE = re.compile(
r"name='(?P<name>LoginSecurity|SubmitToken)'[^>]*value='(?P<value>[^']*)'"
)
class FreshHarvestError(Exception):
"""Base error."""
class FreshHarvestAuthError(FreshHarvestError):
"""Credentials rejected, or the session expired and could not be renewed."""
@dataclass
class Delivery:
"""A scheduled delivery."""
delivery_date: date | None = None
window: str | None = None
status: str | None = None
total: float | None = None
items: list[str] = field(default_factory=list)
cutoff: str | None = None
class FreshHarvestClient:
"""Session-holding client for one portal account."""
def __init__(
self, session: aiohttp.ClientSession, email: str, password: str
) -> None:
self._session = session
self._email = email
self._password = password
self._authenticated = False
async def _get(self, path: str) -> str:
async with self._session.get(
BASE.join(URL(path)), headers={"User-Agent": USER_AGENT}
) as resp:
resp.raise_for_status()
return await resp.text()
async def async_login(self) -> None:
"""Run the two-step handshake: fetch tokens, then post credentials.
The tokens are bound to the session cookie issued by the same GET, so
the fetch and the post cannot be split across sessions or cached.
"""
form = await self._get(LOGIN_FORM)
hidden = {m["name"]: m["value"] for m in _HIDDEN_RE.finditer(form)}
if len(hidden) != 2:
raise FreshHarvestError(
f"login form missing anti-replay tokens (got {sorted(hidden)}); "
"the portal markup likely changed"
)
payload = {
"LoginEmail": self._email,
"LoginPassword": self._password,
"LoginSecurity": hidden["LoginSecurity"],
"SubmitToken": hidden["SubmitToken"],
"Redirect": "",
}
async with self._session.post(
BASE.join(URL(LOGIN_SUBMIT)),
data=payload,
headers={"User-Agent": USER_AGENT},
) as resp:
resp.raise_for_status()
body = await resp.text()
if not self._looks_authenticated(body):
raise FreshHarvestAuthError("login rejected")
self._authenticated = True
@staticmethod
def _looks_authenticated(body: str) -> bool:
"""Distinguish a good login from a rejected one.
NOTE: provisional. Every /p/* path returns 200 even for nonsense URLs,
so an HTTP status is not a signal here. Needs confirming against a real
authenticated response before this integration can be trusted.
"""
lowered = body.lower()
if "invalid" in lowered or "incorrect" in lowered:
return False
return "sign out" in lowered or "log out" in lowered
async def async_get_next_delivery(self) -> Delivery:
"""Return the account's next scheduled delivery.
UNIMPLEMENTED. The portal serves a catch-all 200 for every /p/* path
when signed out, so the real account pages could not be located or
parsed without an authenticated session. Fill this in against a live
session rather than guessing at selectors.
"""
raise FreshHarvestError(
"delivery parsing is not implemented yet: the portal HTML has not "
"been mapped against a signed-in session"
)
async def async_fetch(self, path: str) -> str:
"""Fetch a portal page, re-authenticating once if the session lapsed."""
if not self._authenticated:
await self.async_login()
body = await self._get(path)
if not self._looks_authenticated(body):
self._authenticated = False
await self.async_login()
body = await self._get(path)
if not self._looks_authenticated(body):
raise FreshHarvestAuthError(f"could not hold a session for {path}")
return body
@@ -0,0 +1,50 @@
"""Config flow for Fresh Harvest."""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .api import FreshHarvestAuthError, FreshHarvestClient, FreshHarvestError
from .const import DOMAIN
STEP_USER_SCHEMA = vol.Schema(
{vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str}
)
class FreshHarvestConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the portal-credentials flow."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
errors: dict[str, str] = {}
if user_input is not None:
email = user_input[CONF_EMAIL]
await self.async_set_unique_id(email.lower())
self._abort_if_unique_id_configured()
# cookie_jar must persist across requests for the token handshake.
session = async_create_clientsession(self.hass)
client = FreshHarvestClient(session, email, user_input[CONF_PASSWORD])
try:
await client.async_login()
except FreshHarvestAuthError:
errors["base"] = "invalid_auth"
except FreshHarvestError:
errors["base"] = "cannot_connect"
else:
return self.async_create_entry(title=email, data=user_input)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_SCHEMA, errors=errors
)
+12
View File
@@ -0,0 +1,12 @@
"""Constants for the Fresh Harvest integration."""
from __future__ import annotations
from datetime import timedelta
DOMAIN = "freshharvest"
# The portal is a small business's site, not an API. Poll gently: delivery
# schedules change on the order of days, and the customization cutoff is the
# only time-sensitive value.
UPDATE_INTERVAL = timedelta(hours=6)
@@ -0,0 +1,39 @@
"""Polling coordinator for Fresh Harvest."""
from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .api import Delivery, FreshHarvestAuthError, FreshHarvestClient, FreshHarvestError
from .const import DOMAIN, UPDATE_INTERVAL
_LOGGER = logging.getLogger(__name__)
class FreshHarvestCoordinator(DataUpdateCoordinator[Delivery]):
"""Fetch the account's next delivery."""
def __init__(
self, hass: HomeAssistant, entry: ConfigEntry, client: FreshHarvestClient
) -> None:
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=UPDATE_INTERVAL,
config_entry=entry,
)
self.client = client
async def _async_update_data(self) -> Delivery:
try:
return await self.client.async_get_next_delivery()
except FreshHarvestAuthError as err:
raise ConfigEntryAuthFailed(str(err)) from err
except FreshHarvestError as err:
raise UpdateFailed(str(err)) from err
@@ -0,0 +1,12 @@
{
"domain": "freshharvest",
"name": "Fresh Harvest",
"codeowners": ["@flan"],
"config_flow": true,
"documentation": "https://git.onetick.ninja/flan/ha-freshharvest",
"integration_type": "service",
"iot_class": "cloud_polling",
"issue_tracker": "https://git.onetick.ninja/flan/ha-freshharvest/issues",
"requirements": [],
"version": "0.1.0"
}
+103
View File
@@ -0,0 +1,103 @@
"""Sensors for Fresh Harvest."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import FreshHarvestConfigEntry
from .api import Delivery
from .const import DOMAIN
from .coordinator import FreshHarvestCoordinator
@dataclass(frozen=True, kw_only=True)
class FreshHarvestSensorDescription(SensorEntityDescription):
"""Describes a Fresh Harvest sensor."""
value_fn: Callable[[Delivery], object]
SENSORS: tuple[FreshHarvestSensorDescription, ...] = (
FreshHarvestSensorDescription(
key="next_delivery",
translation_key="next_delivery",
device_class=SensorDeviceClass.DATE,
value_fn=lambda d: d.delivery_date,
),
FreshHarvestSensorDescription(
key="order_total",
translation_key="order_total",
device_class=SensorDeviceClass.MONETARY,
native_unit_of_measurement="USD",
value_fn=lambda d: d.total,
),
FreshHarvestSensorDescription(
key="status",
translation_key="status",
value_fn=lambda d: d.status,
),
FreshHarvestSensorDescription(
key="item_count",
translation_key="item_count",
value_fn=lambda d: len(d.items) if d.items else None,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: FreshHarvestConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the sensor platform."""
coordinator = entry.runtime_data
async_add_entities(
FreshHarvestSensor(coordinator, entry, description) for description in SENSORS
)
class FreshHarvestSensor(CoordinatorEntity[FreshHarvestCoordinator], SensorEntity):
"""A value read off the Fresh Harvest portal."""
_attr_has_entity_name = True
entity_description: FreshHarvestSensorDescription
def __init__(
self,
coordinator: FreshHarvestCoordinator,
entry: FreshHarvestConfigEntry,
description: FreshHarvestSensorDescription,
) -> None:
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{entry.entry_id}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name="Fresh Harvest",
manufacturer="Fresh Harvest",
entry_type=DeviceEntryType.SERVICE,
configuration_url="https://freshharvest.com/",
)
@property
def native_value(self):
"""Return the sensor value."""
return self.entity_description.value_fn(self.coordinator.data)
@property
def extra_state_attributes(self) -> dict[str, object] | None:
"""Expose box contents on the item-count sensor."""
if self.entity_description.key != "item_count":
return None
return {"items": self.coordinator.data.items}
@@ -0,0 +1,28 @@
{
"config": {
"step": {
"user": {
"description": "Sign in with your freshharvest.com account.",
"data": {
"email": "Email",
"password": "Password"
}
}
},
"error": {
"cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication"
},
"abort": {
"already_configured": "This account is already configured"
}
},
"entity": {
"sensor": {
"next_delivery": { "name": "Next delivery" },
"order_total": { "name": "Order total" },
"status": { "name": "Order status" },
"item_count": { "name": "Items in box" }
}
}
}