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
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+26
View File
@@ -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.
+6
View File
@@ -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.
+57
View File
@@ -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.
@@ -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" }
}
}
}