# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

- `npm run build` — clean `dist/` and compile TypeScript (`src/` → `dist/`). The published entry point is `dist/index.js`.
- `npm run build-watch` — incremental compile.
- `npm run lint` — `eslint src/**.ts --max-warnings=0`. Lint must be clean to publish (`prepublishOnly` runs lint + build).
- `npx ts-node src/test.ts` — standalone harness that talks to real devices. Edit the hardcoded IP list at `src/test.ts:26` before running. There is no unit test framework.

To exercise the plugin in Homebridge during development, point a local Homebridge instance at the built `dist/` (e.g. `npm link` into the Homebridge install). The plugin registers under platform name `Daikin Local Platform` (see `src/settings.ts`).

## Architecture

This is a Homebridge **dynamic platform plugin**. Three layers, deliberately separated:

1. **`src/index.ts` → `src/platform.ts` (`DaikinPlatform`)** — Homebridge entry. On `DID_FINISH_LAUNCHING` it reads `climateIPs` (and optional `climateKeys`, an array of `{"<ip>": "<13-digit key>"}` map entries flattened into an ip→key lookup) from config, calls `DaikinLocalAPI.fetchDevices`, then for each returned device either restores the cached `PlatformAccessory` (matched by UUID derived from MAC address) or registers a new one. Cached accessories whose MAC is no longer present are unregistered. Each accessory is wrapped in a `ClimateAccessory`. The optional `climateCoolingOnly` config array (climateIPs entries of units to expose as cooling-only) is served to the accessory layer via `isCoolingOnly()`, and `climateSwingSwitches` (units that get the per-axis swing switches) via `isSwingSwitchesEnabled()`; both match the exact entry first, then the bare IP (shared `configListHas`).

2. **Device protocol layer** (no Homebridge dependency) — an abstract `DaikinDevice` base class (`src/daikin-device.ts`) with one subclass per wire protocol. `DaikinLocalAPI.fetchDevices` (`src/daikin-local.ts`) auto-detects the protocol per IP by calling each subclass's `probe()` — dsiot first, then legacy, with BRP072C unshifted to the front when the IP has a `climateKeys` entry — mirroring pydaikin's factory order. Probe failures log an info line on the first attempt (`does not answer the X protocol`) so users can see what was ruled out. The base class owns the query throttle/coalescing (`queryDevice`), `fetchDeviceStatus`, the status callback and the shared rate-limited axios client (`maxRequests: 1, perMilliseconds: 500`); subclasses implement `_doQuery`, `probe` and all getters/setters. **All subclasses exchange values in the shared `CLIMATE_MODE_*` / `CLIMATE_FAN_SPEED_*` hex codes** so the accessory layer is protocol-agnostic — a legacy device translates at its boundary.

   - **`DaikinDsiotDevice` (`src/daikin-local.ts`)** — newer firmware (2.8.0+). POSTs to `http://<ip>/dsiot/multireq` (see `src/const.ts`). The response is a deeply nested `{responses: [{fr, pc: {pch: [...]}}]}` tree; **all reads go through `extractValue` / `extractObject`**, which walk the tree by `fr` (resource path like `/dsiot/edge/adr_0100.dgc_status`) plus a `/`-separated `pn` path (e.g. `e_1002/e_3001/p_01`). All writes go through `sendCommand`, which wraps a `pch` payload under `e_1002` and POSTs with `op: 3`. Protocol details: target temperature / fan speed live under mode-dependent `pn` keys (heating=`p_03`, cooling=`p_02`, auto=`p_1D`, fan=`p_28`, dehumidify=`p_27`) — the mode/key maps `TARGET_TEMP_PN_BY_MODE` / `FAN_SPEED_PN_BY_MODE` are shared by the getters and setters. Temperatures are hex-encoded: read `parseInt(hex, 16) / 2.0`, written `(temp * 2).toString(16)`. Vane swing is also per-mode and per-axis (`SWING_PN_BY_MODE`: cooling `p_05`/`p_06`, heating `p_07`/`p_08`, auto `p_20`/`p_21`, dry `p_22`/`p_23`, fan `p_24`/`p_25` — vertical/horizontal): the value's first byte selects swing (`0F`) vs fixed (`00`), the remaining bytes store the fixed vane position and their count varies per axis/firmware, so `setSwing` flips only the first byte and preserves the tail. Axis capability = the property exists and its `md.mx` bitmask (little-endian, bit n = first-byte value n accepted — same encoding as the mode mask) accepts `0x0F`.
   - **`DaikinBRP069Device` (`src/daikin-brp069.ts`)** — legacy query-string protocol (BRP069-era adapters, the ones Home Assistant's pydaikin supports). Reads `GET /common/basic_info`, `/aircon/get_control_info`, `/aircon/get_sensor_info`, `/aircon/get_model_info` (comma-separated `key=value` bodies parsed by `parseResponse`; `basic_info`/`model_info` are static and fetched once). All writes go through `sendControl`, which **must send the full `pow/mode/stemp/shum(/f_rate/f_dir)` state in one `GET /aircon/set_control_info`**: it re-reads control info, merges overrides (`mergeControlValues`, which fills unspecified values from the unit's per-mode memory keys `dt<mode>`/`dh<mode>`/`dfr<mode>`) and builds the query (`buildControlParams`) — same semantics as pydaikin's `_update_settings()`. Legacy codes are translated to the shared constants at this boundary (mode `3`↔`CLIMATE_MODE_COOLING`, `f_rate` `A`↔`CLIMATE_FAN_SPEED_AUTO`, ...). The wire-code lookup tables live in overridable protected fields (`climateModeByDeviceMode` etc.); `getResource`/`mergeControlValues`/`buildControlParams` are the designed override points for sibling firmwares, and `getBaseUrl`/`getExtraHeaders`/`getHttpsAgent` (all used by `requestResource`) are the transport override points. `_doQuery` reports a failed required resource at error level with its HTTP status; a 404 on `get_control_info` with `basic_info` present triggers `logSecureAdapterHint()` (the BRP072C signature). If `getResource` throws, it is an axios error — non-2xx statuses come from axios's default `validateStatus`.
   - **`DaikinAirBaseDevice` (`src/daikin-airbase.ts`)** — AirBase BRP15B61 (Australian ducted), subclass of `DaikinBRP069Device` exactly like pydaikin's `DaikinAirBase(DaikinBRP069)`. Differences, all expressed via the override points above: every path gets a `skyfi/` prefix; **different mode numbering** (`0`=fan `1`=heat `2`=cool `3`=auto `7`=dry — do not confuse with BRP069's); fans have 3 speeds (`f_rate` 1/3/5) plus a separate `f_auto` flag (auto is *not* an `f_rate` value); `set_control_info` requires the fixed parameter set `f_airside/f_auto/f_dir/f_rate/lpw/mode/pow/shum/stemp` on every call. Units usually lack `hhum`/`shum` and report `otemp=-` when no outdoor sensor exists. Zone control (`get/set_zone_setting`) is not implemented.
   - **`DaikinBRP072CDevice` (`src/daikin-brp072c.ts`)** — secure BRP072C-style adapters (Daikin Comfort Control app; e.g. US ATMOSPHERA built-in WiFi), subclass of `DaikinBRP069Device` like pydaikin's `DaikinBRP072C(DaikinBRP069)`. Same wire protocol, different transport: HTTPS with a per-device `https.Agent` carrying legacy-TLS options (`rejectUnauthorized: false`, `SSL_OP_LEGACY_SERVER_CONNECT`, `SECLEVEL=0`, `minVersion: TLSv1` — required on Node 18+/OpenSSL 3), an `X-Daikin-uuid` header, and a one-time `GET /common/register_terminal?key=<13-digit key>` before the first request (`register()`, re-armed whenever a query fails so an adapter reboot self-heals; wrong key ⇒ HTTP 403, logged at error level). The uuid defaults to a deterministic RFC-4122 v3 hex uuid derived from the plugin name (pydaikin does the same with `'pydaikin'`) so reinstalls don't exhaust the adapter's ~10 registration slots. These units serve only `basic_info` over plain HTTP, which is what `logSecureAdapterHint()` in the BRP069 class detects. Requires the user-supplied key from `climateKeys`; it is never auto-probed without one.

   Shared behaviour worth knowing: `queryDevice` self-throttles via `_lastUpdateTimestamp` + `MIN_REQUEST_INTERVAL_MS` (`const.ts`); pass `bForce=true` after a write so the next read sees fresh state (`sendCommand`/`sendControl` already do this). Mode codes and fan-speed codes are 4-char hex strings exported as constants from `src/daikin-device.ts` (re-exported by `daikin-local.ts`) — use them, don't inline literals. An IP entry may carry a port (`"192.168.1.5:8080"`), since URLs are built by string interpolation.

3. **`src/accessories/climate.ts` (`ClimateAccessory`)** — Maps a `DaikinDevice` onto a HomeKit `HeaterCooler` service plus auxiliary services. It registers a `setInterval` of `DEVICE_STATUS_REFRESH_INTERVAL` (30s) that calls `fetchDeviceStatus`, and the device's callback (`setCallback`) pushes new values back into HomeKit characteristics. When adding a new HomeKit characteristic, wire `onGet`/`onSet` here and update the callback handler so the periodic refresh propagates changes. The HeaterCooler mode menu is per-device: `validValues` on `TargetHeaterCoolerState` and the optional threshold characteristics follow `supportsOperationMode()` (detected from the dsiot `md.mx` bitmask), then the `climateCoolingOnly` override (`platform.isCoolingOnly(device.IP)`) forces heat/auto off and cool on — it only hides modes, never adds one. Vane swing: the HeaterCooler gets the binary `SwingMode` characteristic when the device supports at least one axis (enabled = every supported axis on, i.e. 3D; disabled = all off), and `climateSwingSwitches` (`platform.isSwingSwitchesEnabled`) additionally exposes one `Switch` service per supported axis (subtypes `swing-vertical`/`swing-horizontal`) for the remaining combinations — HomeKit has no native four-way swing selector. Removed optional characteristics must not be touched by `updateDeviceStatus` (`updateCharacteristic` would silently re-add them), hence the `_supportsHeat`/`_supportsCool`/`_supportsSwing*` gates there. Default names of plugin-created services (outdoor temperature sensor, swing switches, the unnamed-device fallback) are localized via `src/i18n.ts` (`language` config field, same codes as the Homebridge UI languages, fallback exact → base language → `en`); `applyDefaultServiceName` names new services fully (`Name`+`ConfiguredName`); a restored service is renamed only when its name is still a plugin default in *any* language (`isDefaultServiceName`) **and** differs from the wanted one — the equality bail-out is upgrade safety: pre-1.5.1 outdoor sensors have no `ConfiguredName`, and adding one without changing the visible name could clobber a Home-app rename (those live only in HomeKit's database). So upgrades change nothing until the user picks a language, and Home-app renames survive restarts and language changes.

## Conventions

- Logging goes through `DaikinPlatformLogger` (`src/logger.ts`), which gates `debug` on `platformConfig.debugMode`. Don't log via `console.*`.
- `config.schema.json` has `customUi: true`: `homebridge-ui/public/index.html` renders the **entire** settings page (`showSchemaForm()` is deliberately not called, so the schema-driven form stays hidden — it could only render below the custom sections, which made the layout order illogical). Sections in task order: *Devices* (one unified list — climateIPs rows enriched with scan results, discovered-but-unconfigured units in the same list with an add icon, per-row icon buttons edit/remove/add as inline SVG, auto-scan on page open plus a rescan button, `needs key`/`no reply`/`found`/`unused key` badges — `needs key` is itself a button that opens the edit form with the key field focused) and *Advanced* (collapsed; name + the *HomeKit name language* dropdown + debugMode — the dropdown mirrors Config UI X's own language list (codes and labels), preselects `config.language` or else the live UI language via `homebridge.i18nCurrentLang()`, and `sync()` always writes the resolved value so the runtime sees the same default). There are **no separate sections for per-device settings**: they live in the device's inline edit form as option rows (title + description + switch; `switchControl`/`optionRow` builders). Opening an edit snapshots the row into `row.orig` — keystrokes sync to config live, so Cancel/Esc write the snapshot back; Done/Cancel/Remove are the form's buttons, and the IP/key fields carry soft warn-only validation (`looksLikeIpEntry`/`looksLikeKey` — red hint + `is-invalid`, never blocking, since hostnames are legal). *Secure adapter* shows the 13-digit key field when the scan reports `secure`, a key already exists (switch locked on), or the user switches it on (for units the scan can't reach); *Cooling only* (`row.coolOnly` → `climateCoolingOnly`) hides Heating/Auto in HomeKit and previews the resulting Home-app mode menu inline, and shows as a `cool only` chip (snowflake SVG — icons are all inline SVG, no emoji) on the device row; *Swing switches* (`row.swingSwitches` → `climateSwingSwitches`, same flag machinery via `adoptOrphanFlag`/`loadFlagList`, `swing` chip) adds the per-axis HomeKit switches. Keys are written to `climateKeys` (its `[{"<ip>": "<key>"}]` shape) and the per-device flags to `climateCoolingOnly`/`climateSwingSwitches` (plain string arrays), all keyed by the **exact `climateIPs` entry, port included** — that's how the platform looks them up; at load, entries are matched to device rows exact-first then by bare IP (repairing older configs). Key entries matching no device render as `unused key` orphan rows (adopt-as-device or delete — never dropped silently); unmatched cooling-only entries are carried invisibly (`orphanCoolingOnly`, written back verbatim, re-adopted when the device reappears). Removing a device goes through the same machinery (`stashOrphans`): its key becomes a visible orphan row and its cooling-only flag rides along invisibly — deleting a key is always its own explicit action. **A new config field must be added both to `config.schema.json` and to this page.** Every write is a read-modify-write against `getPluginConfig()` so fields the page doesn't own (e.g. Homebridge's `_bridge` child-bridge block) are preserved. The finder button calls `homebridge.request('/discover')`, served by `homebridge-ui/server.js` (a `HomebridgePluginUiServer` from `@homebridge/plugin-ui-utils`, run on the Homebridge host by Config UI X — never part of the plugin runtime); `homebridge-ui/discover.js` implements the Daikin UDP discovery (broadcast `DAIKIN_UDP/common/basic_info` from source port 30000 to port 30050, probe repeated 3× since UDP is lossy, replies parsed like `basic_info`; dsiot, BRP069 and AirBase units all answer it, and `en_secure=1` in a reply marks a BRP072C needing a key). `homebridge-ui/` ships in the npm package (`.npmignore` only excludes `.github`); eslint/tsc don't cover it, so syntax-check with `node --check`. Theming: Config UI X mirrors its theme into the iframe only as **body classes** (`config-ui-x-<theme>` / `config-ui-x-dark-mode-<theme>`, plus `dark-mode` when dark — dark cards are `#2b2b2b` with white text), while the injected Bootstrap 5.3 sheet keeps `:root` in light theme; so light-theme Bootstrap colours (e.g. `.text-muted`'s `--bs-secondary-color`) can be unreadable in dark mode — override them under `body.dark-mode` (see the `<style>` block in `index.html`) and prefer `color: inherit`/opacity over hardcoded colours.
- `tsconfig.json` has `strict: true` but `noImplicitAny: false` — the response-tree code in `daikin-local.ts` relies on this for indexed access into untyped JSON.
- Bumping the plugin version: update `package.json` `version`. Releases are tagged commits like `1.2.6` (see `git log`).
