Skip to content

Refactor to door-lock-driven guest lifecycle + housekeeping state#2

Merged
chschafl merged 33 commits into
mainfrom
claude/great-sagan-GgjH7
Jun 1, 2026
Merged

Refactor to door-lock-driven guest lifecycle + housekeeping state#2
chschafl merged 33 commits into
mainfrom
claude/great-sagan-GgjH7

Conversation

@chschafl

Copy link
Copy Markdown
Owner

Summary

Streamlines the integration around two state machines: a door-lock-driven guest lifecycle and a separate housekeeping state.

  • Guest status: reserved → due_in → in_house → departed → vacant, derived each poll from PMS data + a locally-latched entered_at flag set by lock events
  • House state: ready / occupied / dirty / cleaning, persisted across restarts, advanced via dedicated buttons
  • Predictable departed window: held for 10 seconds before next-guest rotation so automations (auto-lock, away mode) fire reliably
  • Configurable lock trigger source: Keymaster slot event or generic HA lock/sensor entity with user-defined unlock states
  • State persisted via homeassistant.helpers.storage.Store
  • Single-property scope: one config entry = one property (per discussion)

Removed

  • Phone, email fields
  • Multi-property selection
  • All next_guest_* sensors (next booking is fetched internally and rotated into current once the active guest goes vacant)
  • Mark Checked Out button (automatic now)

Added

  • House State sensor + Guest Status enum sensor
  • Mark Guest Departed, Mark Cleaning Started, Mark Ready buttons
  • Events: str_ha_guest_changed, str_ha_guest_status_changed, str_ha_house_state_changed

Test plan

  • Coordinator state derivation across all five guest statuses
  • Lock latch only fires inside the lock-access window
  • House transitions (auto occupied → dirty, manual dirty → cleaning → ready)
  • Event firing on guest + house changes
  • Sensor passthrough from STRState
  • End-to-end with a real PMS (Host Tools)
  • Real Keymaster slot unlock event
  • Real lock.* entity unlock event

Note: 4 unrelated provider tests (test_host_tools.py, test_custom_endpoint.py) fail on this branch — they also fail on main (verified via git stash). They're aioresponses URL-matching issues with query strings, not caused by this PR.

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9


Generated by Claude Code

claude and others added 30 commits May 30, 2026 07:52
Replaces the original "current/next guest with phone/email" model with a
single-property, lock-aware lifecycle:

- Guest status: reserved → due_in → in_house → departed → vacant, derived
  from PMS data + a locally-latched entered_at flag set by lock events.
- House state: ready / occupied / dirty / cleaning, persisted across
  restarts and advanced via dedicated buttons.
- Departed is held for a predictable 10s window so automations
  (auto-lock, away mode) fire reliably before the next guest rotates in.
- Lock trigger source is configurable: Keymaster slot events or a
  generic HA lock/sensor entity with user-defined unlock states.
- State persisted via homeassistant.helpers.storage.Store.

Removed: phone/email fields, multi-property selection, next-guest
sensors, Mark Checked Out button.

Added: House State sensor, Mark Cleaning Started / Mark Ready / Mark
Departed buttons, three lifecycle events (guest_changed,
guest_status_changed, house_state_changed).

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
- Switch every entity to translation_key so names come from the
  translations bundle in the user's language.
- Add entity name + enum state translations for guest_status
  (reserved/due_in/in_house/departed/vacant) and house_state
  (ready/occupied/dirty/cleaning) across all four locales.
- Drop the "Current" prefix on the guest name sensor / unique_id —
  single-guest scope makes it redundant.

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
Match the project branding throughout:
- HA domain, module folder, manifest, hacs.json
- Event names (str_concierge_guest_changed, …)
- Service domain (str_concierge.sync_keymaster)
- Device identifier tuple
- Test imports
- README, Makefile, devcontainer

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
… notes

- Rewrite README around what users get and how to wire it into automations.
  Expand the Keymaster section into a step-by-step end-to-end walkthrough
  (create slot → point STR Concierge at it → auto-push PIN on guest change).
  Pull out development, testing, and PMS internals so the README stays
  approachable for non-technical hosts.

- Add CONTRIBUTING.md as the home for community contributions. Open with
  why we want help (every host uses a different PMS), document the
  STRProvider interface and 5-step recipe for adding one, and call out
  that partial support is fine — list the patterns for missing
  get_properties, missing door codes, no mark_arrived/checked_out
  endpoints, rate-limit constraints, and webhook-only PMSes. Move the
  full dev lifecycle here: symlink workflow, remote SSH, tests, lint,
  triggering state transitions during testing.

- Add docs/providers/ with an index, per-provider implementation notes
  for Host Tools (auth, endpoints, field mapping, quirks) and Custom
  Endpoint (API contract that was previously embedded in the README).

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
Add a "Getting Python set up on macOS" subsection covering the three
viable paths — VS Code dev container, pyenv, Homebrew — with honest
trade-offs and a "what we don't recommend" list (system Python,
Anaconda, --user pip installs).

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
Both were written from the public API docs and pass the unit tests,
but neither has been confirmed against a live account. Mark them with
a warning in the README and per-provider docs index, and invite users
who try them to report back.

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
Expand "Remote HA over SSH" in CONTRIBUTING into three focused
sections: configuring HASS_CONFIG / HASS_SSH (with a table mapping
HA install types to config paths), step-by-step SSH setup for
HA OS / Container / Core, and a troubleshooting table covering
the common make deploy / deploy-ssh failure modes (Connection
refused, mDNS, publickey, rsync missing on the official SSH add-on).

Also drop the stale HASS_TOKEN reference from the Makefile header —
nothing in the Makefile actually uses it.

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
Expand the HA OS SSH setup with the three traps the first contributor
hit: ssh.port: 0 disables the SSH server entirely (matching the
add-on log line "SSH port is disabled"), rsync isn't installed by
default and needs packages: [rsync] in the add-on config, and the
VS Code dev container has no keys of its own.

Add a "When you're running inside the VS Code dev container"
subsection covering both the quick fix (generate a key in the
container) and the permanent fix (bind-mount ~/.ssh from the Mac
host into the container via devcontainer.json).

Extend the troubleshooting table to distinguish the failure modes:
"Connection refused with SSH-port-disabled log", "Connection refused
without log", "Permission denied from Mac" vs "Permission denied
from inside the dev container", "rsync not found after SSH succeeds"
vs "can't shell out at all (wrong add-on)".

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
Based on a working reference implementation from a sister project,
the previous Host Tools client was wrong on every axis:

  - Base URL was /api/v1, should be /api (no version segment). The
    wrong path returns the marketing SPA's HTML instead of a 404,
    which silently swallowed the bug.
  - Auth header was `Authorization: Bearer <token>`. Host Tools
    requires a custom `authToken: <token>` header — Bearer is also
    silently routed to the SPA.
  - get_properties hit /listings, which doesn't exist. Host Tools
    has no listings-list endpoint, so we now collect the listing ID
    during setup and surface it via a synthetic Property.
  - get_property_data hit /reservations?listingId=…, also wrong.
    Real path is GET /getReservations/{listingId}/{startDate}/{endDate}
    with dates as YYYY-MM-DD in the URL path.
  - Reservation field shape was wrong: real fields are
    guestFirstName + guestLastName (not guestName), checkIn/checkOut
    (not startDate/endDate as the primary form), and status values
    are accepted/confirmed/pending/inquiry — with cancelled/canceled/
    declined/blocked needing to be filtered out (they share the
    response stream with real bookings).

Wire it in:
  - Add CONF_HOST_TOOLS_LISTING_ID, ask for it in the credentials
    step when provider is host_tools, persist in entry data, pass
    through create_provider(**extra) into HostToolsProvider.
  - Translate the new credentials field for en/de/es/nl.
  - mark_arrived / mark_checked_out: drop (inherit base class
    NotImplementedError). Host Tools has /setreservation for
    field writes but no status-transition endpoint; local state is
    truth for guest lifecycle anyway.
  - Rewrite tests against the new endpoint shape + add coverage for
    cancelled/blocked filtering and the authToken header.
  - Rewrite docs/providers/host_tools.md with the corrected info,
    including the two "silently returns HTML" gotchas so the next
    person debugging doesn't waste an afternoon.

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
The /api/getListings endpoint does exist after all — drop the
manual-listing-ID config field and go back to the standard flow
of fetching the listings, populating a dropdown, and letting the
user pick.

Reverts the listing_id plumbing added in the previous Host Tools
fix (CONF_HOST_TOOLS_LISTING_ID, factory **extra kwarg, config
flow extra field, translations, init pass-through) since it's no
longer needed. Keeps the rest of that commit: correct base URL,
authToken header, correct reservation field shape, cancelled/blocked
filtering.

Implementation:
  - HostToolsProvider.get_properties now calls GET /getListings
    and parses with _parse_listing (field aliases _id/id/listingId
    → property_id, nickname/name/title → property_name)
  - Add unit tests for the listings endpoint including alternate
    field names and the wrapped-envelope shape
  - Update docs/providers/host_tools.md to describe both endpoints

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
The lock trigger option now has three values: "Lock entity",
"Keymaster slot", and "Disabled — manual buttons only". Picking
Disabled installs no listener (no warning either) so users who
only want the manual Mark Guest Arrived/Departed buttons have a
clean, intentional choice.

Add INFO-level logging the user can actually see without flipping
to DEBUG:

  - coordinator logs every guest_changed / guest_status_changed /
    house_state_changed transition with before → after values
  - Host Tools provider logs the result of each /getReservations
    poll (how many returned, how many kept, how many filtered),
    so "nothing is syncing" can be diagnosed at a glance
  - Skipped reservations log their booking ID and status at DEBUG
    for follow-up

Keep per-poll state snapshots at DEBUG (status, house state,
current/next bookings, lock window, entered_at) so bumping the
logger to debug gives a complete picture without parsing events.

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
Three changes:

1. Default the "Lock trigger source" to "Disabled — manual buttons
   only" via DEFAULT_LOCK_TRIGGER_SOURCE. New installs don't get a
   "no entity configured" warning until the user explicitly opts in
   to lock-based detection.

2. Add CONF_CLEANER_KEYMASTER_SLOT — a second, independent Keymaster
   slot for the cleaner. When the cleaner enters that slot's PIN and
   the house state is `dirty`, it auto-flips to `cleaning`. Other
   states are no-ops with a DEBUG log (we don't want a cleaner
   entering while a guest is in-house to silently reset the workflow).

   The cleaner listener is installed regardless of the main lock
   trigger source — guest-arrival detection and cleaner-arrival
   detection are independent concerns. A user can leave the main
   trigger Disabled and still get auto cleaner detection.

3. Document "what's automatic vs. what's manual" in the README:
   add a transition table making it explicit that "Mark Guest
   Departed" (in_house → departed) and "Mark Ready" (cleaning →
   ready) are the two transitions that always require a button
   press or an external automation. Add Step 5 to the Keymaster
   walkthrough covering the cleaner slot.

Plumbing:
  - const.py: new CONF, new DEFAULT_LOCK_TRIGGER_SOURCE constant
  - __init__.py: install cleaner listener up front, then dispatch
    on trigger source; default switched
  - coordinator.py: new async_handle_cleaner_arrived; import
    HOUSE_CLEANING
  - config_flow.py: new optional field, default switched
  - translations: keymaster_slot label clarified as "(guest arrival)"
    and a parallel "(cleaner arrival)" added for all four locales

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
Create branding/ with four PNG placeholders at the dimensions the
home-assistant/brands repo expects (icon 256² + @2x 512², logo
256×128 + @2x 512×256). They're intentionally crude — cyan
background, "STR" lettering, a big PLACEHOLDER banner — so it's
obvious they need to be replaced before submission.

branding/README.md documents:
  - What each file is for and what dimensions are required
  - That HA loads brand assets from the home-assistant/brands CDN
    at custom_integrations/str_concierge/*, not from this repo at
    runtime
  - How to swap in real artwork and PR it to the brands repo
  - Design guidance (transparent background preferred, must work
    at 32² favicon scale)

CONTRIBUTING.md gets a "Designers welcome too" section pointing at
branding/README.md so it's discoverable.

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
The provider's _pick_current_and_next only marks a booking as
`current` when [checkin <= now <= checkout]. An upcoming guest
with a checkin in a few hours stays in `next_guest` until they
actually start, which meant the integration sat at `vacant` and
no entities populated — even though the user's README said
"when current is vacant and the next booking's arrival window
opens, that booking takes the Current Guest slot automatically".

Add _promote_upcoming() in the coordinator: when current is None
but next exists and `now >= next.checkin - arrival_window`,
promote next to current. The rest of derive_state then naturally
flips status to `due_in` and populates name, door code, check-in/out,
lock window sensors.

Regression seen in the wild: a booking for "Kaite Sambrook" with
checkin in ~10 hours and arrival_window=4 left the integration
showing vacant and no upcoming-guest data anywhere.

Two new tests:
  - test_next_guest_promoted_to_current_inside_arrival_window
  - test_next_guest_NOT_promoted_outside_arrival_window

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
Drop the arrival-window gate from _promote_upcoming. Hosts want to
see who's coming next the moment the current guest departs, even
if the next booking is months away. Status derivation already
handles the time-based naming naturally:

  - far in the future          → reserved
  - within arrival window      → due_in
  - actively in-house          → in_house
  - within courtesy window     → departed
  - nothing at all on calendar → vacant

So `vacant` is now reserved (heh) for the genuine empty-calendar
case rather than "the next booking is more than N hours away".

Test renamed and inverted:
  test_next_guest_NOT_promoted_outside_arrival_window
    →
  test_next_guest_promoted_even_when_far_in_future
(now asserts the promotion happens and the status is `reserved`,
not `vacant`).

README updated:
  - Guest Status bullet calls out the always-show-next behaviour
    explicitly
  - "departed → next guest / vacant" row clarifies that rotation
    happens regardless of how far the next booking is

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
…nputs

User-driven changes:

1. Default provider look-ahead to 30 days (was 90 in host_tools, 90
   each in hostfully + guesty). Make it configurable via a new
   "Vacancy threshold (days)" option, default 30. Provider fetches
   only that far ahead, and the coordinator's _promote_upcoming
   refuses to surface a next_guest whose checkin is beyond the
   threshold — so `vacant` now means "no booking inside the next N
   days" with N user-controlled.

2. Rename arrival_window_hours → arrival_window_minutes so the time
   units match the lock-window options. Default 240 (= 4 hours).
   __init__.py keeps a one-shot backward-compat read of the old key
   for users who already have it persisted.

3. Replace vol.Range integer fields in the options form with
   selector.NumberSelector(mode=BOX) so they render as a text-box
   number input with a unit-of-measurement label, not a slider.
   Coerce back to int on submit since NumberSelector returns floats.

Plumbing:
  - providers/__init__.py: factory takes lookahead_days, passes to
    each provider's constructor
  - host_tools / hostfully / guesty: accept lookahead_days, store
    as a timedelta, use in the fetch window
  - coordinator: takes both arrival_window_minutes AND
    vacancy_threshold_days; _promote_upcoming gates on the threshold
  - translations: rename arrival_window field, add vacancy_threshold
    field across en/de/es/nl
  - README: reflect new defaults + new field

New regression test:
  test_vacant_when_next_guest_beyond_vacancy_threshold — a booking
  6+ months out stays vacant with the default 30-day threshold.

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
- Drop the optional base_url field from the Guesty credentials step.
  Guesty has a single public API endpoint; the constructor still
  accepts a base_url override so tests and edge cases keep working,
  but the UI no longer surfaces an extra field that confuses users.

- Reorder PROVIDER_OPTIONS so Custom Endpoint comes last. It's the
  "I run my own backend" fallback and shouldn't sit between the
  named PMS providers in the dropdown.

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
Rework the Custom Endpoint provider against the new path shape:

  GET  /properties/{propertyId}/reservations
  POST /properties/{propertyId}/reservations/{reservationId}/arrive
  POST /properties/{propertyId}/reservations/{reservationId}/checkout

(Was: GET /reservations?propertyId=…, POST /reservations/{id}/…)

The reservations endpoints now embed propertyId in the path, so the
provider stashes it from the first get_property_data call and reuses
it for mark_arrived / mark_checked_out. A defensive RuntimeError fires
if those are called before any get_property_data — useful in tests
and surfaces wiring bugs early.

Also publish docs/providers/backend-api-spec.md: a complete,
standalone API specification written so it can be handed to another
agent or developer and implemented end-to-end. Includes auth shape,
all four endpoints with worked examples, the field-alias table,
status-value semantics (including "drop cancelled bookings"), error
conventions, and a conformance checklist.

The existing docs/providers/custom_endpoint.md is rewritten as a
short integration-side summary that points at the spec for the
wire-level details.

Tests:
  - test_get_property_data now mocks /properties/p1/reservations
  - test_mark_arrive / test_mark_checkout fetch property data first
    so the provider knows the property_id
  - new test_mark_actions_fail_before_property_known guards against
    silently posting to a malformed URL

All 43 tests pass; ruff clean.

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
Two parsing fixes for Host Tools, both confirmed against live data:

1. Check-in / check-out times were always midnight UTC because the
   parser only read the date fields (`checkIn`, `checkOut`). Host
   Tools sends the time of day as separate fields — `checkInTime`,
   `checkOutTime` (plus `checkin_time` / `checkout_time` /
   `startTime` / `endTime` as variant aliases). The time field
   comes in any of these shapes:

     16          → 16:00
     15.5        → 15:30
     "16"        → 16:00
     "15:30"     → 15:30
     "4:00 PM"   → 16:00
     "4 PM"      → 16:00
     ISO ts      → pull the HH:MM portion

   Add _parse_time_of_day() that handles all of those and
   _combine_date_and_time() that overlays the parsed time onto the
   date. Falls back to the date's embedded time if the explicit
   time field is absent.

2. Door code field is actually `lockCode` in the Host Tools API,
   not `doorCode`. Add `lockCode` first in the alias list; keep
   `doorCode` / `door_code` / `accessCode` / `access_code` as
   defensive fallbacks for variant payloads. When parsing succeeds
   but no door code is found, log the full payload key list at
   DEBUG so the next "where's my code?" debugging session is
   one log line, not a curl session.

Tests: 8 new — 6 for time parsing (numeric, HH:MM, 12-hour, fractional,
ISO-fallback, midnight-default) + 2 for the lockCode field and the
alias chain. 51 total pass.

Docs/providers/host_tools.md gets a new "Date + time-of-day handling"
subsection with the input → interpretation table, and the door-code
section calls out lockCode as canonical.

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
Confirmed bug: when Host Tools sends checkIn="2026-06-01" (date only)
and checkInTime=16, the `16` means 4 PM in the property's local
timezone — but the previous code stamped it as 16:00 UTC, which is
wrong for every HA install outside UTC.

Fix _combine_date_and_time():
  - If date_value is date-only (YYYY-MM-DD), build a naive datetime,
    attach dt_util.DEFAULT_TIME_ZONE, then convert to UTC. So a
    Vienna HA install reading checkInTime=16 in summer stores
    2026-06-01 14:00 UTC; the HA UI then renders it back as 16:00
    local for the user.
  - If date_value is already a full ISO timestamp with embedded
    offset, we trust the offset and don't reinterpret as local.
    Overrides from time_value still apply but in the date's zone.
  - Date-only with no time field defaults to local midnight,
    converted to UTC (was: UTC midnight directly).

3 new tests pin the behaviour:
  - Vienna summer (DST, UTC+2): checkInTime=16 → 14:00 UTC
  - Vienna winter (UTC+1):       checkInTime=16 → 15:00 UTC
  - ISO timestamp with `Z`:      kept as UTC regardless of HA tz

Other time tests updated to use a utc_tz fixture so they're
explicit about which timezone they assume — no more accidental
UTC-only correctness.

docs/providers/host_tools.md gains a "Timezone semantics" subsection
explaining the assumption (HA's tz == property's tz), the conversion
flow, and the remote-property workaround.

54 tests pass.

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
Document the guest-status and house-state state machines visually
via two Mermaid stateDiagrams, with the relevant configuration
options labelled directly on each transition. GitHub renders both
inline so contributors see them in the README without leaving the
tab. Mermaid was chosen over SVG/PNG so the diagrams diff cleanly
in PRs and stay maintainable.

The guest-status diagram covers the full
  vacant → reserved → due_in → in_house → departed → (next/vacant)
loop, with vacancy_threshold_days, arrival_window_minutes, and the
lock_minutes_* options annotated on the arrows. A side note spells
out how the lock-access window bounds in_house latching.

The house-state diagram is a separate, smaller chart since house
state is independent of guest state except for the two automatic
crossings (in_house → occupied, departed → dirty).

Add a "Config knobs at a glance" table beneath the diagrams that
maps each option key to which transition it affects and the
default value — so users can read the chart, find the knob they
want, and jump straight to Configure.

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
Remove _attr_entity_registry_visible_default = False from the
GuestDoorCodeSensor. That flag only affects first-registration
visibility, but HA still surfaces a "(Hidden)" tag in the entity
list even after the user un-hides it — confusing because the
value IS visible.

Trade-off: the door PIN is no longer auto-hidden in dashboards.
Update the README to call out "keep this off public dashboards"
as a guideline rather than promise something the code doesn't
reliably deliver. Users who want it hidden can do so per-entity
in HA's UI.

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
Per user request, re-add _attr_entity_registry_visible_default = False
on GuestDoorCodeSensor. Default behaviour for fresh installs: door
PIN is registered as hidden in the entity registry. Users who want
to surface it on a dashboard can un-hide it per-entity from the UI.

Update the README to spell out how to un-hide it if desired, since
"(Hidden)" can be confusing when first encountered.

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
Two improvements:

1. Device name now shows the rental name ("Beach House"), not the
   opaque listing ID. The config flow already presents friendly
   names in the property dropdown — we just weren't keeping the
   picked name around afterwards. Store it as
   CONF_PROPERTY_NAME in entry.data, plumb through the coordinator
   as `property_name`, and use it in entity.device_info as the
   fallback when the PMS provider doesn't echo the listing name
   per poll (Host Tools doesn't, since /getReservations only
   returns reservation rows). The live PMS name still wins when
   available; the captured name is the second choice; the raw
   property ID is now only used for entries created before this
   change exists.

2. Add a DEBUG log line inside _combine_date_and_time showing the
   raw input fields, the resolved local timezone, the constructed
   local datetime, and the resulting UTC. When a user reports
   "PST 11am shows as 4am" we can confirm in one log line whether
   dt_util.DEFAULT_TIME_ZONE was actually set to America/Los_Angeles
   or still defaulting to UTC.

Tests updated to pass property_name to the coordinator fixture.

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
The previous fix only treated the date-only branch as local — full
ISO timestamps (e.g. "2026-05-31T11:00:00Z") were trusted as UTC.
Empirically, Host Tools sends property-local times with a misleading
`Z` suffix: a user in PST reported their 11 AM check-in showing as
4 AM in the HA UI, which is exactly the symptom of taking the `Z`
at face value (11:00 UTC rendered in PDT = 04:00).

Unify the helper: always extract date + time-of-day from whatever
the field contains, ignore any offset, stamp HA's configured tz,
convert to UTC. The reference TypeScript implementation behaves the
same way (extracts only the HH:MM portion, drops the offset).

Regression test added:
  test_pst_11am_iso_z_regression — under America/Los_Angeles, a
  Host Tools payload of "T11:00:00Z" becomes 18:00 UTC, so HA
  renders 11:00 AM PDT to the user.

The previous "ISO timestamp keeps its own offset" test had to flip
sign — now it asserts the offset is ignored. Renamed to
test_iso_timestamp_with_z_is_treated_as_local and updated
test_iso_timestamp_no_explicit_time_field to be explicit about the
UTC default.

Docs: rewrite the "Timezone semantics" subsection with a side-by-side
table showing the three input shapes and what they resolve to under
America/Los_Angeles, with a note that the `Z` suffix is a Host Tools
mis-encoding to be ignored.

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
Just a label change — translation_key stays "mark_ready" so the
entity ID and unique_id are unchanged. Updates all four locales
(en/de/es/nl) plus the README references.

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
Stand up .github/workflows/ci.yml as the PR gate. Four parallel
jobs, all required:

  - **Ruff**      `ruff check custom_components/ tests/`
  - **Pytest**    Full suite on Python 3.13 with coverage
  - **Hassfest**  HA's official manifest + structure validator
  - **HACS**      hacs/action@main, category: integration

Triggered on pull_request, push to main, and workflow_dispatch
(useful when a transient external-action failure can be re-run
without an empty commit). Adds concurrency group keyed to the
ref so a follow-up push cancels the previous in-flight run.

Pip cache is keyed to requirements_test.txt for fast warm starts.
Pinned Python 3.13 to match the pytest-homeassistant-custom-component
0.13.185 minimum.

CONTRIBUTING.md gets a new "What CI runs on your PR" subsection
mapping each job to the local make command (where one exists), so
contributors can reproduce gate failures locally. Also drops the
"or 4 pre-existing aioresponses failures" caveat — those were the
custom-endpoint URL mismatches, fixed when the provider moved to
the nested /properties/{id}/reservations path.

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
claude added 3 commits May 31, 2026 18:37
Investigated the run on commit 70282f3 — all four checks failed for
distinct reasons:

**Ruff (10 errors)**
  - 6× E402 in tests/conftest.py: imports after the required
    `pytest_plugins = "..."` declaration. This pattern is mandatory
    for pytest-homeassistant-custom-component. Add pyproject.toml
    with a per-file ignore for that file.
  - 4× F401 unused imports across test files. `ruff --fix` cleaned
    these plus 37 other modernizations (mostly `from datetime import
    timezone` → `UTC`).
  - 1× B017 blind `pytest.raises(Exception)` → tightened to
    aiohttp.ClientResponseError.
  - 1× E501 long inline comment → reflow to two lines.

**Pytest (install fails)**
  - requirements_test.txt was over-pinned. freezegun==1.5.0 conflicts
    with the rest of the pinned toolchain on the GitHub Actions
    runner; also freezegun is unused (no `from freezegun` anywhere).
    Rewrite the file: pin only pytest-homeassistant-custom-component
    (since it drives the whole HA transitive tree) and ruff/mypy
    (for reproducible lint). Let pip pick compatible pytest, pytest-
    asyncio, pytest-cov, aioresponses versions.

**Hassfest**
  - manifest.json keys weren't in HA's required order (domain, name,
    then alphabetical). Reorder: domain → name → after_dependencies
    → codeowners → config_flow → dependencies → documentation →
    iot_class → issue_tracker → requirements → version.
  - Translation `base_url` labels embedded the example URL inline
    (e.g. "Base URL (e.g. https://your-server.com/api)"), which
    hassfest flags. Strip to just "Base URL" / "Basis-URL" / etc.
    in all four locales — the example was redundant with the field
    name anyway.

**HACS**
  - hacs.json had a `description` key, which is not a permitted key
    in the HACS manifest schema. Removed.
  - HACS also requires repo-level **GitHub description** and **topics**
    on the GitHub repo settings — those have to be set in the repo
    UI (Repo → About ⚙) since there's no API access from this PR.
    I'll flag this to the user.

Add pyproject.toml with a basic ruff config (target py312, line 100,
select E/F/W/I/UP/B) so behaviour is consistent in CI and local.

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
Local was on ruff 0.15.15 where UP038 (no-tuple-isinstance) was
deprecated for being a perf regression. CI uses the pinned 0.4.4
where it's still active and fires on host_tools.py:109. Switch to
`int | float` syntax — supported since Python 3.10 and we target
3.12+, so this is uniformly safe.

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
Locally I'd been on an unpinned pytest-homeassistant-custom-component
that picked the latest 0.13.x. CI installed the explicit 0.13.185 pin
from requirements_test.txt and hit a stricter lingering-thread check:

    AssertionError: assert (False or False)
     + where False = isinstance(<Thread(Thread-1 (_run_safe_shutdown_loop)…
                                <class 'threading._DummyThread'>)
     + and   False = 'Thread-1 (_run_safe_shutdown_loop)'.startswith(
                       'waitpid-')

  (pytest_homeassistant_custom_component/plugins.py:405)

The thread is left behind by aiohttp's ClientSession shutdown.
0.13.300+ tolerates it; reproduced 56 passed in a fresh CI-replica
venv with the new floor.

Pin as `>=0.13.300` rather than ==latest so a transient pypi yank
or a brand-new release doesn't break us. The hard upper boundary is
implicitly Python 3.13 (which the workflow already uses).

https://claude.ai/code/session_01V68vQAmMQwUctanNhh1tN9
@chschafl chschafl merged commit ac8e02a into main Jun 1, 2026
10 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants