From eb50a9ecc1b82afb8992a20c022ff7cb28b5f72b Mon Sep 17 00:00:00 2001 From: flan Date: Mon, 3 Aug 2026 17:05:17 +0000 Subject: [PATCH] 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. --- .gitignore | 2 + CHANGELOG.md | 26 ++++ NOTICE | 6 + README.md | 57 +++++++ custom_components/freshharvest/__init__.py | 40 +++++ custom_components/freshharvest/api.py | 144 ++++++++++++++++++ custom_components/freshharvest/config_flow.py | 50 ++++++ custom_components/freshharvest/const.py | 12 ++ custom_components/freshharvest/coordinator.py | 39 +++++ custom_components/freshharvest/manifest.json | 12 ++ custom_components/freshharvest/sensor.py | 103 +++++++++++++ custom_components/freshharvest/strings.json | 28 ++++ 12 files changed, 519 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 NOTICE create mode 100644 README.md create mode 100644 custom_components/freshharvest/__init__.py create mode 100644 custom_components/freshharvest/api.py create mode 100644 custom_components/freshharvest/config_flow.py create mode 100644 custom_components/freshharvest/const.py create mode 100644 custom_components/freshharvest/coordinator.py create mode 100644 custom_components/freshharvest/manifest.json create mode 100644 custom_components/freshharvest/sensor.py create mode 100644 custom_components/freshharvest/strings.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..34e3f4b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog + +All notable changes to this project are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Config flow taking freshharvest.com portal credentials. +- Session client implementing the two-step login handshake against + `/s/popup/login` and `/s/submit/login`, including the per-session + `LoginSecurity` and `SubmitToken` anti-replay fields. +- Update coordinator polling every 6 hours, surfacing auth failures as + `ConfigEntryAuthFailed` so Home Assistant prompts for re-authentication. +- Sensor platform for next delivery date, order total, order status, and box + item count. + +### Known limitations + +- Delivery parsing is unimplemented; the integration cannot yet produce values. + The portal HTML has not been mapped against a signed-in session. +- The signed-in check is a provisional heuristic and needs confirming against a + real authenticated response. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..383558f --- /dev/null +++ b/NOTICE @@ -0,0 +1,6 @@ +ha-freshharvest + +Portions of this project were written with assistance from Claude (Anthropic). + +This is an unofficial integration. It is not affiliated with, endorsed by, or +supported by Fresh Harvest. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f70ef31 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# ha-freshharvest + +Unofficial Home Assistant integration for [Fresh Harvest](https://freshharvest.com/), +the Georgia local-produce delivery subscription. + +> **Status: incomplete — does not work yet.** The login handshake is implemented +> and the entity scaffolding is in place, but the portal page parsing is not +> written. See [Why it is unfinished](#why-it-is-unfinished). + +## What it is meant to expose + +| Entity | Value | +| --- | --- | +| `sensor.fresh_harvest_next_delivery` | Date of the next scheduled delivery | +| `sensor.fresh_harvest_order_total` | Cost of the upcoming order | +| `sensor.fresh_harvest_order_status` | Portal order status | +| `sensor.fresh_harvest_items_in_box` | Item count, with contents in `items` attribute | + +## How the site works + +Fresh Harvest is not on Shopify, Farmigo, or Local Line — the page metadata +reports `Vy Technology - Custom Code`. It is a server-rendered jQuery site with +no JSON API and no mobile app, so this integration scrapes HTML. + +Login is a two-step handshake: + +1. `GET /s/popup/login` returns the form plus two hidden anti-replay fields, + `LoginSecurity` and `SubmitToken`, minted per session. +2. `POST /s/submit/login` with `LoginEmail`, `LoginPassword`, both tokens, and + an empty `Redirect`. + +The tokens are bound to the cookie issued by step 1, so the two requests must +share a cookie jar and cannot be cached or split. + +## Why it is unfinished + +Signed out, **every** `/p/*` path returns HTTP 200 — including invented ones. +The site has no distinguishable 404, so the account pages cannot be located by +probing, and the delivery markup cannot be guessed. Finishing this requires one +signed-in session to capture the real account, delivery, and box-contents pages. + +Concretely, what remains: + +- Implement `FreshHarvestClient.async_get_next_delivery()` in + [api.py](custom_components/freshharvest/api.py). +- Replace the provisional `_looks_authenticated()` heuristic, which currently + guesses at a "sign out" link, with a real signed-in marker. + +## Installation + +Copy `custom_components/freshharvest/` into your Home Assistant `config/custom_components/` +directory and restart, then add the integration from **Settings → Devices & Services**. + +## Disclaimer + +Unofficial and unaffiliated. Polls every 6 hours; please do not lower that — +this is a small business's website, not an API. diff --git a/custom_components/freshharvest/__init__.py b/custom_components/freshharvest/__init__.py new file mode 100644 index 0000000..b9b8b4d --- /dev/null +++ b/custom_components/freshharvest/__init__.py @@ -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) diff --git a/custom_components/freshharvest/api.py b/custom_components/freshharvest/api.py new file mode 100644 index 0000000..d42c332 --- /dev/null +++ b/custom_components/freshharvest/api.py @@ -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='(?PLoginSecurity|SubmitToken)'[^>]*value='(?P[^']*)'" +) + + +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 diff --git a/custom_components/freshharvest/config_flow.py b/custom_components/freshharvest/config_flow.py new file mode 100644 index 0000000..3dd3f73 --- /dev/null +++ b/custom_components/freshharvest/config_flow.py @@ -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 + ) diff --git a/custom_components/freshharvest/const.py b/custom_components/freshharvest/const.py new file mode 100644 index 0000000..b3b2df5 --- /dev/null +++ b/custom_components/freshharvest/const.py @@ -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) diff --git a/custom_components/freshharvest/coordinator.py b/custom_components/freshharvest/coordinator.py new file mode 100644 index 0000000..8039112 --- /dev/null +++ b/custom_components/freshharvest/coordinator.py @@ -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 diff --git a/custom_components/freshharvest/manifest.json b/custom_components/freshharvest/manifest.json new file mode 100644 index 0000000..0dbe449 --- /dev/null +++ b/custom_components/freshharvest/manifest.json @@ -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" +} diff --git a/custom_components/freshharvest/sensor.py b/custom_components/freshharvest/sensor.py new file mode 100644 index 0000000..5853440 --- /dev/null +++ b/custom_components/freshharvest/sensor.py @@ -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} diff --git a/custom_components/freshharvest/strings.json b/custom_components/freshharvest/strings.json new file mode 100644 index 0000000..dbdc7f5 --- /dev/null +++ b/custom_components/freshharvest/strings.json @@ -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" } + } + } +}