Smart Device Abstraction Layer
The bf_smart crate provides a unified interface for controlling smart home devices across multiple platforms: SmartThings Enterprise, Yale locks, Hive thermostats, Daikin Altherma heat pumps, and iParcelBox smart parcel boxes.
Key Files
crates/bf_smart/src/lib.rs- Crate entry pointcrates/bf_smart/src/client.rs- SmartClient traitcrates/bf_smart/src/models.rs- DeviceCapability, DeviceState typescrates/bf_smart/src/rpc_production.rs- Production routingcrates/bf_smart/src/smartthings/mod.rs- SmartThings APIcrates/bf_smart/src/yale/mod.rs- Yale APIcrates/bf_smart/src/daikin_client.rs- Daikin Onecta APIcrates/bf_smart/src/capability/daikin.rs- Daikin capability executioncrates/bf_smart/src/parcelbox_client.rs- iParcelBox Cloud API clientcrates/bf_smart/src/capability/parcel_box.rs- ParcelBox capability executioncrates/bf_user/src/durable_object_sqlx.rs- State refresh logic
Architecture Overview
┌─────────────────────────────────────────────────────────────────────────┐
│ bf_user DO │
│ │
│ RpcRequest::ExecuteSmartCapability { device_id, capability } │
│ │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ bf_smart │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────────────────┐│
│ │ categorize_capability() ││
│ │ ││
│ │ DeviceCapability::Lock → Yale ││
│ │ DeviceCapability::SetHeating... → Hive ││
│ │ DeviceCapability::SetDaikin* → Daikin ││
│ │ DeviceCapability::* → SmartThings ││
│ │ ││
│ └───────────────────────────────────────────────────────────────────┘│
│ │ │
│ ┌─────────────────────┼─────────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ SmartThings │ │ Yale │ │ Hive │ │
│ │ Client │ │ Client │ │ Client │ │
│ │ │ │ │ │ │ │
│ │ • Sensors │ │ • Locks │ │ • Thermostat │ │
│ │ • Switches │ │ • PIN codes │ │ │ │
│ │ • Doorbells │ │ • Alarm hub │ │ │ │
│ │ • Appliances │ │ • Activity │ │ │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │
│ ┌─────────────────┐ │
│ │ Daikin │ │
│ │ Client │ │
│ │ │ │
│ │ • Heat pump │ │
│ │ • Hot water │ │
│ │ • Multi-zone │ │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Capability Execution Flow
When a capability is executed, it follows this flow:
┌────────────────────────────────────────────────────────────────────────┐
│ 1. Client Request │
│ POST /properties/{id}/devices/{device_id}/capabilities │
│ { "type": "lock" } │
└────────────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────────┐
│ 2. bf_resident Handler │
│ - Validates request │
│ - Creates RpcRequest::ExecuteSmartCapability │
│ - Proxies to User Durable Object │
└────────────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────────┐
│ 3. User DO: execute_smart_capability_internal() │
│ - Gets device info from local storage │
│ - Gets external IDs (smartthings_id, yale_id, hive_id, daikin_id) │
│ - Gets credentials for the platform │
│ - Builds bf_smart::ExecuteCapabilityRequest │
└────────────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────────┐
│ 4. bf_smart: categorize_capability() │
│ - Determines target platform from capability type │
│ - Routes to appropriate client │
└────────────────────────────────────────────────────────────────────────┘
│
┌─────────────────────┼─────────────────────┬───────────────┐
▼ ▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ SmartThings API │ │ Yale API │ │ Hive API │ │ Daikin API │
│ │ │ │ │ │ │ (Onecta Cloud) │
│ POST /devices/ │ │ POST /panel/ │ │ POST /heating/ │ │ PATCH /gateway- │
│ {id}/commands │ │ device/{id}/ │ │ {id}/setpoint │ │ devices/{id}/... │
└──────────────────┘ └──────────────────┘ └──────────────────┘ └──────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────────┐
│ 5. Response Processing │
│ - Parse SmartEvent from response │
│ - Update local device state in DO │
│ - Broadcast to WebSocket clients │
│ - Return updated DeviceState to caller │
└────────────────────────────────────────────────────────────────────────┘
Capability Categorization
The categorize_capability() function routes capabilities to the correct platform:
fn categorize_capability(capability: &DeviceCapability) -> CapabilityCategory {
match capability {
// Yale: Lock operations and PIN management
DeviceCapability::Lock
| DeviceCapability::Unlock
| DeviceCapability::GetLockStatus
| DeviceCapability::CreatePinCode { .. }
| DeviceCapability::DeletePinCode { .. }
| DeviceCapability::SyncPinCodes
| DeviceCapability::ClearAllPinCodes
| DeviceCapability::SetAlarmHubMode { .. }
| DeviceCapability::SilenceAlarm
// Yale: Alarm-hub keypad PIN (cache-first via the bf_user DO)
| DeviceCapability::GetAlarmPinCode { .. }
| DeviceCapability::SetAlarmPinCode { .. } => CapabilityCategory::Yale,
// Hive: Thermostat control
DeviceCapability::SetHeatingSetpoint { .. }
| DeviceCapability::SetCoolingSetpoint { .. }
| DeviceCapability::SetThermostatMode { .. }
| DeviceCapability::SetThermostatFanMode { .. } => CapabilityCategory::Hive,
// Daikin: Altherma heat pump control
DeviceCapability::SetDaikinClimateOnOff { .. }
| DeviceCapability::SetDaikinOperationMode { .. }
| DeviceCapability::SetDaikinRoomTemperature { .. }
| DeviceCapability::SetDaikinHotWaterOnOff { .. }
| DeviceCapability::SetDaikinHotWaterTemperature { .. }
| DeviceCapability::GetDaikinState => CapabilityCategory::Daikin,
// iParcelBox: parcel box control (iParcelBox Cloud API)
DeviceCapability::ParcelBoxAllowDelivery
| DeviceCapability::ParcelBoxEmpty
| DeviceCapability::ParcelBoxLock
| DeviceCapability::GetParcelBoxStatus => CapabilityCategory::ParcelBox,
// Everything else: SmartThings
_ => CapabilityCategory::SmartThings,
}
}
Device State Refresh
The state refresh system keeps device states current by periodically querying SmartThings.
Refresh Flow
┌────────────────────────────────────────────────────────────────────────┐
│ RefreshDeviceStates RPC │
│ │
│ 1. Get all devices with their last_updated timestamps │
│ 2. Filter to devices not updated in last 60 seconds (stale) │
│ 3. Filter to devices that support refresh capability │
│ 4. Execute refresh in parallel (max 5 concurrent) │
│ 5. Process returned SmartEvent to update local state │
│ 6. Return refreshed device states │
└────────────────────────────────────────────────────────────────────────┘
Devices That Support Refresh
Not all device types support the SmartThings refresh capability. DaikinThermostat devices use the Daikin Onecta API for state refresh instead of SmartThings (see Daikin State Refresh below), and ParcelBox devices use the iParcelBox Cloud API (GetParcelBoxStatus, see iParcelBox cloud API below) with webhooks as the primary freshness mechanism.
fn device_supports_refresh(device_type: &DeviceType) -> bool {
matches!(device_type,
// Sensors
DeviceType::ContactSensor
| DeviceType::MotionSensor
| DeviceType::WaterLeakSensor
| DeviceType::SmokeSensor
// Security devices (NOT SmartKeypad, AlarmKeypad, DoorbellChime)
| DeviceType::SmartLock
| DeviceType::VideoDoorbellPro
| DeviceType::IndoorSiren
// Climate (NOT DaikinThermostat - uses Daikin API)
| DeviceType::Thermostat
// Energy
| DeviceType::HomeEnergyMeter
// Appliances
| DeviceType::Oven
| DeviceType::InductionHob
| DeviceType::Extractor
| DeviceType::Dishwasher
| DeviceType::WasherDryer
| DeviceType::FridgeFreezer
// Hub
| DeviceType::SmartThingsHub
// Alarm Hub (special Yale handling)
| DeviceType::AlarmHub
)
}
Devices That Do NOT Support Refresh
These device types are skipped during refresh:
| Device Type | Reason |
|---|---|
SmartKeypad | No refresh capability in SmartThings |
AlarmKeypad | No external API representation |
DoorbellChime | No external API representation |
Tablet | Not a smart device |
DaikinThermostat | Uses Daikin Onecta API (not SmartThings) |
ParcelBox | Uses iParcelBox Cloud API (GetParcelBoxStatus); webhooks primary |
Staleness Threshold
Devices are only refreshed if they haven't been updated in the last 60 seconds. This prevents excessive API calls while ensuring reasonably fresh data.
let staleness_threshold = 60; // seconds
let age_secs = now - last_updated;
if age_secs < staleness_threshold {
// Skip - data is fresh enough
continue;
}
AlarmHub Special Handling
AlarmHub devices use the Yale API instead of SmartThings for state refresh:
if device.device_type == DeviceType::AlarmHub {
// Fetch from Yale house/state API for full state including faults
let state = yale_client.get_house_state(house_id).await?;
return DeviceStateData::AlarmHub {
mode: state.mode,
alarm_active: state.alarm_active,
faults: state.faults,
// ...
};
}
Arm/disarm (SetAlarmHubMode, SilenceAlarm) and keypad-PIN management
(GetAlarmPinCode, SetAlarmPinCode) are all served by the Yale platform (the
Yale wire calls are currently placeholders pending the confirmed Yale alarm API).
Alarm-hub keypad PIN cache
GetAlarmPinCode is cache-first. The keypad PIN is cached per device in a
DO-managed alarm_pin_cache table in bf_user (mirrors token_cache):
CREATE TABLE alarm_pin_cache (
device_id TEXT NOT NULL PRIMARY KEY,
pin_code TEXT,
slot INTEGER,
label TEXT,
fetched_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
GetAlarmPinCode { force_refresh: false }readsalarm_pin_cachefirst and returnsAlarmPinCode { cached: true }on a hit — Yale is never touched.- On a cache miss (or
force_refresh: true) the capability executes normally and a successful fetch warms the cache. SetAlarmPinCodewrites the cache from the request values (the source of truth) on success.
The AlarmPinCode result is transient: it is written to alarm_pin_cache, never
to device_states, so it cannot clobber the hub's live AlarmHub card state.
Battery Status
Battery is an optional capability present on multiple device types. This is a design tradeoff - rather than having unique state types for every device variant, battery is included as Option<u8> on devices that may have batteries.
Devices with Optional Battery
pub enum DeviceStateData {
Lock {
locked: bool,
contact: Option<ContactState>,
battery: Option<u8>, // ← Optional
},
ContactSensor {
contact: ContactState,
battery: Option<u8>, // ← Optional
},
MotionSensor {
motion: bool,
battery: Option<u8>, // ← Optional
},
VideoDoorbellPro {
streaming: bool,
battery: Option<u8>, // ← Optional (some are wired)
// ...
},
AlarmHub {
mode: AlarmHubMode,
battery: Option<u8>, // ← Optional (mains + backup)
// ...
},
Thermostat {
current_temperature: f64,
battery: Option<u8>, // ← Optional (most are mains-powered)
// ...
},
ParcelBox {
box_status: ParcelBoxStatus,
battery: Option<u8>, // ← Optional (box has a backup battery)
// ...
},
}
Why Optional Battery?
This approach works because:
- Known device inventory: Properties have specific, curated devices
- Avoids type explosion: Without this, we'd need
LockWithBattery,LockWithoutBattery, etc. - Runtime flexibility: Same device type can handle variants
- Webhook compatibility: SmartThings events may or may not include battery
Battery Status Endpoint
The GetBatteryStatus RPC returns battery levels for all battery-powered devices:
RpcRequest::GetBatteryStatus => {
// Returns devices ordered by:
// 1. Floor (ascending)
// 2. Room (alphabetical)
// 3. Device name (alphabetical)
Vec<BatteryStatusItem> {
device_id: String,
device_name: String,
device_type: String,
battery: Option<u8>, // None if device doesn't report battery
}
}
DeviceCapability Enum
Over 50 capability variants covering all device types:
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum DeviceCapability {
// Lock Operations (→ Yale)
Lock,
Unlock,
GetLockStatus,
// PIN Code Management (→ Yale)
CreatePinCode {
partner_user_id: String,
code: String,
name: String,
user_email: Option<String>,
is_temporary: bool,
valid_from: i64,
valid_until: Option<i64>,
valid_time_start: Option<String>, // "HH:MM"
valid_time_end: Option<String>,
valid_days: Option<String>, // "MO,TU,WE,TH,FR"
},
DeletePinCode { pin_code_id: String, pin: String, access_type: String },
SyncPinCodes,
ClearAllPinCodes,
// Thermostat (→ Hive)
SetHeatingSetpoint { temperature: f64 },
SetCoolingSetpoint { temperature: f64 },
SetThermostatMode { mode: ThermostatMode },
SetThermostatFanMode { mode: ThermostatFanMode },
GetTemperatureMeasurement,
// Daikin Altherma Heat Pump (→ Daikin Onecta API)
SetDaikinClimateOnOff { on: bool },
SetDaikinOperationMode { mode: DaikinOperationMode },
SetDaikinRoomTemperature { temperature: f64 },
SetDaikinHotWaterOnOff { on: bool },
SetDaikinHotWaterTemperature { temperature: f64 },
GetDaikinState,
// Parcel Box (→ iParcelBox Cloud API)
ParcelBoxAllowDelivery, // allowDelivery — accept the requested delivery
ParcelBoxEmpty, // emptyBox — unlock to remove parcels, reset count
ParcelBoxLock, // lockBox — lock / cancel clearing
GetParcelBoxStatus, // getStatus — poll
// Alarm Hub (→ Yale)
SetAlarmHubMode { mode: AlarmHubMode, bypass: Vec<String> },
SilenceAlarm,
GetAlarmPinCode { force_refresh: bool }, // cache-first keypad PIN read
SetAlarmPinCode { code: String, slot: Option<u8>, label: Option<String> },
// Sensors (→ SmartThings)
GetContactStatus,
GetMotionStatus,
GetWaterStatus,
GetSmokeStatus,
// Video Doorbell (→ SmartThings WebRTC)
InitiateWebRtcSession { client_sdp_offer: Option<String> },
CompleteWebRtcSession { session_id: String, sdp_answer: String, ice_candidates: Vec<String> },
EndWebRtcSession { session_id: String, device_id: String },
SetTalkback { session_id: String, enabled: bool },
// Switches (→ SmartThings)
TurnOn,
TurnOff,
GetPowerStatus,
// Samsung Appliances (→ SmartThings)
SetOvenMode { mode: OvenMode, temperature: Option<f64> },
SetOvenTimer { minutes: u32 },
StartCooking,
StopCooking,
SetHobPower { zone: u8, power_level: u8 },
StartDishwasher { program: DishwasherProgram },
StartWashing { program: WashingProgram, temperature: u8 },
StartDrying { program: DryingProgram },
SetFridgeTemperature { temperature: f64 },
SetFreezerTemperature { temperature: f64 },
// Energy (→ SmartThings)
GetEnergyMeasurement,
GetPowerMeasurement,
// General
Refresh,
}
DeviceStateData
Type-safe state representation per device:
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "device_type", rename_all = "snake_case")]
pub enum DeviceStateData {
Lock {
locked: bool,
contact: Option<ContactState>,
battery: Option<u8>,
},
Thermostat {
current_temperature: f64,
heating_setpoint: f64,
cooling_setpoint: f64,
mode: ThermostatMode,
fan_mode: ThermostatFanMode,
humidity: Option<f64>,
battery: Option<u8>,
},
DaikinThermostat {
climate_on: bool,
operation_mode: DaikinOperationMode, // Heating, Cooling, Auto
room_temperature: f64,
room_temperature_setpoint: f64,
outdoor_temperature: Option<f64>,
hot_water_on: bool,
hot_water_temperature: f64,
hot_water_setpoint: f64,
leaving_water_temperature: Option<f64>,
last_polled: Option<i64>,
},
ParcelBox {
box_status: ParcelBoxStatus, // Locked, AllowDelivery, DeliveryRequested, Clearing, Error
lock_status: bool, // true = locked
lid_open: bool,
parcel_count: u32,
connected: bool, // box online
battery: Option<u8>, // backup battery %
charging: Option<String>, // "Off" / …
asleep: Option<bool>,
local_api_enabled: Option<bool>, // should be false post-setup
router_rssi: Option<i32>,
last_opened: Option<String>, // vendor "HH:MM:SS DD/MM/YYYY" string, passed through
error_reason: Option<String>, // set when box_status == Error
last_polled: Option<i64>,
},
VideoDoorbellPro {
streaming: bool,
last_pressed: Option<i64>,
battery: Option<u8>,
active_session_id: Option<String>,
webrtc_offer: Option<String>,
ice_servers: Option<Vec<IceServer>>,
talkback_active: bool,
},
AlarmHub {
mode: AlarmHubMode,
alarm_active: bool,
smoke_detected: bool,
alarm_reason: Option<AlarmReason>,
alarm_reason_details: Option<AlarmReasonDetails>,
faults: Vec<AlarmFault>,
battery: Option<u8>,
},
// Transient result of Get/SetAlarmPinCode — NOT persisted to `device_states`
// (it would clobber the hub's live AlarmHub state for the same device_id).
// Cached in the bf_user `alarm_pin_cache` table instead. Mirrors how
// `YalePinSync` is returned to the caller without being written as state.
AlarmPinCode {
pin_code: Option<String>,
slot: Option<u8>,
label: Option<String>,
cached: bool, // true when served from the DO cache, not a fresh Yale fetch
fetched_at: Option<i64>,
},
ContactSensor {
contact: ContactState,
battery: Option<u8>,
},
MotionSensor {
motion: bool,
battery: Option<u8>,
},
HomeEnergyMeter {
power: f64, // Current power in watts
energy: f64, // Total energy in kWh
voltage: Option<f64>,
current: Option<f64>,
},
SmartPlug {
on: bool,
power: Option<f64>,
energy: Option<f64>,
},
// ... additional device types
}
SmartEvent Types
Events generated by device state changes:
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SmartEvent {
// Lock events
LockStatusChanged {
locked: bool,
method: Option<String>,
user_id: Option<String>,
},
// Sensor events
ContactChanged { contact: ContactState },
MotionDetected { motion: bool },
WaterDetected { water: bool },
SmokeDetected { smoke: bool },
// Door events
DoorLeftAjar { duration_seconds: u64 },
DoorbellPressed,
// Alarm events
AlarmHubModeChanged { mode: AlarmHubMode },
AlarmHubTriggered {
reason: AlarmReason,
details: Option<AlarmReasonDetails>,
},
AlarmHubOff,
AlarmHubSmokeChanged { smoke: bool },
// PIN code events
PinCodeManaged {
state: PinCodeState,
partner_user_id: Option<String>,
},
// Energy events
PowerMeasurement {
power: f64,
unit: String,
total_energy: Option<f64>,
},
// Video events
WebRtcSessionStarted {
session_id: String,
sdp_offer: String,
ice_servers: Vec<IceServer>,
},
WebRtcSessionEnded { session_id: String },
// Daikin events
DaikinClimateChanged {
climate_on: bool,
operation_mode: DaikinOperationMode,
room_temperature: f64,
room_temperature_setpoint: f64,
},
DaikinHotWaterChanged {
hot_water_on: bool,
hot_water_temperature: f64,
hot_water_setpoint: f64,
},
// Parcel box events (iParcelBox)
ParcelBoxDeliveryRequested, // boxStatus -> deliveryRequested (actionable)
ParcelBoxDelivered { parcel_count: u32 }, // a delivery completed (count increased)
ParcelBoxEmptied, // emptyBox / Clearing
ParcelBoxLocked, // back to Locked
ParcelBoxError { reason: String }, // boxStatus -> Error
ParcelBoxStatusChanged { // generic state delta (no notification)
box_status: ParcelBoxStatus,
lock_status: bool,
lid_open: bool,
parcel_count: u32,
connected: bool,
},
// System events
DeviceOnline, // ParcelBox reuses these off `connected`
DeviceOffline,
BatteryLow { battery: u8 }, // ParcelBox raises this off its backup battery
// General - returned from Refresh capability
StateUpdated { state: DeviceStateData },
}
Mock vs Production Mode
The crate supports both modes via feature flags:
// Build with mock support
cargo build --features mock
// Production build (no mock code included)
cargo build --release
Mock mode (#[cfg(feature = "mock")]):
- Simulates device behavior in-memory
- Persists state to D1 SQLite
- Deterministic state transitions
- Battery values randomized (70-100%)
- Useful for testing and development
Production mode:
- Routes to real platform APIs
- Uses actual credentials
- Handles real device responses
- Mock code completely excluded from binary
SmartThings Client
pub const SMARTTHINGS_API_BASE_URL: &str = "https://api.smartthings.com/v1";
pub struct SmartThingsClient {
pub jwt_token: String, // Short-lived (1 hour)
pub location_id: String,
}
impl SmartThingsClient {
// Command builders for common operations
pub fn build_lock_command() -> CommandRequest;
pub fn build_unlock_command() -> CommandRequest;
pub fn build_refresh_command() -> CommandRequest;
pub fn build_switch_on_command() -> CommandRequest;
pub fn build_thermostat_setpoint_command(temp: f64) -> CommandRequest;
// Execute command on device
pub async fn execute_command(
&self,
device_id: &str,
command: CommandRequest,
) -> Result<CommandResponse>;
}
Yale Client
pub const YALE_API_BASE_URL: &str = "https://api.aaecosystem.com";
pub struct YaleClient {
pub api_key: String,
pub oauth_token: String,
}
impl YaleClient {
// Lock operations
pub async fn remote_operate(&self, lock_id: &str, command: &str) -> Result<()>;
// PIN code management
pub async fn load_pin(&self, lock_id: &str, pin: YalePinCommand) -> Result<()>;
pub async fn delete_pin(&self, lock_id: &str, partner_user_id: &str) -> Result<()>;
pub async fn list_pins(&self, lock_id: &str) -> Result<Vec<YalePin>>;
// Alarm hub
pub async fn get_house_state(&self, house_id: &str) -> Result<YaleHouseState>;
pub async fn set_house_state(&self, house_id: &str, mode: &str) -> Result<()>;
}
Daikin Client
pub const DAIKIN_API_BASE_URL: &str = "https://api.onecta.daikineurope.com/v1";
pub struct DaikinClient {
pub oauth_token: String,
}
impl DaikinClient {
// Fetch all gateway devices (covers all Daikin devices in one call)
pub async fn get_gateway_devices(&self) -> Result<Vec<DaikinGatewayDevice>>;
// Set a characteristic on a specific management point
pub async fn set_characteristic(
&self,
device_id: &str,
embedded_id: &str, // e.g., "climateControl" or "domesticHotWaterTank"
characteristic: &str, // e.g., "onOffMode", "temperatureControl"
body: serde_json::Value,
) -> Result<()>;
}
Daikin Management Points
Daikin devices expose functionality through management points:
| Management Point | Embedded ID | Controls |
|---|---|---|
| Climate Control | climateControl (mainZone) | On/off, operation mode, room temperature setpoint |
| Hot Water Tank | domesticHotWaterTank | On/off, hot water temperature setpoint |
Daikin State Refresh
DaikinThermostat devices bypass the SmartThings refresh system and use the Daikin Onecta API directly:
- Polling interval: Reuses the existing
DeviceRefreshscheduled task (1-hour interval) - Staleness threshold: 60 seconds (same as SmartThings)
- API efficiency: A single
GET /gateway-devicescall returns state for all Daikin devices - Rate budget: At 1-hour polling = 24 GET requests/day, leaving ~176/day for SET operations (200/day limit)
Optimistic Updates
Daikin SET operations use optimistic state updates instead of re-fetching:
- PATCH the Daikin API to change a setting
- If PATCH succeeds, return
ExecuteCapabilityResult::success(state)with the SET value applied to the current cached state - No extra GET call — 1 API call per SET operation
- The next hourly
DeviceRefreshreconciles with real API state
This contrasts with SmartThings, which clears cached state and re-fetches after commands.
ParcelBox Client
pub const PARCELBOX_API_BASE: &str = "https://api.iparcelbox.com";
pub struct ParcelBoxClient {
pub api_key: String, // org-wide x-api-key
pub device_password: String, // per-device x-device-password
}
impl ParcelBoxClient {
// Full status JSON → DeviceStateData::ParcelBox
pub async fn get_status(&self, device_id: &str) -> Result<ParcelBoxDeviceState>;
// allowDelivery / emptyBox / lockBox
pub async fn send_command(&self, device_id: &str, command: &str) -> Result<()>;
// Setup-only commands
pub async fn set_webhook(&self, device_id: &str, url: &str) -> Result<()>; // box reboots
pub async fn disable_local_api(&self, device_id: &str) -> Result<()>; // localapidisable
pub async fn enable_local_api(&self, device_id: &str) -> Result<()>; // support / un-setup
}
ParcelBox is vendor-API-only (like Daikin): DeviceType::ParcelBox.smartthings_capabilities() == &[] and it is excluded from requires_smartthings_id(). It is not a lock and not a climate device — it has its own state machine and command verbs. Writes are optimistic (same pattern as Daikin): POST the command, then return a DeviceStateData::ParcelBox with the expected new box_status applied (e.g. ParcelBoxLock → Locked, lock_status: true; ParcelBoxEmpty → Clearing, parcel_count: 0); the next webhook or hourly poll reconciles.
iParcelBox Cloud API
The integration uses the cloud API (the Workers backend cannot reach a box on a customer LAN). All facts below are verified against the iParcelBox Tenant App Provisioning doc.
Transport
Every command is:
POST https://api.iparcelbox.com/rpc/{COMMAND}?DeviceID={DEVICE_ID}
x-api-key: {API_KEY} // org-wide
x-device-password: {DEVICE_PASSWORD} // per-device
Content-Type: application/json
A body ({"params":{"url":"…"}}) is sent only for setWebhook. Throttle: 200 req/s, 10,000 req/day per API_KEY (fleet-shared) — keep webhooks primary and lengthen the parcel-box poll cadence as the fleet grows. An active/trialing Premium subscription per device (started by provisioning) is required.
Command → Capability Mapping
| iParcelBox command | Our DeviceCapability | Notes |
|---|---|---|
getStatus | GetParcelBoxStatus | full status JSON; hourly backstop poll (like GetDaikinState) |
allowDelivery | ParcelBoxAllowDelivery | accept the requested delivery |
emptyBox | ParcelBoxEmpty | unlock to remove parcels, reset count → Clearing |
lockBox | ParcelBoxLock | lock / cancel clearing → Locked |
setWebhook | (setup only) | body {"params":{"url":"…"}}; device reboots |
localapidisable | (setup only) | disables the on-LAN local API (cloud-only) |
/provision | (setup only) | POST /provision; assigns ownership, returns DeviceID (MAC) |
boxStatus State Machine
The vendor's boxStatus strings (mixed casing) are parsed explicitly via ParcelBoxStatus::from_vendor and stored as the canonical camelCase enum:
Vendor boxStatus | ParcelBoxStatus | Meaning |
|---|---|---|
Locked | Locked | Default "waiting for delivery" |
allowDelivery | AllowDelivery | Delivery being made (accepting) |
deliveryRequested | DeliveryRequested | Courier needs permission (the actionable alert) |
Clearing | Clearing | Emptying the box (after emptyBox) |
Error | Error | E.g. box opened when locked, lid left open |
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] // our canonical form; NOT used to parse vendor strings
pub enum ParcelBoxStatus { Locked, AllowDelivery, DeliveryRequested, Clearing, Error }
The vendor mixes casing (
Locked/Clearing/Errorcapitalised,allowDelivery/deliveryRequestedcamel), so vendor → enum is an explicitmatchon the exact strings, not serde-driven. We serialize our own canonical camelCase for storage and clients.
State, Webhooks & Polling
getStatus.data carries boxStatus, lockStatus, lidStatus, connected, localAPI_enabled, parcelCount, lastOpened, routerRSSI, and — because the box has a backup battery — battery, asleep, charging. So ParcelBox participates in BatteryLow and the battery-status endpoint like a Lock.
Webhooks are the primary freshness mechanism: the box POSTs the same getStatus JSON (incl. top-level device MAC) to a registered URL, authenticated by an unguessable per-box URL token (no signature — see webhook-handling.md). The hourly DeviceRefresh task polls GetParcelBoxStatus as a backstop, subject to the 10k/day fleet quota.
See Also
- device-resolution.md - How devices are matched to external IDs
- webhook-handling.md - Event processing from external platforms
- notification-system.md - How events become notifications