| # Commissioning Proxy Cluster |
| |
| The Commissioning Proxy cluster (cluster ID 0x0455) provides a proxy service |
| that allows a Commissioner to use commissioning transports not supported |
| locally, or to extend its commissioning range. Commissioners can use the proxy |
| to discover and establish a connection to commissionable devices that are |
| reachable by the proxy. The proxy connection acts as a tunnel through which the |
| Commissioner can run a PASE session with the commissionable device and |
| ultimately commission it. |
| |
| ## Overview |
| |
| This directory contains an implementation of the Matter Commissioning Proxy |
| cluster server (`CommissioningProxyCluster.{h,cpp}`) using the code-driven |
| `DefaultServerCluster` pattern. |
| |
| The cluster owns **all transport-agnostic behavior**: command validation, |
| feature-based attribute/command gating, transport/WiFiBand constraint checks, |
| the writable-attribute storage and change-reporting, and — via three subsystems |
| it composes — proxy-session bookkeeping, `ProxyMessage` routing, the |
| background-scan result cache, and multi-transport `ProxyScanRequest` |
| aggregation: |
| |
| | Subsystem | Responsibility | |
| | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | |
| | `CommissioningProxySessionManager` | Session-id allocation, id→{transport,fabric} table, per-fabric isolation, `ProxyMessage` routing + response-timeout timer | |
| | `CommissioningProxyScanCache` | `CachedResults` / `NumCachedResults` (one entry per device, per-entry TTL, `MaxCachedResults` cap) | |
| | `CommissioningProxyScanAggregator` | Combines a multi-transport `ProxyScanRequest` into one `ProxyScanResponse` | |
| |
| A fourth transport-agnostic component, `CommissioningProxyBgScanRegistry`, ships |
| with the cluster but is **instantiated per transport and owned by the driver** |
| rather than composed by the cluster: |
| `ProxyBackGroundScanStartRequest`/`ProxyBackGroundScanStopRequest` fan out to |
| every matching driver, so the per-fabric records they arbitrate are necessarily |
| transport-local. It holds the per-fabric scan requests and their lifetime |
| timers, the spec transport/band overlap arithmetic on Stop, and the |
| paused/deferred state used while the radio is held by a connect or foreground |
| scan — leaving the driver only the hardware start/stop. See |
| [Background scanning](#background-scanning). |
| |
| The application supplies **only the platform-specific transport work** by |
| implementing the `CommissioningProxyTransport` driver interface (one per |
| physical transport, e.g. BLE or Wi-Fi PAF) and registering it with the cluster. |
| There is no do-everything application delegate: static device capabilities are |
| passed to the constructor via `Config`, writable-attribute state is owned by the |
| cluster, and the driver is scoped to transport actions only. See |
| [Architecture](#architecture-why-a-driver-not-a-delegate) below. |
| |
| ### How it works |
| |
| A Commissioner that lacks (or is out of range of) the transport a commissionable |
| device advertises on uses the proxy as a tunnel. The flow is: |
| |
| 1. **Discover** — the Commissioner sends `ProxyScanRequest` (or subscribes to |
| the background-scan cache); the proxy scans its local transports and returns |
| the commissionable devices it can see as `ScanResultStruct` entries. |
| 2. **Connect** — the Commissioner sends `ProxyConnectRequest` naming a single |
| transport plus the target device (address/discriminator/VID/PID). The proxy |
| opens a transport connection to the device and replies with a unique |
| `SessionID` in the `ProxyConnectResponse`. |
| 3. **Tunnel** — the Commissioner drives the PASE handshake (and the rest of |
| commissioning) by sending each Matter packet in a `ProxyMessageRequest` keyed |
| by `SessionID`; the proxy forwards it over the transport and returns the |
| device's reply in the `ProxyMessageResponse`. The proxy is a dumb relay — the |
| PASE session is end-to-end between the Commissioner and the device. |
| 4. **Disconnect** — the Commissioner sends `ProxyDisconnectRequest` to cancel an |
| in-flight connect); the proxy tears the transport connection down. |
| |
| The cluster server itself is **transport-agnostic**: it validates the requested |
| transport against the set it advertises, then dispatches the work to the |
| registered `CommissioningProxyTransport` driver whose `GetTransportType()` |
| matches the request's transport bit. Today drivers exist for **BLE** (BTP) and |
| **Wi-Fi PAF** (PAFTP) — see the transport integration sections below. |
| |
| ## Features |
| |
| The cluster supports the following optional features: |
| |
| - **WiFiNetworkInterface (WI)**: Gates the Wi-Fi-band surface — the `WiFiBand` |
| attribute and the `WiFiBand(s)` command fields. It does **not** gate the |
| Wi-Fi PAF transport itself (see Transports below). |
| - **BackgroundScan (BGS)**: Allows background scanning of commissioning |
| transports, with results cached in the `CachedResults` / `NumCachedResults` |
| attributes. |
| |
| ## Transports |
| |
| The transports the proxy advertises in the `Transport` attribute (and accepts in |
| `ProxyConnectRequest` / `ProxyScanRequest`) are exactly the ones for which a |
| driver has been registered (`RegisterTransport`), independent of the `WI` |
| feature. An application typically registers a driver per compiled-in transport: |
| |
| | Transport (`CapabilitiesBitmap`) | Driver registered when | Wire protocol | |
| | -------------------------------- | ----------------------------------- | -------------------- | |
| | `kBle` (BTP) | `CONFIG_NETWORK_LAYER_BLE` | BTP over BLE | |
| | `kWiFiPAF` (PAFTP) | `CHIP_DEVICE_CONFIG_ENABLE_WIFIPAF` | PAFTP over Wi-Fi NAN | |
| |
| `ProxyConnectRequest` selects **exactly one** transport per session. A request |
| naming a transport with no registered driver is rejected with |
| `INVALID_TRANSPORT_TYPE`. (The spec's `NTL` bit is not currently supported.) |
| |
| ## Architecture: why a driver, not a delegate |
| |
| Per the SDK cluster guidance, a cluster that triggers platform-specific actions |
| uses a **Delegate (or Driver) interface**, while a cluster's own attribute state |
| is pushed through setters rather than fetched from a delegate. This cluster |
| follows that split precisely, so instead of one do-everything delegate there are |
| four homes for what such a delegate would otherwise hold: |
| |
| | Concern | Lives in | Why | |
| | ----------------------------------------------------------------- | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | |
| | Transport actions (Connect / Scan / SendMessage / Disconnect / …) | `CommissioningProxyTransport` driver | The only genuinely platform-specific surface (BlueZ GATT, wpa_supplicant NAN). | |
| | Writable attributes (`ScanMaxTime`, `CacheTimeout`) + cache view | The cluster (members + setters) and its subsystems | The cluster owns change-reporting, so a driver can never forget `NotifyAttributeChanged`. | |
| | Static capabilities (`MaxSessions`, `MaxCachedResults`, bands) | `Config` (constructor argument) | Fixed device facts, not actions. | |
| | Background-scan fabric records, band arithmetic, pause/resume | `CommissioningProxyBgScanRegistry` (one per driver) | Transport-agnostic logic, but held per transport because background Start/Stop fan out to every matching driver. | |
| |
| **How multiple platforms plug in.** A platform provides one |
| `CommissioningProxyTransport` implementation per physical transport and |
| registers it with `RegisterTransport()`. The cluster dispatches each command to |
| the driver whose `GetTransportType()` matches the request's transport bit, and |
| drivers report async results back through the cluster's shared subsystems |
| (`Sessions()`, `ScanCache()`, `ScanAggregator()`). Background scanning is |
| inherited the same way: the driver holds a `CommissioningProxyBgScanRegistry` |
| and implements its three-method `HardwareControl` hook. A new platform therefore |
| writes only its GATT/NAN driver and inherits all session/scan/message |
| bookkeeping unchanged. |
| |
| `RegisterTransport()` is used (rather than a single constructor-injected |
| delegate) because there can be several drivers — one per transport — and they |
| are typically constructed after the cluster, whose endpoint is only known at |
| registration time. |
| |
| ## Usage |
| |
| For new applications using the `CodeDrivenDataModelProvider`, instantiate and |
| register the cluster directly. This provides the most flexibility and control. |
| |
| ### 1. Implement a transport driver |
| |
| Create a class that inherits from |
| `chip::app::Clusters::CommissioningProxy::CommissioningProxyTransport` and |
| implement its virtual methods — one class per physical transport. A driver only |
| handles the transport-specific work; the cluster performs all spec validation |
| first and owns the session/scan/message bookkeeping. Methods run on the Matter |
| task; the driver reports async results back through its host cluster (given in |
| `SetHost`) via `Sessions()`, `ScanCache()`, and `ScanAggregator()`. |
| |
| ```cpp |
| #include <app/clusters/commissioning-proxy-server/CommissioningProxyTransport.h> |
| |
| class MyBleTransport : public chip::app::Clusters::CommissioningProxy::CommissioningProxyTransport |
| { |
| public: |
| CapabilitiesBitmap GetTransportType() const override { return CapabilitiesBitmap::kBle; } |
| void SetHost(CommissioningProxyCluster * cluster) override { mHost = cluster; } |
| |
| // Open a transport connection. On success, allocate a session via |
| // mHost->Sessions().AllocSessionId()/RegisterSession() and call |
| // commandObj->AddResponse() with a ProxyConnectResponse carrying the sessionID. |
| Protocols::InteractionModel::Status Connect(chip::app::CommandHandler * commandObj, |
| const DataModel::InvokeRequest & request, uint16_t discriminator, |
| System::Clock::Seconds16 timeout) override; |
| |
| // Forward a Matter packet; deliver the reply via |
| // mHost->Sessions().DispatchMessageResponse(). |
| CHIP_ERROR SendMessage(uint16_t sessionId, chip::System::PacketBufferHandle && buf) override; |
| |
| // Report foreground-scan results to mHost->ScanAggregator().Contribute(); |
| // background-scan results to mHost->ScanCache().Report(). |
| Protocols::InteractionModel::Status Scan(System::Clock::Seconds16 scanMaxTime) override; |
| |
| // Background scan: forward to the driver's CommissioningProxyBgScanRegistry, |
| // which owns the per-fabric records, lifetime timers and band arithmetic |
| // (see Background scanning below). |
| Protocols::InteractionModel::Status BgScanStart(System::Clock::Seconds16 timeout, BitMask<WiFiBandBitmap> wiFiBands, |
| FabricIndex fabricIndex, NodeId nodeId) override; |
| Protocols::InteractionModel::Status BgScanStop(BitMask<CapabilitiesBitmap> transport, BitMask<WiFiBandBitmap> wiFiBands, |
| FabricIndex fabricIndex, NodeId nodeId) override; |
| |
| // ... CancelPendingConnect / Disconnect / OnAllSessionsClosed / |
| // IsConnectPending / Shutdown |
| |
| private: |
| CommissioningProxyCluster * mHost = nullptr; |
| }; |
| ``` |
| |
| ### 2. Instantiate the cluster and register the driver(s) |
| |
| Construct the `CommissioningProxyCluster` for the endpoint (the endpoint id is a |
| constructor argument; `Config` carries the fixed feature set and device |
| capabilities), then register a driver per available transport. |
| |
| ```cpp |
| #include <app/clusters/commissioning-proxy-server/CommissioningProxyCluster.h> |
| #include <app/server-cluster/ServerClusterInterfaceRegistry.h> |
| |
| using namespace chip::app::Clusters::CommissioningProxy; |
| |
| MyBleTransport gBleTransport; |
| |
| chip::BitMask<Feature> gFeatures(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface); |
| |
| // Supplies the response-timeout and scan-watchdog timers. |
| chip::app::DefaultTimerDelegate gTimerDelegate; |
| |
| // Config: featureFlags plus the supported Wi-Fi bands. MaxSessions and |
| // MaxCachedResults are Fixed-quality attributes and come from |
| // CHIP_CONFIG_COMMISSIONING_PROXY_MAX_SESSIONS / _MAX_CACHED_RESULTS. |
| chip::app::RegisteredServerCluster<CommissioningProxyCluster> gCPCluster( |
| CommissioningProxyEndpoint, CommissioningProxyCluster::Config(gFeatures), gTimerDelegate); |
| |
| void SetUpProxy() |
| { |
| gCPCluster.Cluster().RegisterTransport(gBleTransport); // before registration/Startup |
| } |
| ``` |
| |
| ### 3. Register the Cluster |
| |
| In your application's initialization sequence, register the cluster instance |
| with the `CodegenDataModelProvider`: |
| |
| ```cpp |
| #include <data-model-providers/codegen/CodegenDataModelProvider.h> |
| |
| void ApplicationInit() |
| { |
| VerifyOrDie(chip::app::CodegenDataModelProvider::Instance().Registry().Register( |
| gCPCluster.Registration()) == CHIP_NO_ERROR); |
| } |
| ``` |
| |
| A complete working example (device wiring plus the BLE and Wi-Fi PAF drivers) |
| lands with the example-app change later in this series. |
| |
| ## Transport driver methods |
| |
| A `CommissioningProxyTransport` driver implements the following. Each is invoked |
| only after the cluster has validated the request; the driver does the |
| transport-specific work and reports results back through the host cluster's |
| subsystems. |
| |
| | Method | Description | |
| | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | |
| | `GetTransportType()` | The single transport bit this driver services (`kBle` / `kWiFiPAF`) | |
| | `SetHost()` | Bind the host cluster (set to null at cluster teardown) | |
| | `Connect()` | Open a transport session; allocate + register it via `Sessions()` | |
| | `SendMessage()` | Forward a packet; reply routed back via `Sessions()` | |
| | `Scan()` | Foreground scan; results reported to `ScanAggregator()` | |
| | `BgScanStart()` | Start a background scan for the given `(fabricIndex, nodeId)`; normally forwarded to the driver's `CommissioningProxyBgScanRegistry::Start()`. Results reported to `ScanCache()` (BGS) | |
| | `BgScanStop()` | Stop a background scan for the given `(fabricIndex, nodeId)`; normally forwarded to `CommissioningProxyBgScanRegistry::Stop()` (BGS) | |
| | `CancelPendingConnect()` | Cancel an in-flight connect (null-SessionID disconnect) | |
| | `Disconnect()` | Tear down an active proxy session | |
| | `OnAllSessionsClosed()` | Notified when the last session across all transports closes | |
| | `IsConnectPending()` | Whether a connect is in flight (counted against `MaxSessions`) | |
| | `Shutdown()` | Cancel driver timers/state before cluster destruction | |
| |
| Note there is **no** `ProxyScanRequest`/`ProxyMessageRequest`/etc. delegate |
| hook: those commands' spec logic, session tracking, message routing, and scan |
| aggregation live in the cluster and its subsystems; the driver only exposes the |
| transport primitives above. |
| |
| ## Background scanning |
| |
| `ProxyBackGroundScanStartRequest`/`ProxyBackGroundScanStopRequest` are |
| per-fabric and MAY name several transports, so the cluster validates the request |
| (BGS/WI feature gating, reserved transport and band bits, supported bands) and |
| then fans it out to every registered driver whose transport bit is set, passing |
| the requesting fabric index and node id taken from the subject descriptor. Start |
| reports the first non-success status a driver returned; Stop returns `NOT_FOUND` |
| only when no driver had a matching record. |
| |
| Everything a driver then has to do that is _not_ radio work is provided by |
| `CommissioningProxyBgScanRegistry` — one instance per driver, constructed with a |
| reference to the driver's `HardwareControl` implementation: |
| |
| | Registry call | Responsibility | |
| | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | |
| | `Start()` | Add or refresh the `(fabricIndex, nodeId)` record and its transport/band mask, arm its lifetime timer, start the hardware if it is the first record | |
| | `Stop()` | Remove the requested transports/bands from the record; stop the hardware once no records remain | |
| | `Pause()` | Suspend the hardware scan while the radio is needed for a connect or foreground scan; records stay registered (idempotent) | |
| | `ResumeIfNeeded()` | Restart a paused scan once the radio is free; no-op if not paused or if no records remain | |
| | `Shutdown()` | Cancel every lifetime timer and stop the hardware scan if the registry owns it | |
| |
| `Start()` with `timeoutSecs == 0` means no lifetime timer, i.e. scan until an |
| explicit Stop. `Stop()` applies the spec's transport/band arithmetic: a zero |
| transport mask means "stop the listed bands only"; a record left with no |
| transports or no bands is removed; `SUCCESS` is returned even when nothing |
| overlapped, and `NOT_FOUND` only when the fabric has no record at all. |
| |
| The driver supplies the only transport-specific parts via `HardwareControl`: |
| |
| | Hook | Contract | |
| | ---------------------- | ------------------------------------------------------------------------------------------------------------------ | |
| | `StartHardwareScan()` | Start or resume the hardware scan, wiring the driver's own discovery callback (return codes below) | |
| | `StopHardwareScan()` | Stop the hardware scan; called only while the registry owns the radio, never while paused | |
| | `ClearCachedResults()` | Drop this transport's cached results (`host->ScanCache().ClearTransport(...)`) whenever the last record is removed | |
| |
| `StartHardwareScan()` returns `CHIP_NO_ERROR` when the scan is running, |
| `CHIP_ERROR_BUSY` when the radio is currently held — the registry keeps the |
| records, stays paused and retries on the next `ResumeIfNeeded()` — and any other |
| error is a hard failure that rejects the triggering `Start()`. |
| |
| A driver therefore wires background scanning up as a small `HardwareControl` |
| implementation plus one registry instance, and forwards the two driver methods |
| to it: |
| |
| ```cpp |
| #include <app/clusters/commissioning-proxy-server/CommissioningProxyBgScanRegistry.h> |
| |
| class MyBleBgScanHardware : public CommissioningProxyBgScanRegistry::HardwareControl |
| { |
| public: |
| // Returns CHIP_ERROR_BUSY when the single scanner is held by a connect or a |
| // foreground scan; the registry then defers and retries on resume. |
| CHIP_ERROR StartHardwareScan() override { return StartMyPlatformScan(OnBgScanDiscovery); } |
| void StopHardwareScan() override { StopMyPlatformScan(); } |
| void ClearCachedResults() override { sHost->ScanCache().ClearTransport(CapabilitiesBitmap::kBle); } |
| }; |
| |
| // Declared before the registry so it outlives it: the registry's destructor may |
| // call back into these hooks. |
| MyBleBgScanHardware sHardware; |
| CommissioningProxyBgScanRegistry sBgScan(sHardware); |
| |
| Status MyBleTransport::BgScanStart(System::Clock::Seconds16 timeout, BitMask<WiFiBandBitmap> wiFiBands, FabricIndex fabricIndex, |
| NodeId nodeId) |
| { |
| return sBgScan.Start(fabricIndex, nodeId, GetTransportType(), wiFiBands, timeout); |
| } |
| |
| Status MyBleTransport::BgScanStop(BitMask<CapabilitiesBitmap> transport, BitMask<WiFiBandBitmap> wiFiBands, |
| FabricIndex fabricIndex, NodeId nodeId) |
| { |
| return sBgScan.Stop(fabricIndex, nodeId, transport, wiFiBands); |
| } |
| ``` |
| |
| `CHIP_ERROR_BUSY` is the load-bearing case: on BLE the scanner is owned by a |
| connect or foreground scan, and on Wi-Fi PAF a `ProxyConnect` owns the single |
| NAN subscribe slot. A driver reports the conflict from `StartHardwareScan()`, |
| calls `Pause()` when it takes the radio and `ResumeIfNeeded()` when it releases |
| it, and the registry keeps the Commissioner's background scan registered across |
| the gap. `ResumeIfNeeded()` must be wrapped in `PlatformMgr().ScheduleWork()` if |
| the "radio freed" path could otherwise re-enter the driver. |
| |
| ## Async Command Handling |
| |
| `ProxyConnectRequest` and `ProxyMessageRequest` are asynchronous — they return |
| `std::nullopt` from `InvokeCommand` to prevent an immediate response, and call |
| `CommandHandler::AddResponse()` or `CommandHandler::AddStatus()` from a |
| transport callback once the operation completes. |
| |
| To keep the exchange alive across the async operation, store a |
| `CommandHandler::Handle` and extend the exchange response timeout: |
| |
| ```cpp |
| // Store the handle before returning nullopt |
| CommandHandler::Handle handle(commandObj); |
| if (auto * ec = commandObj->GetExchangeContext()) |
| { |
| ec->SetResponseTimeout(chip::System::Clock::Seconds16(responseTimeout + 10)); |
| } |
| // … return std::nullopt from InvokeCommand … |
| |
| // Later, in your transport callback: |
| auto * handler = handle.Get(); |
| if (handler != nullptr) |
| { |
| handler->AddResponse(commandPath, response); |
| } |
| ``` |
| |
| ## BLE Transport Integration |
| |
| When the build enables BLE (`CONFIG_NETWORK_LAYER_BLE`) the driver |
| (`CommissioningProxyBleTransport`) drives a BTP connection through |
| `chip::Ble::BleLayer`. The Linux example driver, landing with the example-app |
| change later in this series, |
| (`examples/all-devices-app/all-devices-common/device/types/commissioning-proxy/CommissioningProxyBleTransport.cpp`) |
| shows the full integration: |
| |
| - `Connect()` — on the first BLE connect the proxy flips its own BLE role from |
| peripheral to central via `BLEManagerImpl::SwitchToCentralMode()` (a one-way |
| switch; `IsCentralMode()` reports the state and central-mode advertising is |
| then refused), then calls `BleLayer::NewBleConnectionByDiscriminator()` to |
| open an L2CAP/BTP connection to the commissionee. |
| - `SendMessage()` — calls `BLEEndPoint::Send()` to push the tunneled |
| commissioning packet over BTP. |
| - `Disconnect()` — calls `BLEEndPoint::Close()` to drop the connection. |
| |
| Incoming BTP messages are routed back to the cluster via a `BleProxyDelegate` |
| (`chip::Ble::BleLayerDelegate`) that wraps the original `BleLayer` transport, |
| matches the connection against the active session map, and calls |
| `host->Sessions().DispatchMessageResponse()`. |
| |
| ## Wi-Fi PAF Transport Integration |
| |
| When the build enables Wi-Fi PAF (`CHIP_DEVICE_CONFIG_ENABLE_WIFIPAF`) the |
| driver (`CommissioningProxyPafTransport`) interacts with |
| `chip::WiFiPAF::WiFiPAFLayer` to open, send over, receive from, and close PAF |
| (NAN) sessions: |
| |
| - `Connect()` — calls `WiFiPAFLayer::WiFiPAFSubscribe()` to open a PAF session |
| identified by the commissionee discriminator and peer address. |
| - `SendMessage()` — calls `WiFiPAFLayer::SendMessage()` to send the tunneled |
| commissioning packet over PAFTP. |
| - `Disconnect()` — calls `WiFiPAFLayer::RmPafSession()` to release the PAF |
| session. |
| |
| Incoming PAF messages are routed back to the cluster via a |
| `WiFiPAFLayerDelegate` subclass that intercepts `WiFiPAFMessageReceived`, |
| matches the peer against the active session map, and calls |
| `host->Sessions().DispatchMessageResponse()`. |
| |
| ## Cluster State |
| |
| The cluster tracks proxy state internally: |
| |
| - `kState_CPDisconnected` — no active proxy session |
| - `kState_CPConnected` — a transport session (BLE or Wi-Fi PAF) is open and |
| ready to forward messages |
| |
| State transitions: |
| |
| ``` |
| ProxyConnectRequest ──► transport connect success ──► kState_CPConnected |
| kState_CPConnected ──► ProxyDisconnectRequest ──► kState_CPDisconnected |
| ``` |