Skip to main content

Optional Smart-Device Credentials

How IT Setup conditionally surfaces supplier credentials based on the smart devices present in a property, how an IT admin connects them after the fact, and the end-to-end recipe for adding a brand-new credential-backed device (Daikin Altherma and Aidoo are worked examples).

For credential generation internals (HMAC email/password derivation, seeds) see credential-management.md. For capability execution internals see smart-device-abstraction.md.

The Core Idea: Credentials Are Optional and Device-Driven

A property only needs credentials for the platforms whose devices it actually has. SmartThings + Yale are the baseline commissioning path; Hive, Daikin, Aidoo, and iParcelBox are optional and are only shown/connected when a matching device is in the property's inventory.

There are two complementary "optional credential" primitives, at two layers:

  • Presence flags (IT Setup / web) — the it_setup row carries per-service status (daikin_connected, yale_email, hive_email, …). The web UI shows a service's credential panel only when the property has a device of that type and the service isn't yet connected.
  • Option-typed credentials (smart layer)DeviceCredentials in crates/bf_smart/src/rpc.rs:59 holds every platform's creds as Option<…> with has_*() guards. A capability simply won't route to a platform whose credentials are absent.
// crates/bf_smart/src/rpc.rs:79
impl DeviceCredentials {
pub fn has_yale(&self) -> bool {
self.yale_api_key.is_some() && self.yale_oauth_token.is_some()
}
pub fn has_hive(&self) -> bool { self.hive_api_key.is_some() }
pub fn has_daikin(&self) -> bool { self.daikin_oauth_token.is_some() }
pub fn has_aidoo(&self) -> bool { self.aidoo_token.is_some() }
pub fn has_parcelbox(&self) -> bool {
// org-wide API key + the per-device password must both be present
self.parcelbox_api_key.is_some() && self.parcelbox_device_password.is_some()
}
}

OAuth vs. Generated Credentials

Not every credential type needs OAuth. There are two families, and a new type can be either:

ServiceAuth modelWhere the secret lives
SmartThingsPer-property API key (+ generated Samsung account)it_setup.smartthings_api_key_encrypted (D1, encrypted)
HiveGenerated email + deterministic password — no OAuthderived on demand from BF_CREDENTIAL_SEED_*
YaleOAuth 2.0tokens in oauth_tokens (bf_auth DO); client creds are env secrets
DaikinOAuth 2.0tokens in oauth_tokens (bf_auth DO); client creds per-property in it_setup
AidooDirect Airzone email/password loginit_setup.aidoo_password_encrypted (D1, encrypted); tokens in bf_auth after login
iParcelBoxOrg API key + per-device password — no OAuthPARCELBOX_API_KEY env secret (fleet-wide); per-device parcelbox_device_password_encrypted on external_device_id (D1, encrypted)
  • Generated services (SmartThings/Hive) are "connected" the moment their account exists — there is no token round-trip to confirm.
  • OAuth services (Yale/Daikin) are only "connected" after a token exchange succeeds and is verified (see Verifying Setup Status).
  • Direct-login services (Aidoo) are only "connected" after a successful vendor login. Because an admin password exists, cloud transfer requires an Aidoo password-reset confirmation when Aidoo is connected.
  • iParcelBox has no OAuth and no generated email. It is "connected" only after a provision call resolves the box's DeviceID (MAC) and a live getStatus confirms the box is online (see Verifying Setup Status). The provision-resolved MAC and the encrypted device password are stored per-device; the API key is shared fleet-wide.
  • Future credential types that are pure API-key/username-password follow the Hive/SmartThings, Aidoo, or iParcelBox pattern and skip the OAuth handler entirely.

Vendor API Keys: Environment vs. Per-Property

Verified split — do not hard-code or push vendor keys per property unless the vendor's model requires it:

  • Shared / global → Cloudflare Wrangler secrets (env), never in D1. Read via env.secret(...):
    • Yale OAuth client + API key: YALE_CLIENT_ID / YALE_CLIENT_SECRET / YALE_API_KEY (and YALE_STAGING_*), selected by environment in crates/bf_auth/src/yale.rs:19.
    • iParcelBox fleet credentials: PARCELBOX_API_KEY (the x-api-key on every cloud command, one per organisation) plus the master-account PARCELBOX_ACCOUNT_USERNAME / PARCELBOX_ACCOUNT_PASSWORD (used only for the provision call). PARCELBOX_API_KEY is declared in the bf_user and bf_resident wranglers; the account creds are bf_resident-only (provisioning is setup-time).
    • Credential-generation seeds: BF_CREDENTIAL_SEED_PROD / BF_CREDENTIAL_SEED_STAGING.
    • Encryption key: BF_ENCRYPTION_KEY (reused to encrypt the per-device iParcelBox password).
    • Declared in crates/bf_auth/wrangler.production.toml.
  • Per-property / per-device → encrypted in D1. Used when each property/device carries its own secret:
    • SmartThings API key → it_setup.smartthings_api_key_encrypted.
    • Daikin OAuth client_id/secret → daikin_client_id_encrypted / daikin_client_secret_encrypted (each property registers its own Onecta app). These are passed into the OAuth flow and re-used for autonomous token refresh.
    • Aidoo Airzone account password → aidoo_password_encrypted on it_setup; the email is stored alongside it and bf_auth stores the resulting Airzone tokens after login.
    • iParcelBox per-device password → external_device_id.parcelbox_device_password_encrypted (the x-device-password header), alongside the resolved parcelbox_device_id (MAC) and parcelbox_serial. Each physical box has its own password; the fleet API key is shared.

Rule of thumb: a key that is the same for the whole fleet is an env secret; a secret that a property's own vendor account or a specific physical device carries is encrypted in D1 (per-property in it_setup, per-device on external_device_id).

Connecting Credentials After the Fact (Web, IT Admin)

Confirmed: an IT admin can add credentials on the property detail page at any point, not just during initial commissioning.

  • Gating: all /it-admin/* routes require the ItAdmin (or Admin) role, derived from the Clerk JWT it_admin feature claim (crates/bf_resident/src/handlers/auth.rs:28). Enforced at the HTTP/middleware layer.
  • Conditional surfacing: apps/web/src/lib/components/ITAdminManager.svelte derives hasDaikinDevices, hasAidooDevices, and hasParcelBoxDevices from the device list and shows the matching setup panel when the device is present and the service is not connected.
  • Store actions (apps/web/src/stores/itSetup.ts):
    • saveDaikinCredentials()POST …/setup/save-daikin-credentials
    • requestDaikinOAuthUrl()POST …/setup/daikin-oauth-url
    • validateDaikin()POST …/setup/validate-daikin
    • saveAidooCredentials()POST …/setup/save-aidoo-credentials
    • connectAidoo()POST …/setup/connect-aidoo
    • provisionParcelBox()POST …/setup/provision-parcelbox
  • The handler allows this in HiveValidated, Commissioning, and Commissioned states, and auto-syncs the credential to the property's User DO if the property is already (being) commissioned (handlers.rs:4757).
  • Secrets never reach the client. Handlers return ItSetupClient (crates/bf_resident/src/handlers/it_setup/types.rs:37), which erases the SmartThings key, Daikin client_id/secret, and Aidoo password and exposes only presence flags (smartthings_api_key_configured, daikin_credentials_configured, aidoo_credentials_configured, *_connected). Always construct via ItSetupClient::from(it_setup).

Verifying Setup Status

"Is this service connected?" is answered differently per family:

  • Generated (SmartThings/Hive): connected once the account/API key exists — presence is the status.
  • OAuth (Yale/Daikin): connected only after a successful token exchange is confirmed. For Daikin (handlers.rs:4703 validate_daikin):
    1. Require it_setup.daikin_email (set when credentials were saved).
    2. Ask bf_auth for token status: BfAuthClient::get_token_status(daikin_email, env).
    3. If !status.has_tokens400 "complete the OAuth flow first".
    4. Otherwise set daikin_connected = true, write an audit log, and (if commissioned) sync to the User DO.
  • The truth source for OAuth confirmation is bf_auth's oauth_tokens table, not the it_setup flag; daikin_connected is a cached projection. Token refresh is service-aware — the bf_auth DO dispatches on the service column to daikin::refresh_token (per-property client creds) or yale::refresh_token (env creds) (crates/bf_auth/src/durable_object.rs:399).
  • Direct login (Aidoo): connect_aidoo requires saved aidoo_email + encrypted aidoo_password, asks bf_auth to log in to Airzone Cloud, and only then sets aidoo_connected = true. The password remains encrypted in D1; bf_auth stores the Airzone token material for runtime use and refresh.
  • Provision-resolved (iParcelBox): there are no OAuth tokens. The provision_parcelbox handler provisions the box (POST https://api.iparcelbox.com/provision), stores the returned DeviceID (MAC) + encrypted device password + a generated webhook token, then does a live getStatus to confirm the box is online. Only once connected:true does it register the webhook, disable the local API, and set it_setup.parcelbox_connected = true. If the box isn't on wifi yet it returns a "provisioned, waiting for box online" state and the step is re-runnable. So parcelbox_connected reflects a verified live box, not just stored creds.

Cloud Transfer Password Reset Policy

Cloud transfer is the cutover from installer/admin supplier access to the post-commissioning state. CompleteCloudTransferRequest therefore carries explicit reset confirmations for services where a reusable admin password exists:

  • Yale: always required.
  • Hive: required only when it_setup.hive_email exists.
  • Aidoo: required only when it_setup.aidoo_connected is true.
  • Daikin: no password-reset confirmation; OAuth token exchange is the confirmation path.
  • iParcelBox: no password-reset confirmation; provisioning stores the per-device password, verifies live cloud status, and disables the local API as part of setup.

Optional confirmation fields default to false, so callers may omit Hive/Aidoo confirmations for properties that do not use those services.

Recipe: Add a New Credential Type

  1. Service enum — add a variant + short_code() / name() + FromStr in crates/bf_auth/src/credentials.rs:69.
  2. OAuth or generated?
    • Generated: reuse generate_email / generate_password_for_service (add a service-specific symbol set only if the vendor restricts characters, like Hive).
    • OAuth: add an exchange/refresh module (mirror crates/bf_auth/src/daikin.rs), a callback arm in crates/bf_auth/src/handlers/oauth.rs, and a dispatch arm in the DO refresh (durable_object.rs:399). Add a service value; the oauth_tokens.service column already supports multiple providers.
    • Direct login: mirror Aidoo — store the email/password encrypted in it_setup, add a login endpoint in bf_auth, set *_connected only after the vendor login succeeds, and require a cloud-transfer password-reset confirmation if the account keeps a reusable admin password.
  3. Key storage decision — shared key → Wrangler secret read via env.secret(...); per-property key → new encrypted column on it_setup (+ migration), following daikin_client_*_encrypted.
  4. Setup status — add a *_connected column + email column to it_setup (migration), the DB getters/setters in crates/bf_resident_db/src/operations/it_setup.rs, and a validate_* handler that confirms via bf_auth before flipping the flag.
  5. Client view — extend ItSetupClient with presence flags only (*_configured, *_connected) and update the From<ItSetup> erasure. Mirror the fields in the web ItSetup interface (apps/web/src/stores/itSetup.ts).
  6. Smart layer — add the Option field + has_*() to DeviceCredentials (crates/bf_smart/src/rpc.rs:59), and populate it where the User DO builds credentials.

Recipe: Associate the Credential With a New Device

  • Device type — add the variant to crates/bf_types/src/smart/device_type.rs. Set smartthings_capabilities() to &[] and exclude it from requires_smartthings_id() if it's vendor-API-only (as DaikinThermostat does). Add it to DeviceType::all() and any predicate (is_climate_device, etc.).
  • External ID — add the vendor id column to devices and external_device_id (migration, cf. daikin_id / daikin_type) so device resolution can attach it.
  • Conditional UI — the web decides "show this credential" by matching device type (ITAdminManager.svelte checks device_type === "daikin_thermostat").

Recipe: Make It Available for Smart-Device Actions

Capabilities route to a platform purely on type, then gate on has_*():

  1. Define the capability — add the variant to DeviceCapability in crates/bf_smart/src/models.rs (e.g. SetDaikinRoomTemperature { temperature: f64 }).
  2. Categorize — add a match arm in categorize() (crates/bf_smart/src/capability/router.rs) mapping it to the platform category.
  3. Guard + dispatch — in crates/bf_smart/src/capability/mod.rs, the category arm checks credentials.has_daikin() and the external id before calling the handler; absent creds → a clean error, never a panic.
  4. Handler — implement the arm in crates/bf_smart/src/capability/daikin.rs: call the vendor client (set_characteristic / get_gateway_devices), then return an optimistic updated state (PATCH succeeds → apply the set value to cached state, no extra GET).

Device State & Refresh

  • State typeDeviceStateData::DaikinThermostat in crates/bf_smart/src/models.rs holds the full snapshot (climate on/off, mode, room/setpoint temps, hot water, last_polled).
  • Refresh strategy — Daikin bypasses SmartThings. The scheduled DeviceRefresh task picks GetDaikinState instead of Refresh for DaikinThermostat (crates/bf_user/src/durable_object/devices.rs:282), which does a single GET /gateway-devices covering all the property's Daikin devices.
  • Staleness — devices skipped if updated within the last 60s; budget-friendly (≈24 GETs/day at the hourly cadence, leaving headroom under Onecta's 200/day limit). See smart-device-abstraction.md.

Defining Events

  1. Event variant — add to SmartEvent in crates/bf_smart/src/models.rs (e.g. DaikinClimateChanged { … }). Use the full-state StateUpdated { state } when returning a complete snapshot.
  2. Apply to state — add a JSON-merge arm in event_to_json_updates() (crates/bf_user/src/durable_object/state.rs).
  3. Side effects — add an arm in crates/bf_user/src/durable_object/notifications/side_effects.rs. State-only events (the Daikin ones) do nothing here; events that should notify a resident raise a notification.
  4. Broadcastupdate_device_state_only() persists state (with a staleness guard) and pushes a DeviceStateUpdate WebSocket message to connected clients. See realtime-events.md.

Review Notes

One drift surfaced while documenting; it is not a bug today, but it is worth tightening:

  • The device-type → "needs this credential" mapping lives only in the web frontend (ITAdminManager.svelte string-matches device types such as "daikin_thermostat", "aidoo_thermostat", and "parcel_box"). There is no Rust-side source of truth, so backend and UI can disagree about which services a property requires. Consider a DeviceType::required_service() (or similar) in bf_types that both sides consume.

See Also