mirror of
https://github.com/danieldemus/openhab-addons
synced 2026-07-29 12:34:21 +02:00
[unifi] Unifi family of addons (#20598)
* Unify UniFi Network, Protect and Access bindings Signed-off-by: Dan Cunningham <dan@digitaldan.com>
This commit is contained in:
+1
-3
@@ -421,10 +421,8 @@
|
||||
/bundles/org.openhab.binding.tradfri/ @cweitkamp @kaikreuzer
|
||||
/bundles/org.openhab.binding.tuya/ @J-N-K
|
||||
/bundles/org.openhab.binding.twilio/ @digitaldan
|
||||
/bundles/org.openhab.binding.unifi/ @mgbowman @Hilbrand
|
||||
/bundles/org.openhab.binding.unifiaccess/ @digitaldan
|
||||
/bundles/org.openhab.binding.unifi/ @digitaldan @mgbowman @Hilbrand
|
||||
/bundles/org.openhab.binding.unifiedremote/ @GiviMAD
|
||||
/bundles/org.openhab.binding.unifiprotect/ @digitaldan
|
||||
/bundles/org.openhab.binding.upb/ @marcusb
|
||||
/bundles/org.openhab.binding.upnpcontrol/ @mherwege
|
||||
/bundles/org.openhab.binding.urtsi/ @openhab/add-ons-maintainers
|
||||
|
||||
@@ -2091,21 +2091,11 @@
|
||||
<artifactId>org.openhab.binding.unifi</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.binding.unifiaccess</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.binding.unifiedremote</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.binding.unifiprotect</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.binding.upb</artifactId>
|
||||
|
||||
@@ -1,421 +1,146 @@
|
||||
# UniFi Binding
|
||||
|
||||
This binding integrates with [Ubiquiti UniFi Networks](https://www.ubnt.com/products/#unifi), allowing presence detection of network clients.
|
||||
The UniFi Binding integrates Ubiquiti UniFi devices into openHAB, covering the UniFi Network, UniFi Protect, and UniFi Access product families.
|
||||
|
||||
## Supported Things
|
||||
Installing this binding provides support for all three product families at once.
|
||||
Each family has its own documentation, thing types, and channels, but they share a single `unifi:controller` bridge that holds the credentials for your UniFi console.
|
||||
You configure the bridge once and attach Network, Protect, and Access things to it — one login per console, no matter how many devices you have.
|
||||
|
||||
- `controller` - An instance of the UniFi controller software
|
||||
- `site` - A site with connection statistics
|
||||
- `wlan` - A wireless network. Control the Wi‑Fi network and provide easy access via QR code.
|
||||
- `wirelessClient` - Any wireless client connected to a UniFi wireless network
|
||||
- `wiredClient` - A wired client connected to the UniFi network
|
||||
- `poePort` - A PoE (Power over Ethernet) port on a UniFi switch
|
||||
- `accessPoint` - An access point managed by the UniFi controller software
|
||||
- `network` - A network managed by the UniFi controller software.
|
||||
## Supported Bridges
|
||||
|
||||
## Discovery
|
||||
- **Controller**: This bridge represents a connection to a UniFi console.
|
||||
|
||||
The binding supports discovery of Things connected to a UniFi controller (bridge).
|
||||
To discover Things, start the discovery process manually.
|
||||
One bridge per console is recommended. Network, Protect, and Access things all attach to the same bridge and share its session.
|
||||
|
||||
## Binding Configuration
|
||||
|
||||
The binding has no global configuration options; all configuration is done at the bridge and Thing levels.
|
||||
> **Multiple Consoles** If you have more then one UniFi console, like a dedicated UNVR, each console will require its own `unifi:controller` bridge along with a user that exists on that console as each handles its own login and session management.
|
||||
|
||||
## Bridge Configuration
|
||||
|
||||
You need at least one UniFi Controller (bridge) for this binding to work.
|
||||
It requires a network accessible instance of the [Ubiquiti Networks Controller Software](https://www.ubnt.com/download/unifi).
|
||||
Required configuration parameters are:
|
||||
|
||||
The following table describes the Bridge configuration parameters:
|
||||
- **host**: Hostname or IP address of the UniFi console.
|
||||
- **username**: Local user account on the UniFi console.
|
||||
- **password**: Password for the local user account.
|
||||
|
||||
| Parameter | Description | Config | Default |
|
||||
|----------------|------------------------------------------------------------------------------|--------- |---------|
|
||||
| host | Hostname or IP address of the UniFi Controller | Required | unifi |
|
||||
| port | Port of the UniFi Controller. On UniFi OS, the default port is typically 443 | Optional | 8443 |
|
||||
| unifios | Whether the UniFi Controller is running on UniFi OS | Required | false |
|
||||
| username | The username to access the UniFi Controller | Required | - |
|
||||
| password | The password to access the UniFi Controller | Required | - |
|
||||
| refresh | Refresh interval in seconds | Optional | 10 |
|
||||
| timeoutSeconds | Request timeout in seconds. Increase if you experience timeout exceptions | Optional | 5 |
|
||||
Additional optional parameters:
|
||||
|
||||
## Thing Configuration
|
||||
- **port**: Port on which the console is listening. Defaults to `443` (modern UniFiOS consoles). Legacy stand-alone Network controllers typically use `8443` — set this explicitly for those.
|
||||
- **unifios**: Whether this is a UniFiOS console (UDM/UDR/UNVR/Cloud Key Gen2 Plus). Selects `/api/auth/login` (UniFiOS) vs `/api/login` (legacy). Defaults to `true`.
|
||||
- **timeoutSeconds**: HTTP request timeout in seconds. Defaults to `30`.
|
||||
|
||||
You must define a UniFi Controller (Bridge) before defining UniFi Things for this binding to work.
|
||||
## Usage
|
||||
|
||||
### `site`
|
||||
Configure a `unifi:controller` bridge, then attach things from any of the three product families to it.
|
||||
Each family has its own documentation:
|
||||
|
||||
The following table describes the `site` configuration parameters:
|
||||
|
||||
| Parameter | Description | Config | Default |
|
||||
| ------------ | -------------------------------------------------------------|--------- | ------- |
|
||||
| sid | The ID, name, or description of the site | Required | - |
|
||||
|
||||
### `wlan`
|
||||
|
||||
The following table describes the `wlan` configuration parameters:
|
||||
|
||||
| Parameter | Description | Config | Default |
|
||||
| ------------ |----------------------------|--------- | ------- |
|
||||
| wid | The name or ID of the WLAN | Required | - |
|
||||
|
||||
### `wirelessClient` & `wiredClient`
|
||||
|
||||
The following table describes the `wirelessClient` & `wiredClient` configuration parameters:
|
||||
|
||||
| Parameter | Description | Config | Default |
|
||||
| ------------ | -------------------------------------------------------------|--------- | ------- |
|
||||
| cid | The MAC address, IP address, hostname or alias of the client | Required | - |
|
||||
| site | The site where the client should be found | Optional | - |
|
||||
| considerHome | The interval in seconds to consider the client as home | Optional | 180 |
|
||||
|
||||
Here's some additional notes regarding the Thing configuration parameters:
|
||||
|
||||
#### `cid`
|
||||
|
||||
The `cid` parameter is a universal "client identifier". It accepts the following values:
|
||||
|
||||
1. MAC address [highest priority]
|
||||
1. IP address
|
||||
1. Hostname (as shown by the controller)
|
||||
1. Alias (as defined by you in the controller UI) [lowest priority]
|
||||
|
||||
The priority essentially means the binding attempts to look up by MAC address, then by IP address, then by hostname, and finally by alias.
|
||||
Once it finds a matching client, it short-circuits and stops searching.
|
||||
Most of the time, you will simply use the MAC address.
|
||||
|
||||
#### `site`
|
||||
|
||||
The `site` parameter is optional. If you leave it blank, the client will appear `ONLINE` if found in any site defined on the controller.
|
||||
|
||||
You may use the `site` parameter as a filter if you only want the client to appear home if it is found in the site defined in the `site` parameter.
|
||||
|
||||
Additionally, you may use friendly site names as they appear in the controller UI.
|
||||
|
||||
#### `considerHome`
|
||||
|
||||
The `considerHome` parameter allows you to control how quickly the binding marks a client as away.
|
||||
For example, using the default of `180` (seconds), the binding will report a client as away as soon as `lastSeen` + `180` (seconds) < `now`.
|
||||
|
||||
### `poePort`
|
||||
|
||||
The following table describes the `poePort` configuration parameters:
|
||||
|
||||
| Parameter | Description | Config |
|
||||
|------------|-----------------------------------------------------------|----------|
|
||||
| portNumber | The port number as reported by the switch (starts with 1) | Required |
|
||||
| macAddress | The MAC address of the switch device the port is part of | Required |
|
||||
|
||||
### `accessPoint`
|
||||
|
||||
The following table describes the `accessPoint` configuration parameters:
|
||||
|
||||
| Parameter | Description | Config | Default |
|
||||
| ------------ | ------------------------------------------------|--------- | ------- |
|
||||
| mac | The MAC address of the access point | Required | - |
|
||||
| site | The site where the access point should be found | Optional | - |
|
||||
|
||||
### `network`
|
||||
|
||||
The following table describes the `network` configuration parameters:
|
||||
|
||||
| Parameter | Description | Config | Default |
|
||||
| ------------ | -------------------------------------------------------------|--------- | ------- |
|
||||
| nid | The id of the network | Required | - |
|
||||
|
||||
## Channels
|
||||
|
||||
### `site`
|
||||
|
||||
The `site` information that is retrieved is available as these channels:
|
||||
|
||||
| Channel ID | Item Type | Description | Permissions |
|
||||
|-----------------------|-----------|------------------------------------------------------------------------|-------------|
|
||||
| totalClients | Number | Total number of clients connected | Read |
|
||||
| wirelessClients | Number | Number of wireless clients connected | Read |
|
||||
| wiredClients | Number | Number of wired clients connected | Read |
|
||||
| guestClients | Number | Number of guest clients connected | Read |
|
||||
| guestVoucher | String | Guest voucher for access through the guest portal | Read |
|
||||
| guestVouchersGenerate | String | Generate additional guest vouchers for access through the guest portal | Write |
|
||||
|
||||
The `guestVouchersGenerate` string channel is a command only channel that will trigger voucher creation.
|
||||
It has configuration parameters to tailor the vouchers created:
|
||||
|
||||
| Parameter | Description | Config | Default |
|
||||
| ------------------------ | --------------------------------------------------------------------------- |--------- | ------- |
|
||||
| voucherCount | Number of vouchers to create | Optional | 1 |
|
||||
| voucherExpiration | Minutes a voucher is valid after activation (default is 1 day) | Optional | 1440 |
|
||||
| voucherUsers | Number of users for voucher, 0 for no limit | Optional | 1 |
|
||||
| voucherUpLimit | Upload speed limit in kbps, no limit if not set | Optional | |
|
||||
| voucherDownLimit | Download speed limit in kbps, no limit if not set | Optional | |
|
||||
| voucherDataQuota | Data transfer quota in MB per user, no limit if not set | Optional | |
|
||||
|
||||
### `wlan`
|
||||
|
||||
The `wlan` information that is retrieved is available as these channels:
|
||||
|
||||
| Channel ID | Item Type | Description | Permissions |
|
||||
|-----------------|-----------|---------------------------------------------------------------------------------|-------------|
|
||||
| enable | Switch | Enable status of the WLAN | Read, Write |
|
||||
| wirelessClients | Number | Number of wireless clients connected | Read |
|
||||
| guestClients | Number | Number of guest clients connected | Read |
|
||||
| essid | String | Wireless Network (ESSID) | Read |
|
||||
| site | String | UniFi site the WLAN is associated with | Read |
|
||||
| security | String | Security protocol of the Wi-Fi network | Read |
|
||||
| wlanBand | String | Wireless LAN band of the Wi-Fi network | Read |
|
||||
| wpaEnc | String | WPA Encoding of the Wi-Fi network | Read |
|
||||
| wpaMode | String | WPA Mode of the Wi-Fi network | Read |
|
||||
| passphrase | String | Passphrase of the Wi-Fi network | Read |
|
||||
| qrcodeEncoding | String | MECARD-like encoding to generate a QR code for easy access to the Wi‑Fi network | Read |
|
||||
|
||||
::: warning Attention
|
||||
If you link an item to the `passphrase` or `qrcodeEncoding` channel, your Wi‑Fi password will be exposed in openHAB.
|
||||
The password will also be visible in the openHAB event log.
|
||||
:::
|
||||
|
||||
The `qrcodeEncoding` channel can be used to easily create a QR code to access, for example, a guest network.
|
||||
It contains a MECARD-like representation of the access.
|
||||
This is the notation used in QR codes that can be scanned by mobile phones.
|
||||
|
||||
### `wirelessClient`
|
||||
|
||||
The `wirelessClient` information that is retrieved is available as these channels:
|
||||
|
||||
| Channel ID | Item Type | Description | Permissions |
|
||||
|------------|----------------------|----------------------------------------------------------------------|-------------|
|
||||
| online | Switch | Online status of the client | Read |
|
||||
| name | String | Name of device (from the controller web UI) | Read |
|
||||
| hostname | String | Hostname of device (from the controller web UI) | Read |
|
||||
| site | String | Site name (from the controller web UI) the client is associated with | Read |
|
||||
| macAddress | String | MAC address of the client | Read |
|
||||
| ipAddress | String | IP address of the client | Read |
|
||||
| guest | Switch | On if this is a guest client | Read |
|
||||
| ap | String | Access point (AP) the client is connected to | Read |
|
||||
| essid | String | Network name (ESSID) the client is connected to | Read |
|
||||
| rssi | Number:Power | Received signal strength indicator (RSSI) of the client | Read |
|
||||
| uptime | Number:Time | Uptime of the client (in seconds) | Read |
|
||||
| lastSeen | DateTime | Date and Time the client was last seen | Read |
|
||||
| experience | Number:Dimensionless | Overall health indication of the client (in percentage) | Read |
|
||||
| blocked | Switch | Blocked status of the client | Read, Write |
|
||||
| cmd | String | Command channel: `reconnect` to force the client to reconnect | Write |
|
||||
| reconnect | Switch | Force the client to reconnect | Write |
|
||||
|
||||
_Note: All channels with the Write permission require administrator credentials as defined in the controller._
|
||||
|
||||
### `wiredClient`
|
||||
|
||||
The `wiredClient` information that is retrieved is available as these channels:
|
||||
|
||||
| Channel ID | Item Type | Description | Permissions |
|
||||
|------------|----------------------|----------------------------------------------------------------------|-------------|
|
||||
| online | Switch | Online status of the client | Read |
|
||||
| name | String | Name of device (from the controller web UI) | Read |
|
||||
| hostname | String | Hostname of device (from the controller web UI) | Read |
|
||||
| site | String | Site name (from the controller web UI) the client is associated with | Read |
|
||||
| macAddress | String | MAC address of the client | Read |
|
||||
| ipAddress | String | IP address of the client | Read |
|
||||
| uptime | Number:Time | Uptime of the client (in seconds) | Read |
|
||||
| lastSeen | DateTime | Date and Time the client was last seen | Read |
|
||||
| experience | Number:Dimensionless | Overall health indication of the client (in percentage) | Read |
|
||||
| blocked | Switch | Blocked status of the client | Read, Write |
|
||||
|
||||
#### `blocked`
|
||||
|
||||
The `blocked` channel allows you to block / unblock a client via the controller.
|
||||
|
||||
#### `reconnect`
|
||||
|
||||
The `reconnect` channel allows you to force a client to reconnect.
|
||||
Sending `ON` to this channel will trigger a reconnect via the controller.
|
||||
|
||||
### `poePort`
|
||||
|
||||
The `poePort` information that is retrieved is available as these channels:
|
||||
|
||||
| Channel ID | Item Type | Description | Permissions |
|
||||
|------------|--------------------------|-------------------------------------------------------|-------------|
|
||||
| online | Switch | Online status of the port | Read |
|
||||
| mode | Selection | Select the PoE mode: off, auto, pasv24 or passthrough | Read, Write |
|
||||
| enable | Switch | Enable Power over Ethernet | Read, Write |
|
||||
| cmd | String | Command channel: `power-cycle`: Power Cycle port | Write |
|
||||
| power | Number:Power | Power consumption of the port in Watt | Read |
|
||||
| voltage | Number:ElectricPotential | Voltage of the port in Volt | Read |
|
||||
| current | Number:ElectricCurrent | Current used by the port in mA | Read |
|
||||
|
||||
The `enable` switch channel has a configuration parameter `mode` which is the value used to switch PoE on when the channel is switched to ON.
|
||||
The default mode value is `auto`.
|
||||
|
||||
### `accessPoint`
|
||||
|
||||
The `accessPoint` information that is retrieved is available as these channels:
|
||||
|
||||
| Channel ID | Item Type | Description | Permissions |
|
||||
|------------|----------------------|----------------------------------------------------------------------|-------------|
|
||||
| online | Switch | Online status of the device | Read |
|
||||
| enable | Switch | Enable or disable the access point | Read, Write |
|
||||
| name | String | Name of device (from the controller web UI) | Read |
|
||||
| site | String | Site name (from the controller web UI) the device is associated with | Read |
|
||||
| ipAddress | String | IP address of the device | Read |
|
||||
| uptime | Number:Time | Uptime of the device (in seconds) | Read |
|
||||
| lastSeen | DateTime | Date and Time the device was last seen | Read |
|
||||
| experience | Number:Dimensionless | The average health indication of the connected clients | Read |
|
||||
| led | Switch | Switch the LED on or off | Read, Write |
|
||||
|
||||
### `network`
|
||||
|
||||
The `network` information that is retrieved is available as these channels:
|
||||
|
||||
| Channel ID | Item Type | Description | Permissions |
|
||||
|-----------------|-----------|---------------------------------------------------------------------------------|-------------|
|
||||
| enable | Switch | Enable status of the network | Read, Write |
|
||||
| totalClients | Number | Total number of clients connected | Read |
|
||||
| site | String | UniFi Site the client is associated with | Read |
|
||||
| purpose | String | Purpose of the network (e.g. Corporate, Guest, WAN, VPN, VLAN Only) | Read |
|
||||
|
||||
## Rule Actions
|
||||
|
||||
As an alternative to using the `guestVoucher` and `guestVouchersGenerate` channels on the `site` Thing, it is possible to use rule actions on the Thing to generate, revoke and list guest vouchers.
|
||||
The following actions are available:
|
||||
|
||||
- `boolean success = generateVoucher(Integer expire, Integer users, Integer upLimit, Integer downLimit, Integer dataQuota)`
|
||||
- `boolean success = generateVouchers(Integer count, Integer expire, Integer users, Integer upLimit, Integer downLimit, Integer dataQuota)`
|
||||
- `boolean success = revokeVoucher(String voucherCode)`
|
||||
- `boolean success = revokeVouchers(List<String> voucherCodes)`
|
||||
- `boolean success = revokeAllVouchers()`
|
||||
- `String vouchers = listVouchers()`
|
||||
|
||||
Since there is a separate rule action instance for each `site` Thing, this needs to be retrieved through `getActions(scope, thingUID)`.
|
||||
The first parameter always has to be `unifi` and the second is the full Thing UID of the site that should be used.
|
||||
Once this action instance is retrieved, you can invoke the action method on it.
|
||||
|
||||
Boolean return values for the actions indicate success or failure.
|
||||
|
||||
The `generateVoucher(s)` actions parameters match the configuration parameters for the `guestVouchersGenerate` channel.
|
||||
With the actions, these parameters can be controlled in a rule or script.
|
||||
`null` values for the parameters are allowed, and will set the parameter to the default value.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
| ------------- | --------------------------------------------------------------------------- | ------- |
|
||||
| count | Number of vouchers to create | 1 |
|
||||
| expire | Minutes a voucher is valid after activation (default is 1 day) | 1440 |
|
||||
| users | Number of users for voucher, 0 for no limit | 1 |
|
||||
| upLimit | Upload speed limit in kbps, no limit if not set | |
|
||||
| downLimit | Download speed limit in kbps, no limit if not set | |
|
||||
| dataQuota | Data transfer quota in MB per user, no limit if not set | |
|
||||
|
||||
The `revoke...` actions allow you to revoke previously created vouchers.
|
||||
The parameter is the voucher code or a list of voucher codes to be revoked.
|
||||
|
||||
The `listVouchers` action will return a json string representing the currently available vouchers for the site.
|
||||
The json contains all parameters for the voucher, therefore it is possible to filter on these in a rule or script.
|
||||
For example, one could retrieve all vouchers created before a certain time and use the `revokeVouchers` action to delete these.
|
||||
The structure of the returned json is (depending on content, some fields may be missing):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"code": "3867791284",
|
||||
"createTime": "2023-01-31T14:40:47Z",
|
||||
"duration": 1440,
|
||||
"quota": 2,
|
||||
"used": 0,
|
||||
"qosUsageQuota": 300,
|
||||
"qosRateMaxUp": 200,
|
||||
"qosRateMaxDown": 100,
|
||||
"qosOverwrite": true,
|
||||
"note": "I added a note when creating vouchers in the UniFi hotspot UI",
|
||||
"status": "VALID_MULTI"
|
||||
},
|
||||
{
|
||||
"code": "0021952641",
|
||||
"createTime": "2023-01-31T14:38:47Z",
|
||||
"duration": 1440,
|
||||
"quota": 1,
|
||||
"used": 0,
|
||||
"qosOverwrite": false,
|
||||
"status": "VALID_ONE"
|
||||
},
|
||||
{ ... }
|
||||
]
|
||||
```
|
||||
- [**UniFi Network**](doc/network.md) — wireless / wired clients, WLANs, PoE ports, access points, sites, vouchers.
|
||||
- [**UniFi Protect**](doc/protect.md) — cameras, floodlights, environmental sensors, doorbells, doorlocks, chimes.
|
||||
- [**UniFi Access**](doc/access.md) — doors, readers, door lock rules, emergency state, access events.
|
||||
|
||||
## Full Example
|
||||
|
||||
### `things/unifi.things`
|
||||
A typical UDM-Pro setup with all three families configured against the same local user looks like this in text config:
|
||||
|
||||
`.things` file:
|
||||
|
||||
```java
|
||||
Bridge unifi:controller:home "UniFi Controller" [ host="unifi", port=8443, unifios=false, username="$username", password="$password", refresh=10 ] {
|
||||
Thing wirelessClient matthewsPhone "Matthew's iPhone" [ cid="$cid", site="default", considerHome=180 ]
|
||||
Thing site mysite "My Site" [ sid="$sid" ]
|
||||
}
|
||||
```
|
||||
Bridge unifi:controller:udm "UDM Pro" [
|
||||
host="192.168.1.1",
|
||||
username="openhab",
|
||||
password="secret"
|
||||
] {
|
||||
// Network child things attach directly to unifi:controller:
|
||||
// Thing unifi:wirelessClient:phone "My Phone" [ cid="aa:bb:cc:dd:ee:ff", site="default" ]
|
||||
|
||||
:::tip Note
|
||||
On UniFi OS, the default port is typically 443.
|
||||
:::
|
||||
// Protect NVR sub-bridge (inherits host/credentials from the parent):
|
||||
Bridge unifi:nvr nvr "Protect NVR" [] {
|
||||
// Thing unifi:camera front "Front Camera" [ deviceId="..." ]
|
||||
}
|
||||
|
||||
Replace `$username`, `$password`, `$cid`, and `$sid` accordingly.
|
||||
|
||||
### `items/unifi.items`
|
||||
|
||||
```java
|
||||
Switch MatthewsPhone "Matthew's iPhone [MAP(unifi.map):%s]" { channel="unifi:wirelessClient:home:matthewsPhone:online" }
|
||||
String MatthewsPhoneSite "Matthew's iPhone: Site [%s]" { channel="unifi:wirelessClient:home:matthewsPhone:site" }
|
||||
String MatthewsPhoneMAC "Matthew's iPhone: MAC [%s]" { channel="unifi:wirelessClient:home:matthewsPhone:macAddress" }
|
||||
String MatthewsPhoneIP "Matthew's iPhone: IP [%s]" { channel="unifi:wirelessClient:home:matthewsPhone:ipAddress" }
|
||||
String MatthewsPhoneAP "Matthew's iPhone: AP [%s]" { channel="unifi:wirelessClient:home:matthewsPhone:ap" }
|
||||
String MatthewsPhoneESSID "Matthew's iPhone: ESSID [%s]" { channel="unifi:wirelessClient:home:matthewsPhone:essid" }
|
||||
Number:Power MatthewsPhoneRSSI "Matthew's iPhone: RSSI [%d dBm]" { channel="unifi:wirelessClient:home:matthewsPhone:rssi" }
|
||||
Number:Time MatthewsPhoneUptime "Matthew's iPhone: Uptime [%d %unit%]" { channel="unifi:wirelessClient:home:matthewsPhone:uptime" }
|
||||
DateTime MatthewsPhoneLastSeen "Matthew's iPhone: Last Seen [%1$tH:%1$tM:%1$tS]" { channel="unifi:wirelessClient:home:matthewsPhone:lastSeen" }
|
||||
Switch MatthewsPhoneBlocked "Matthew's iPhone: Blocked" { channel="unifi:wirelessClient:home:matthewsPhone:blocked" }
|
||||
Switch MatthewsPhoneReconnect "Matthew's iPhone: Reconnect" { channel="unifi:wirelessClient:home:matthewsPhone:reconnect" }
|
||||
```
|
||||
|
||||
### `transform/unifi.map`
|
||||
|
||||
```text
|
||||
ON=Home
|
||||
OFF=Away
|
||||
```
|
||||
|
||||
### `sitemaps/unifi.sitemap`
|
||||
|
||||
```perl
|
||||
sitemap unifi label="UniFi Binding"
|
||||
{
|
||||
Frame {
|
||||
Text item=MatthewsPhone
|
||||
Text item=MatthewsPhoneSite
|
||||
Text item=MatthewsPhoneMAC
|
||||
Text item=MatthewsPhoneIP
|
||||
Text item=MatthewsPhoneAP
|
||||
Text item=MatthewsPhoneESSID
|
||||
Text item=MatthewsPhoneRSSI
|
||||
Text item=MatthewsPhoneUptime
|
||||
Text item=MatthewsPhoneLastSeen
|
||||
Switch item=MatthewsPhoneBlocked
|
||||
Switch item=MatthewsPhoneReconnect
|
||||
// Access sub-bridge (inherits host/credentials from the parent):
|
||||
Bridge unifi:bridge access "UniFi Access" [ refreshInterval=300 ] {
|
||||
// Thing unifi:door main "Main Door" [ deviceId="..." ]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `rule actions` for `site` Thing
|
||||
> **Legacy thing IDs.** Newly discovered or created Protect and Access things use the `unifi:` binding ID. Things created with an earlier release keep their legacy `unifiprotect:` / `unifiaccess:` IDs and continue to work unchanged — there is no need to recreate them.
|
||||
|
||||
## Upgrading from earlier UniFi bindings
|
||||
|
||||
If you previously ran any of the UniFi Network, UniFi Protect, or UniFi Access bindings, follow these steps after upgrading.
|
||||
New installations can skip to the next section.
|
||||
|
||||
### Step 1 — Create a `unifi:controller` bridge
|
||||
|
||||
All three family bindings now require a shared `unifi:controller` bridge that holds the console credentials.
|
||||
**If you already have one (from the Network binding), skip ahead to Step 2.**
|
||||
|
||||
1. In the openHAB UI, add a new thing from the **UniFi Binding** → **UniFi Controller**.
|
||||
1. Enter the **hostname**, **username**, and **password** of your UniFi console (the same credentials you previously had on your `unifiprotect:nvr` or `unifiaccess:bridge`).
|
||||
1. For a modern UniFi OS console (UDM / UDM Pro / UDR / UNVR / Cloud Key Gen2 Plus) the defaults (`port=443`, `unifios=true`) are correct.
|
||||
|
||||
For a **legacy stand-alone Network Controller**, set `port=8443` and `unifios=false`.
|
||||
|
||||
> **Multiple Consoles** If you have more then one UniFi console, like a dedicated UNVR, each console will require its own `unifi:controller` bridge along with a user that exists on that console as each handles its own login and session management.
|
||||
|
||||
### Step 2 — Reparent existing things under the controller bridge
|
||||
|
||||
**Network binding users:** Your existing Network things (`unifi:wirelessClient`, `unifi:wlan`, `unifi:site`, etc.) already use `unifi:controller` as their bridge — no changes needed.
|
||||
The controller bridge now serves as the shared authentication bridge for all three families, but its thing type UID and configuration parameters are unchanged.
|
||||
|
||||
**Protect and Access binding users:** `unifiprotect:nvr` and `unifiaccess:bridge` now inherit host/credentials from the parent `unifi:controller` bridge.
|
||||
After upgrade they go `OFFLINE` until you set their parent:
|
||||
|
||||
1. In the openHAB UI, open your existing `unifiprotect:nvr` (or `unifiaccess:bridge`) thing, click **Edit**, and set its **Bridge** to the `unifi:controller` you created in Step 1. Save.
|
||||
1. The thing comes back online. All child things (cameras, lights, sensors, doors, readers) stay linked — no action needed on those.
|
||||
1. The old `hostname`/`username`/`password` fields stored on the Protect/Access bridge are silently ignored. You can remove them from your `.things` file or leave them.
|
||||
|
||||
**Text-config `.things` users:** nest Network, Protect, and Access things inside the controller bridge block:
|
||||
|
||||
```java
|
||||
val uniFiActions = getActions("unifi","unifi:site:home:mysite")
|
||||
val success = uniFiActions.generateVoucher(null, null, null, 100, 500, 250)
|
||||
Bridge unifi:controller:home "UniFi Console" [ host="192.168.1.1", username="openhab", password="secret" ] {
|
||||
// Network things attach directly:
|
||||
Thing unifi:wirelessClient phone "My Phone" [ cid="aa:bb:cc:dd:ee:ff", site="default" ]
|
||||
|
||||
// Protect NVR sub-bridge:
|
||||
Bridge unifiprotect:nvr nvr "Protect NVR" [] {
|
||||
Thing unifiprotect:camera front "Front Camera" [ deviceId="..." ]
|
||||
}
|
||||
|
||||
// Access sub-bridge:
|
||||
Bridge unifiaccess:bridge access "Access" [ refreshInterval=300 ] {
|
||||
Thing unifiaccess:door main "Main Door" [ deviceId="..." ]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
val uniFiActions = getActions("unifi","unifi:site:home:mysite")
|
||||
val vouchersJson = uniFiActions.listVouchers()
|
||||
```
|
||||
### Step 3 — Install the UniFi binding (Karaf / manual installs only)
|
||||
|
||||
```java
|
||||
import java.util.List
|
||||
If you install addons via the openHAB UI addon manager, skip this step — it handles names automatically.
|
||||
|
||||
val List<String> voucherList = newArrayList("38677-91284", "46415-36104")
|
||||
val uniFiActions = getActions("unifi","unifi:site:home:mysite")
|
||||
val success = uniFiActions.revokeVouchers(voucherList)
|
||||
```
|
||||
If you use `feature:install` or pin features in `runtime.cfg`, install `openhab-binding-unifi` — this single feature includes all three child bindings (Network, Protect, Access) automatically.
|
||||
|
||||
| Old feature name | New feature name |
|
||||
| ------------------------------ | ----------------------- |
|
||||
| `openhab-binding-unifi` | `openhab-binding-unifi` |
|
||||
| `openhab-binding-unifiprotect` | `openhab-binding-unifi` |
|
||||
| `openhab-binding-unifiaccess` | `openhab-binding-unifi` |
|
||||
|
||||
### What stays the same
|
||||
|
||||
- All thing type UIDs (`unifi:controller`, `unifi:wirelessClient`, `unifiprotect:nvr`, `unifiprotect:camera`, `unifiaccess:bridge`, `unifiaccess:door`, etc.) are unchanged.
|
||||
- Channel UIDs, item links, rules, persistence, and UI widgets continue to work without edits.
|
||||
- `.items` files do not need changes.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
- **`unifiprotect:nvr` or `unifiaccess:bridge` shows `CONFIGURATION_ERROR`:** the thing has no parent bridge. Complete Step 2 above.
|
||||
- **`unifi:controller` bridge fails to authenticate:** if you have a legacy stand-alone Network Controller (not UniFi OS), add `port=8443` and `unifios=false` to the bridge configuration.
|
||||
- **Things stuck in `UNINITIALIZED`:** the UniFi binding feature is not installed. Install `openhab-binding-unifi` which includes all child bindings.
|
||||
|
||||
## Session Sharing
|
||||
|
||||
The `unifi:controller` bridge authenticates once against the console when it comes online and shares that session with every child thing attached to it.
|
||||
Protect and Access sub-bridges attached to the same controller bridge reuse the session directly — they never open their own login.
|
||||
If the session expires the bridge re-authenticates automatically, and sessions are cached across openHAB restarts so a reboot typically does not require a fresh login.
|
||||
|
||||
+98
-83
@@ -3,18 +3,25 @@
|
||||
This binding integrates [Ubiquiti UniFi Access](https://ui.com/door-access) door access control systems with openHAB.
|
||||
It connects to your UniFi console using the internal API, providing real-time door events via WebSocket and channels to monitor and control door locks, access methods, and doorbell events.
|
||||
|
||||
> **Thing IDs.** Newly discovered or created Access things use the `unifi:` binding ID (e.g. `unifi:bridge`, `unifi:door`). Things created with an earlier release keep their legacy `unifiaccess:` IDs and continue to work unchanged.
|
||||
|
||||
## Supported Things
|
||||
|
||||
| Thing Type | Description |
|
||||
|------------|---------------------------------------------------------------------------------|
|
||||
| ---------- | ------------------------------------------------------------------------------- |
|
||||
| `bridge` | The UniFi console running UniFi Access. Required to discover and manage things. |
|
||||
| `door` | A door with lock control, position sensor, and access event triggers. |
|
||||
| `device` | An access reader or intercom with access method toggles and doorbell events. |
|
||||
|
||||
> **Bridge hierarchy.** The `unifi:bridge` thing is a **child of the shared `unifi:controller` bridge** from the parent `org.openhab.binding.unifi` binding.
|
||||
> The Access bridge itself does not carry `host` / `username` / `password` — host and credentials come from the parent controller bridge.
|
||||
> If you are upgrading from an earlier version of the UniFi Access Binding, see the [UniFi binding README — Upgrading from earlier UniFi bindings](../org.openhab.binding.unifi/README.md#upgrading-from-earlier-unifi-bindings) section for the reparenting steps.
|
||||
|
||||
## Discovery
|
||||
|
||||
1. Add the `bridge` by entering your UniFi console hostname/IP and a local user account.
|
||||
1. Once the `bridge` is ONLINE, `door`s and reader `device`s are discovered automatically and appear in the Inbox.
|
||||
1. First create a `unifi:controller` bridge from the parent UniFi binding pointing at your UniFi console with a local user account.
|
||||
1. Then add a `bridge` as a child of that controller bridge.
|
||||
1. Once the Access `bridge` is ONLINE, `door`s and reader `device`s are discovered automatically and appear in the Inbox.
|
||||
1. Approve discovered items or create them manually using `deviceId`.
|
||||
|
||||
Hub devices (UA-Hub, UA-Hub-Door-Mini, UA-Ultra, etc.) are not discovered as separate things because the door thing already represents the hub's lock and position sensor functions.
|
||||
@@ -24,87 +31,87 @@ Only reader/intercom devices (G6 Entry Pro, UA-LITE, UA-G2-PRO, etc.) appear as
|
||||
|
||||
### Bridge (`bridge`)
|
||||
|
||||
| Parameter | Type | Description | Default | Required | Advanced |
|
||||
|-----------------|---------|--------------------------------------------------------|---------|----------|----------|
|
||||
| host | text | Hostname or IP address of the UniFi console. | N/A | yes | no |
|
||||
| username | text | Local user account for the UniFi console. | N/A | yes | no |
|
||||
| password | text | Password for the UniFi console user. | N/A | yes | no |
|
||||
| refreshInterval | integer | Polling interval in seconds for periodic state sync. | 300 | no | yes |
|
||||
The Access bridge is a child of the `unifi:controller` bridge.
|
||||
Host, username, and password come from the parent controller bridge — the Access bridge itself only carries Access-specific settings.
|
||||
|
||||
The binding connects to the UniFi console's internal API (port 443) using session-based authentication.
|
||||
A local user account is required; cloud-only accounts are not supported.
|
||||
| Parameter | Type | Description | Default | Required | Advanced |
|
||||
| --------------- | ------- | ---------------------------------------------------- | ------- | -------- | -------- |
|
||||
| refreshInterval | integer | Polling interval in seconds for periodic state sync. | 300 | no | yes |
|
||||
|
||||
Authentication is handled by the parent `unifi:controller` bridge — configure the local console user once on the controller bridge.
|
||||
Cloud-only accounts are not supported; a local user account on the console is required.
|
||||
|
||||
### Door (`door`)
|
||||
|
||||
| Parameter | Type | Description | Required |
|
||||
|-----------|------|----------------------------------------------------------|----------|
|
||||
| --------- | ---- | -------------------------------------------------------- | -------- |
|
||||
| deviceId | text | Unique door identifier from the UniFi Access controller. | yes |
|
||||
|
||||
### Device (`device`)
|
||||
|
||||
| Parameter | Type | Description | Required |
|
||||
|-----------|------|------------------------------------------------------------|----------|
|
||||
| --------- | ---- | ---------------------------------------------------------- | -------- |
|
||||
| deviceId | text | Unique device identifier from the UniFi Access controller. | yes |
|
||||
|
||||
## Channels
|
||||
|
||||
### Bridge Channels
|
||||
|
||||
| Channel ID | Item Type | RW | Description |
|
||||
|------------------|-----------|----|---------------------------------------------------------------------------------|
|
||||
| emergency-status | String | RW | System-wide emergency mode: `normal`, `lockdown`, or `evacuation`. |
|
||||
| log-insight | Trigger | - | Fires for insight log events. Payload is JSON with event details. |
|
||||
| log | Trigger | - | Fires for access log events. Payload is JSON with event details. |
|
||||
| Channel ID | Item Type | RW | Description |
|
||||
| ---------------- | --------- | --- | ------------------------------------------------------------------ |
|
||||
| emergency-status | String | RW | System-wide emergency mode: `normal`, `lockdown`, or `evacuation`. |
|
||||
| log-insight | Trigger | - | Fires for insight log events. Payload is JSON with event details. |
|
||||
| log | Trigger | - | Fires for access log events. Payload is JSON with event details. |
|
||||
|
||||
### Door Channels
|
||||
|
||||
| Channel ID | Item Type | RW | Description |
|
||||
| ------------------------ | ------------- | ---- | ---------------------------------------------------------------------------------- |
|
||||
| lock | Switch | RW | Lock state. ON = locked, OFF = unlocked. |
|
||||
| position | Contact | R | Door position sensor. OPEN or CLOSED. |
|
||||
| last-unlock | DateTime | R | Timestamp of the last unlock event. |
|
||||
| last-actor | String | R | Name of the person who last unlocked the door. |
|
||||
| lock-rule | String | R | Current lock rule: `schedule`, `custom`, `keep_unlock`, `keep_lock`, `none`, etc. |
|
||||
| keep-unlocked | Switch | RW | Keep door unlocked indefinitely. ON to activate, OFF to reset. |
|
||||
| keep-locked | Switch | RW | Keep door locked indefinitely. ON to activate, OFF to reset. |
|
||||
| unlock-minutes | Number:Time | W | Unlock for the specified number of minutes. |
|
||||
| unlock-until | DateTime | W | Unlock until the specified date/time. |
|
||||
| thumbnail | Image | R | Latest door camera thumbnail image. |
|
||||
| access-attempt-success | Trigger | - | Fires on successful access. JSON payload with actor, credential provider, etc. |
|
||||
| access-attempt-failure | Trigger | - | Fires on failed access attempt. JSON payload with actor details. |
|
||||
| remote-unlock | Trigger | - | Fires when an admin remotely unlocks the door. |
|
||||
| doorbell-status | Trigger | - | Fires on doorbell status changes (timeout, answered, rejected, etc.). |
|
||||
| alarm | Trigger | - | Fires on door alarms (e.g., `force_unlock` when door is forced open while locked). |
|
||||
| log-insight | Trigger | - | Fires for insight log events referencing this door. |
|
||||
| Channel ID | Item Type | RW | Description |
|
||||
| ---------------------- | ----------- | --- | ---------------------------------------------------------------------------------- |
|
||||
| lock | Switch | RW | Lock state. ON = locked, OFF = unlocked. |
|
||||
| position | Contact | R | Door position sensor. OPEN or CLOSED. |
|
||||
| last-unlock | DateTime | R | Timestamp of the last unlock event. |
|
||||
| last-actor | String | R | Name of the person who last unlocked the door. |
|
||||
| lock-rule | String | R | Current lock rule: `schedule`, `custom`, `keep_unlock`, `keep_lock`, `none`, etc. |
|
||||
| keep-unlocked | Switch | RW | Keep door unlocked indefinitely. ON to activate, OFF to reset. |
|
||||
| keep-locked | Switch | RW | Keep door locked indefinitely. ON to activate, OFF to reset. |
|
||||
| unlock-minutes | Number:Time | W | Unlock for the specified number of minutes. |
|
||||
| unlock-until | DateTime | RW | Unlock until a date/time; also reports the active temporary-unlock end time. |
|
||||
| thumbnail | Image | R | Latest door camera thumbnail image. |
|
||||
| access-attempt-success | Trigger | - | Fires on successful access. JSON payload with actor, credential provider, etc. |
|
||||
| access-attempt-failure | Trigger | - | Fires on failed access attempt. JSON payload with actor details. |
|
||||
| remote-unlock | Trigger | - | Fires when an admin remotely unlocks the door. |
|
||||
| doorbell-status | Trigger | - | Fires on doorbell status changes (timeout, answered, rejected, etc.). |
|
||||
| alarm | Trigger | - | Fires on door alarms (e.g., `force_unlock` when door is forced open while locked). |
|
||||
| log-insight | Trigger | - | Fires for insight log events referencing this door. |
|
||||
|
||||
### Device Channels
|
||||
|
||||
Access method channels are populated for reader/intercom devices (e.g., G6 Entry Pro, UA-LITE, UA-G2-PRO).
|
||||
Hub devices are not discovered as device things — their lock and sensor state is on the door thing.
|
||||
|
||||
| Channel ID | Item Type | RW | Description |
|
||||
|-----------------------|-----------|----|---------------------------------------------------------------|
|
||||
| nfc-enabled | Switch | RW | Enable/disable NFC card access. |
|
||||
| pin-enabled | Switch | RW | Enable/disable PIN code access. |
|
||||
| pin-shuffle | Switch | RW | Enable/disable PIN keypad shuffle. |
|
||||
| face-enabled | Switch | RW | Enable/disable face unlock. |
|
||||
| mobile-tap-enabled | Switch | RW | Enable/disable mobile tap (Bluetooth). |
|
||||
| mobile-button-enabled | Switch | RW | Enable/disable mobile app unlock button. |
|
||||
| mobile-shake-enabled | Switch | RW | Enable/disable mobile shake gesture. |
|
||||
| mobile-wave-enabled | Switch | RW | Enable/disable mobile wave gesture. |
|
||||
| wave-enabled | Switch | RW | Enable/disable hand wave gesture. |
|
||||
| qr-code-enabled | Switch | RW | Enable/disable QR code access. |
|
||||
| touch-pass-enabled | Switch | RW | Enable/disable Touch Pass / Apple Pass access. |
|
||||
| face-anti-spoofing | String | RW | Face anti-spoofing level: `high`, `medium`, or `no`. |
|
||||
| face-detect-distance | String | RW | Face detection distance: `near`, `medium`, or `far`. |
|
||||
| door-sensor | Contact | R | Door position sensor from the associated door. |
|
||||
| doorbell-contact | Contact | R | Doorbell contact state. OPEN during a ring, CLOSED otherwise. |
|
||||
| online | Switch | R | Whether the device is currently online. |
|
||||
| Channel ID | Item Type | RW | Description |
|
||||
| --------------------- | --------- | --- | ------------------------------------------------------------- |
|
||||
| nfc-enabled | Switch | RW | Enable/disable NFC card access. |
|
||||
| pin-enabled | Switch | RW | Enable/disable PIN code access. |
|
||||
| pin-shuffle | Switch | RW | Enable/disable PIN keypad shuffle. |
|
||||
| face-enabled | Switch | RW | Enable/disable face unlock. |
|
||||
| mobile-tap-enabled | Switch | RW | Enable/disable mobile tap (Bluetooth). |
|
||||
| mobile-button-enabled | Switch | RW | Enable/disable mobile app unlock button. |
|
||||
| mobile-shake-enabled | Switch | RW | Enable/disable mobile shake gesture. |
|
||||
| mobile-wave-enabled | Switch | RW | Enable/disable mobile wave gesture. |
|
||||
| wave-enabled | Switch | RW | Enable/disable hand wave gesture. |
|
||||
| qr-code-enabled | Switch | RW | Enable/disable QR code access. |
|
||||
| touch-pass-enabled | Switch | RW | Enable/disable Touch Pass / Apple Pass access. |
|
||||
| face-anti-spoofing | String | RW | Face anti-spoofing level: `high`, `medium`, or `no`. |
|
||||
| face-detect-distance | String | RW | Face detection distance: `near`, `medium`, or `far`. |
|
||||
| door-sensor | Contact | R | Door position sensor from the associated door. |
|
||||
| doorbell-contact | Contact | R | Doorbell contact state. OPEN during a ring, CLOSED otherwise. |
|
||||
| online | Switch | R | Whether the device is currently online. |
|
||||
|
||||
Device trigger channels:
|
||||
|
||||
| Channel ID | Events / Payload | Description |
|
||||
|-----------------|------------------------------------------------------------|----------------------------------------------|
|
||||
| --------------- | ---------------------------------------------------------- | -------------------------------------------- |
|
||||
| doorbell | `pressed`, `incoming`, `incoming-ren`, `completed` | Fires on doorbell events (see below). |
|
||||
| doorbell-status | `DOORBELL_TIMED_OUT`, `ADMIN_UNLOCK_SUCCEEDED`, etc. | Fires on intercom call resolution. |
|
||||
| log-insight | JSON payload with logKey, eventType, message, result, etc. | Fires for insight log events on this device. |
|
||||
@@ -116,7 +123,7 @@ Device trigger channels:
|
||||
When someone presses the doorbell button on a reader/intercom, the following events fire in order on the **device** thing:
|
||||
|
||||
| Step | Channel | Event | Description |
|
||||
|------|--------------------|----------------|-------------------------------------------------------------------------------------------|
|
||||
| ---- | ------------------ | -------------- | ----------------------------------------------------------------------------------------- |
|
||||
| 1 | `doorbell` | `pressed` | Hardware button physically pressed. Fires immediately. No corresponding "released" event. |
|
||||
| 2 | `doorbell` | `incoming` | Intercom call session started. Admins are notified and can answer. |
|
||||
| 2a | `doorbell` | `incoming-ren` | (Alternative) Request-to-Enter button pressed instead of regular doorbell. |
|
||||
@@ -130,7 +137,7 @@ When someone presses the doorbell button on a reader/intercom, the following eve
|
||||
**`doorbell-status` events** (fired at step 4):
|
||||
|
||||
| Event | Description |
|
||||
|-----------------------------|-----------------------------------------------------------|
|
||||
| --------------------------- | --------------------------------------------------------- |
|
||||
| `DOORBELL_TIMED_OUT` | No admin answered within the timeout period. |
|
||||
| `ADMIN_REJECTED_UNLOCK` | An admin declined to unlock the door. |
|
||||
| `ADMIN_UNLOCK_SUCCEEDED` | An admin unlocked the door via the intercom. |
|
||||
@@ -143,7 +150,7 @@ When someone presses the doorbell button on a reader/intercom, the following eve
|
||||
The `alarm` trigger channel fires on the **door** thing when a physical alarm condition is detected:
|
||||
|
||||
| Event | Description |
|
||||
|----------------|---------------------------------------------------------------------------------------------------------------------------------|
|
||||
| -------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `force_unlock` | Door was physically forced open while locked. This is a security event — the lock was engaged but the door was opened by force. |
|
||||
|
||||
The alarm fires as part of the `access.data.v2.location.update` and `access.data.v2.device.update` WebSocket events. It will fire on every location update that includes the alarm, so you may want to debounce in your rule.
|
||||
@@ -153,7 +160,7 @@ The alarm fires as part of the `access.data.v2.location.update` and `access.data
|
||||
These fire on the **door** thing when someone uses the door:
|
||||
|
||||
| Channel | Payload | When |
|
||||
|--------------------------|-------------------------------------------------------------|-----------------------------------------------------------------------------------------------|
|
||||
| ------------------------ | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| `access-attempt-success` | JSON: `actorName`, `credentialProvider`, `message` | Someone successfully accessed the door (NFC tap, PIN entry, face unlock, remote unlock, etc.) |
|
||||
| `access-attempt-failure` | JSON: `actorName`, `credentialProvider`, `message` | An access attempt was denied (invalid card, wrong PIN, etc.) |
|
||||
| `remote-unlock` | JSON: `deviceId`, `name`, `fullName`, `level`, `workTimeId` | An admin remotely unlocked the door from the UniFi app or API. |
|
||||
@@ -166,7 +173,7 @@ The `credentialProvider` field in access events indicates how the person authent
|
||||
These fire on the **bridge** thing for all doors/devices:
|
||||
|
||||
| Channel | Payload | When |
|
||||
|---------------|--------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------|
|
||||
| ------------- | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| `log-insight` | JSON: `logKey`, `eventType`, `message`, `published`, `result`, `actorName`, `doorId`, `doorName`, `deviceId`, `cameraId` | Any significant access event occurs (unlock, deny, doorbell, etc.). This is a superset — every access event fires here. |
|
||||
| `log` | JSON: `type`, `displayMessage`, `result`, `published`, `logKey`, `logCategory`, `actorName` | Raw access log entries. Lower-level than insight logs. |
|
||||
|
||||
@@ -184,37 +191,45 @@ These fire on the **bridge** thing for all doors/devices:
|
||||
|
||||
### Things (`.things`)
|
||||
|
||||
The Access bridge is a child of the `unifi:controller` bridge.
|
||||
Host and credentials live on the controller bridge; the Access bridge only carries the polling interval.
|
||||
|
||||
```java
|
||||
Bridge unifiaccess:bridge:home "UniFi Access" [ host="192.168.1.1", username="localadmin", password="secret" ] {
|
||||
Thing door frontdoor "Front Door" [ deviceId="296f6fcb-9da6-416e-85fb-cadcd8842850" ]
|
||||
Thing device intercom "Front Intercom" [ deviceId="69c70f420080a003e40d8190" ]
|
||||
Bridge unifi:controller:home "UniFi Console" [ host="192.168.1.1", username="localadmin", password="secret" ] {
|
||||
Bridge unifi:bridge access "UniFi Access" [ refreshInterval=300 ] {
|
||||
Thing door frontdoor "Front Door" [ deviceId="296f6fcb-9da6-416e-85fb-cadcd8842850" ]
|
||||
Thing device intercom "Front Intercom" [ deviceId="69c70f420080a003e40d8190" ]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With the Access bridge nested under the controller, existing item channel UIDs like `unifi:door:home:access:frontdoor:lock` embed the parent bridge ID (`home`) and the Access bridge ID (`access`).
|
||||
Update your `.items` file's channel references accordingly if you were previously using the flat `unifi:bridge:home` layout — or use the openHAB UI to re-link items, which handles the new UIDs automatically.
|
||||
|
||||
### Items (`.items`)
|
||||
|
||||
```java
|
||||
// Door status
|
||||
Switch UA_FrontDoor_Lock "Front Door Lock" { channel="unifiaccess:door:home:frontdoor:lock" }
|
||||
Contact UA_FrontDoor_Position "Front Door [%s]" { channel="unifiaccess:door:home:frontdoor:position" }
|
||||
DateTime UA_FrontDoor_LastUnlock "Last Unlock [%1$ta %1$tR]" { channel="unifiaccess:door:home:frontdoor:last-unlock" }
|
||||
String UA_FrontDoor_LastActor "Last Actor [%s]" { channel="unifiaccess:door:home:frontdoor:last-actor" }
|
||||
String UA_FrontDoor_LockRule "Lock Rule [%s]" { channel="unifiaccess:door:home:frontdoor:lock-rule" }
|
||||
Switch UA_FrontDoor_Lock "Front Door Lock" { channel="unifi:door:home:frontdoor:lock" }
|
||||
Contact UA_FrontDoor_Position "Front Door [%s]" { channel="unifi:door:home:frontdoor:position" }
|
||||
DateTime UA_FrontDoor_LastUnlock "Last Unlock [%1$ta %1$tR]" { channel="unifi:door:home:frontdoor:last-unlock" }
|
||||
String UA_FrontDoor_LastActor "Last Actor [%s]" { channel="unifi:door:home:frontdoor:last-actor" }
|
||||
String UA_FrontDoor_LockRule "Lock Rule [%s]" { channel="unifi:door:home:frontdoor:lock-rule" }
|
||||
|
||||
// Door controls
|
||||
Switch UA_FrontDoor_KeepUnlocked "Keep Unlocked" { channel="unifiaccess:door:home:frontdoor:keep-unlocked" }
|
||||
Switch UA_FrontDoor_KeepLocked "Keep Locked" { channel="unifiaccess:door:home:frontdoor:keep-locked" }
|
||||
Number:Time UA_FrontDoor_UnlockMins "Unlock Minutes [%.0f min]" { channel="unifiaccess:door:home:frontdoor:unlock-minutes" }
|
||||
Switch UA_FrontDoor_KeepUnlocked "Keep Unlocked" { channel="unifi:door:home:frontdoor:keep-unlocked" }
|
||||
Switch UA_FrontDoor_KeepLocked "Keep Locked" { channel="unifi:door:home:frontdoor:keep-locked" }
|
||||
Number:Time UA_FrontDoor_UnlockMins "Unlock Minutes [%.0f min]" { channel="unifi:door:home:frontdoor:unlock-minutes" }
|
||||
|
||||
// Intercom access methods
|
||||
Switch UA_Intercom_NFC "NFC Access" { channel="unifiaccess:device:home:intercom:nfc-enabled" }
|
||||
Switch UA_Intercom_Face "Face Unlock" { channel="unifiaccess:device:home:intercom:face-enabled" }
|
||||
Switch UA_Intercom_PIN "PIN Access" { channel="unifiaccess:device:home:intercom:pin-enabled" }
|
||||
Switch UA_Intercom_QR "QR Code Access" { channel="unifiaccess:device:home:intercom:qr-code-enabled" }
|
||||
Switch UA_Intercom_Online "Intercom Online" { channel="unifiaccess:device:home:intercom:online" }
|
||||
Switch UA_Intercom_NFC "NFC Access" { channel="unifi:device:home:intercom:nfc-enabled" }
|
||||
Switch UA_Intercom_Face "Face Unlock" { channel="unifi:device:home:intercom:face-enabled" }
|
||||
Switch UA_Intercom_PIN "PIN Access" { channel="unifi:device:home:intercom:pin-enabled" }
|
||||
Switch UA_Intercom_QR "QR Code Access" { channel="unifi:device:home:intercom:qr-code-enabled" }
|
||||
Switch UA_Intercom_Online "Intercom Online" { channel="unifi:device:home:intercom:online" }
|
||||
|
||||
// Bridge
|
||||
String UA_Emergency "Emergency Status [%s]" { channel="unifiaccess:bridge:home:emergency-status" }
|
||||
String UA_Emergency "Emergency Status [%s]" { channel="unifi:bridge:home:emergency-status" }
|
||||
```
|
||||
|
||||
### Sitemap (`.sitemap`)
|
||||
@@ -247,13 +262,13 @@ sitemap unifiaccess label="UniFi Access" {
|
||||
|
||||
```javascript
|
||||
// Instant doorbell notification (hardware button press)
|
||||
rules.when().channel('unifiaccess:device:home:intercom:doorbell').triggered('pressed').then(e => {
|
||||
rules.when().channel('unifi:device:home:intercom:doorbell').triggered('pressed').then(e => {
|
||||
console.log("Doorbell button pressed!");
|
||||
// Send notification, turn on porch light, etc.
|
||||
}).build('Doorbell Pressed');
|
||||
|
||||
// Track intercom call lifecycle
|
||||
rules.when().channel('unifiaccess:device:home:intercom:doorbell-status').triggered().then(e => {
|
||||
rules.when().channel('unifi:device:home:intercom:doorbell-status').triggered().then(e => {
|
||||
const status = e.payload.event;
|
||||
if (status === 'ADMIN_UNLOCK_SUCCEEDED') {
|
||||
console.log("Door was unlocked via intercom");
|
||||
@@ -263,25 +278,25 @@ rules.when().channel('unifiaccess:device:home:intercom:doorbell-status').trigger
|
||||
}).build('Doorbell Status');
|
||||
|
||||
// Door forced open alarm (security alert)
|
||||
rules.when().channel('unifiaccess:door:home:frontdoor:alarm').triggered('force_unlock').then(e => {
|
||||
rules.when().channel('unifi:door:home:frontdoor:alarm').triggered('force_unlock').then(e => {
|
||||
console.log("SECURITY: Door was forced open while locked!");
|
||||
// Send push notification, trigger siren, etc.
|
||||
}).build('Door Forced Open');
|
||||
|
||||
// Door access events
|
||||
rules.when().channel('unifiaccess:door:home:frontdoor:access-attempt-success').triggered().then(e => {
|
||||
rules.when().channel('unifi:door:home:frontdoor:access-attempt-success').triggered().then(e => {
|
||||
const data = JSON.parse(e.payload.event);
|
||||
console.log("Access granted:", data.actorName, "via", data.credentialProvider);
|
||||
}).build('Access Success');
|
||||
|
||||
rules.when().channel('unifiaccess:door:home:frontdoor:access-attempt-failure').triggered().then(e => {
|
||||
rules.when().channel('unifi:door:home:frontdoor:access-attempt-failure').triggered().then(e => {
|
||||
const data = JSON.parse(e.payload.event);
|
||||
console.log("Access DENIED:", data.actorName);
|
||||
// Send security alert
|
||||
}).build('Access Denied');
|
||||
|
||||
// Global access log (fires for all doors)
|
||||
rules.when().channel('unifiaccess:bridge:home:log-insight').triggered().then(e => {
|
||||
rules.when().channel('unifi:bridge:home:log-insight').triggered().then(e => {
|
||||
const data = JSON.parse(e.payload.event);
|
||||
console.log("Access event:", data.eventType, data.message, "at door:", data.doorName);
|
||||
}).build('Global Access Log');
|
||||
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
@@ -0,0 +1,400 @@
|
||||
# UniFi Network Binding
|
||||
|
||||
This binding provides network management and client presence detection for [Ubiquiti UniFi Networks](https://www.ubnt.com/products/#unifi).
|
||||
It is part of the UniFi binding family and requires the `unifi:controller` bridge from the parent [UniFi binding](/addons/bindings/unifi/).
|
||||
|
||||
## Supported Things
|
||||
|
||||
- `site` - A site with connection statistics
|
||||
- `wlan` - A wireless network. Control the Wi‑Fi network and provide easy access via QR code.
|
||||
- `wirelessClient` - Any wireless client connected to a UniFi wireless network
|
||||
- `wiredClient` - A wired client connected to the UniFi network
|
||||
- `poePort` - A PoE (Power over Ethernet) port on a UniFi switch
|
||||
- `accessPoint` - An access point managed by the UniFi controller software
|
||||
- `network` - A network managed by the UniFi controller software.
|
||||
|
||||
> **Bridge hierarchy.** All Network things are children of the shared `unifi:controller` bridge from the parent [UniFi binding](/addons/bindings/unifi/).
|
||||
> The controller bridge holds the console host, username, and password — see the parent binding documentation for bridge setup.
|
||||
|
||||
## Discovery
|
||||
|
||||
The binding supports discovery of things connected to a `unifi:controller` bridge.
|
||||
To discover things, start the discovery process manually.
|
||||
|
||||
## Thing Configuration
|
||||
|
||||
All Network things require a `unifi:controller` bridge as their parent.
|
||||
See the [UniFi binding documentation](/addons/bindings/unifi/) for controller bridge configuration.
|
||||
|
||||
### `site`
|
||||
|
||||
The following table describes the `site` configuration parameters:
|
||||
|
||||
| Parameter | Description | Config | Default |
|
||||
| --------- | ---------------------------------------- | -------- | ------- |
|
||||
| sid | The ID, name, or description of the site | Required | - |
|
||||
|
||||
### `wlan`
|
||||
|
||||
The following table describes the `wlan` configuration parameters:
|
||||
|
||||
| Parameter | Description | Config | Default |
|
||||
| --------- | -------------------------- | -------- | ------- |
|
||||
| wid | The name or ID of the WLAN | Required | - |
|
||||
|
||||
### `wirelessClient` & `wiredClient`
|
||||
|
||||
The following table describes the `wirelessClient` & `wiredClient` configuration parameters:
|
||||
|
||||
| Parameter | Description | Config | Default |
|
||||
| ------------ | ------------------------------------------------------------ | -------- | ------- |
|
||||
| cid | The MAC address, IP address, hostname or alias of the client | Required | - |
|
||||
| site | The site where the client should be found | Optional | - |
|
||||
| considerHome | The interval in seconds to consider the client as home | Optional | 180 |
|
||||
|
||||
Here's some additional notes regarding the Thing configuration parameters:
|
||||
|
||||
#### `cid`
|
||||
|
||||
The `cid` parameter is a universal "client identifier". It accepts the following values:
|
||||
|
||||
1. MAC address [highest priority]
|
||||
1. IP address
|
||||
1. Hostname (as shown by the controller)
|
||||
1. Alias (as defined by you in the controller UI) [lowest priority]
|
||||
|
||||
The priority essentially means the binding attempts to look up by MAC address, then by IP address, then by hostname, and finally by alias.
|
||||
Once it finds a matching client, it short-circuits and stops searching.
|
||||
Most of the time, you will simply use the MAC address.
|
||||
|
||||
#### `site`
|
||||
|
||||
The `site` parameter is optional. If you leave it blank, the client will appear `ONLINE` if found in any site defined on the controller.
|
||||
|
||||
You may use the `site` parameter as a filter if you only want the client to appear home if it is found in the site defined in the `site` parameter.
|
||||
|
||||
Additionally, you may use friendly site names as they appear in the controller UI.
|
||||
|
||||
#### `considerHome`
|
||||
|
||||
The `considerHome` parameter allows you to control how quickly the binding marks a client as away.
|
||||
For example, using the default of `180` (seconds), the binding will report a client as away as soon as `lastSeen` + `180` (seconds) < `now`.
|
||||
|
||||
### `poePort`
|
||||
|
||||
The following table describes the `poePort` configuration parameters:
|
||||
|
||||
| Parameter | Description | Config |
|
||||
| ---------- | --------------------------------------------------------- | -------- |
|
||||
| portNumber | The port number as reported by the switch (starts with 1) | Required |
|
||||
| macAddress | The MAC address of the switch device the port is part of | Required |
|
||||
|
||||
### `accessPoint`
|
||||
|
||||
The following table describes the `accessPoint` configuration parameters:
|
||||
|
||||
| Parameter | Description | Config | Default |
|
||||
| --------- | ----------------------------------------------- | -------- | ------- |
|
||||
| mac | The MAC address of the access point | Required | - |
|
||||
| site | The site where the access point should be found | Optional | - |
|
||||
|
||||
### `network`
|
||||
|
||||
The following table describes the `network` configuration parameters:
|
||||
|
||||
| Parameter | Description | Config | Default |
|
||||
| --------- | --------------------- | -------- | ------- |
|
||||
| nid | The id of the network | Required | - |
|
||||
|
||||
## Channels
|
||||
|
||||
### `site`
|
||||
|
||||
The `site` information that is retrieved is available as these channels:
|
||||
|
||||
| Channel ID | Item Type | Description | Permissions |
|
||||
| --------------------- | --------- | ---------------------------------------------------------------------- | ----------- |
|
||||
| totalClients | Number | Total number of clients connected | Read |
|
||||
| wirelessClients | Number | Number of wireless clients connected | Read |
|
||||
| wiredClients | Number | Number of wired clients connected | Read |
|
||||
| guestClients | Number | Number of guest clients connected | Read |
|
||||
| guestVoucher | String | Guest voucher for access through the guest portal | Read |
|
||||
| guestVouchersGenerate | String | Generate additional guest vouchers for access through the guest portal | Write |
|
||||
|
||||
The `guestVouchersGenerate` string channel is a command only channel that will trigger voucher creation.
|
||||
It has configuration parameters to tailor the vouchers created:
|
||||
|
||||
| Parameter | Description | Config | Default |
|
||||
| ----------------- | -------------------------------------------------------------- | -------- | ------- |
|
||||
| voucherCount | Number of vouchers to create | Optional | 1 |
|
||||
| voucherExpiration | Minutes a voucher is valid after activation (default is 1 day) | Optional | 1440 |
|
||||
| voucherUsers | Number of users for voucher, 0 for no limit | Optional | 1 |
|
||||
| voucherUpLimit | Upload speed limit in kbps, no limit if not set | Optional | |
|
||||
| voucherDownLimit | Download speed limit in kbps, no limit if not set | Optional | |
|
||||
| voucherDataQuota | Data transfer quota in MB per user, no limit if not set | Optional | |
|
||||
|
||||
### `wlan`
|
||||
|
||||
The `wlan` information that is retrieved is available as these channels:
|
||||
|
||||
| Channel ID | Item Type | Description | Permissions |
|
||||
| --------------- | --------- | ------------------------------------------------------------------------------- | ----------- |
|
||||
| enable | Switch | Enable status of the WLAN | Read, Write |
|
||||
| wirelessClients | Number | Number of wireless clients connected | Read |
|
||||
| guestClients | Number | Number of guest clients connected | Read |
|
||||
| essid | String | Wireless Network (ESSID) | Read |
|
||||
| site | String | UniFi site the WLAN is associated with | Read |
|
||||
| security | String | Security protocol of the Wi-Fi network | Read |
|
||||
| wlanBand | String | Wireless LAN band of the Wi-Fi network | Read |
|
||||
| wpaEnc | String | WPA Encoding of the Wi-Fi network | Read |
|
||||
| wpaMode | String | WPA Mode of the Wi-Fi network | Read |
|
||||
| passphrase | String | Passphrase of the Wi-Fi network | Read |
|
||||
| qrcodeEncoding | String | MECARD-like encoding to generate a QR code for easy access to the Wi‑Fi network | Read |
|
||||
|
||||
::: warning Attention
|
||||
If you link an item to the `passphrase` or `qrcodeEncoding` channel, your Wi‑Fi password will be exposed in openHAB.
|
||||
The password will also be visible in the openHAB event log.
|
||||
:::
|
||||
|
||||
The `qrcodeEncoding` channel can be used to easily create a QR code to access, for example, a guest network.
|
||||
It contains a MECARD-like representation of the access.
|
||||
This is the notation used in QR codes that can be scanned by mobile phones.
|
||||
|
||||
### `wirelessClient`
|
||||
|
||||
The `wirelessClient` information that is retrieved is available as these channels:
|
||||
|
||||
| Channel ID | Item Type | Description | Permissions |
|
||||
| ---------- | -------------------- | -------------------------------------------------------------------- | ----------- |
|
||||
| online | Switch | Online status of the client | Read |
|
||||
| name | String | Name of device (from the controller web UI) | Read |
|
||||
| hostname | String | Hostname of device (from the controller web UI) | Read |
|
||||
| site | String | Site name (from the controller web UI) the client is associated with | Read |
|
||||
| macAddress | String | MAC address of the client | Read |
|
||||
| ipAddress | String | IP address of the client | Read |
|
||||
| guest | Switch | On if this is a guest client | Read |
|
||||
| ap | String | Access point (AP) the client is connected to | Read |
|
||||
| essid | String | Network name (ESSID) the client is connected to | Read |
|
||||
| rssi | Number:Power | Received signal strength indicator (RSSI) of the client | Read |
|
||||
| uptime | Number:Time | Uptime of the client (in seconds) | Read |
|
||||
| lastSeen | DateTime | Date and Time the client was last seen | Read |
|
||||
| experience | Number:Dimensionless | Overall health indication of the client (in percentage) | Read |
|
||||
| blocked | Switch | Blocked status of the client | Read, Write |
|
||||
| cmd | String | Command channel: `reconnect` to force the client to reconnect | Write |
|
||||
| reconnect | Switch | Force the client to reconnect | Write |
|
||||
|
||||
_Note: All channels with the Write permission require administrator credentials as defined in the controller._
|
||||
|
||||
### `wiredClient`
|
||||
|
||||
The `wiredClient` information that is retrieved is available as these channels:
|
||||
|
||||
| Channel ID | Item Type | Description | Permissions |
|
||||
| ---------- | -------------------- | -------------------------------------------------------------------- | ----------- |
|
||||
| online | Switch | Online status of the client | Read |
|
||||
| name | String | Name of device (from the controller web UI) | Read |
|
||||
| hostname | String | Hostname of device (from the controller web UI) | Read |
|
||||
| site | String | Site name (from the controller web UI) the client is associated with | Read |
|
||||
| macAddress | String | MAC address of the client | Read |
|
||||
| ipAddress | String | IP address of the client | Read |
|
||||
| uptime | Number:Time | Uptime of the client (in seconds) | Read |
|
||||
| lastSeen | DateTime | Date and Time the client was last seen | Read |
|
||||
| experience | Number:Dimensionless | Overall health indication of the client (in percentage) | Read |
|
||||
| blocked | Switch | Blocked status of the client | Read, Write |
|
||||
|
||||
#### `blocked`
|
||||
|
||||
The `blocked` channel allows you to block / unblock a client via the controller.
|
||||
|
||||
#### `reconnect`
|
||||
|
||||
The `reconnect` channel allows you to force a client to reconnect.
|
||||
Sending `ON` to this channel will trigger a reconnect via the controller.
|
||||
|
||||
### `poePort`
|
||||
|
||||
The `poePort` information that is retrieved is available as these channels:
|
||||
|
||||
| Channel ID | Item Type | Description | Permissions |
|
||||
| ---------- | ------------------------ | ----------------------------------------------------- | ----------- |
|
||||
| online | Switch | Online status of the port | Read |
|
||||
| mode | Selection | Select the PoE mode: off, auto, pasv24 or passthrough | Read, Write |
|
||||
| enable | Switch | Enable Power over Ethernet | Read, Write |
|
||||
| cmd | String | Command channel: `power-cycle`: Power Cycle port | Write |
|
||||
| power | Number:Power | Power consumption of the port in Watt | Read |
|
||||
| voltage | Number:ElectricPotential | Voltage of the port in Volt | Read |
|
||||
| current | Number:ElectricCurrent | Current used by the port in mA | Read |
|
||||
|
||||
The `enable` switch channel has a configuration parameter `mode` which is the value used to switch PoE on when the channel is switched to ON.
|
||||
The default mode value is `auto`.
|
||||
|
||||
### `accessPoint`
|
||||
|
||||
The `accessPoint` information that is retrieved is available as these channels:
|
||||
|
||||
| Channel ID | Item Type | Description | Permissions |
|
||||
| ---------- | -------------------- | -------------------------------------------------------------------- | ----------- |
|
||||
| online | Switch | Online status of the device | Read |
|
||||
| enable | Switch | Enable or disable the access point | Read, Write |
|
||||
| name | String | Name of device (from the controller web UI) | Read |
|
||||
| site | String | Site name (from the controller web UI) the device is associated with | Read |
|
||||
| ipAddress | String | IP address of the device | Read |
|
||||
| uptime | Number:Time | Uptime of the device (in seconds) | Read |
|
||||
| lastSeen | DateTime | Date and Time the device was last seen | Read |
|
||||
| experience | Number:Dimensionless | The average health indication of the connected clients | Read |
|
||||
| led | Switch | Switch the LED on or off | Read, Write |
|
||||
|
||||
### `network`
|
||||
|
||||
The `network` information that is retrieved is available as these channels:
|
||||
|
||||
| Channel ID | Item Type | Description | Permissions |
|
||||
| ------------ | --------- | ------------------------------------------------------------------- | ----------- |
|
||||
| enable | Switch | Enable status of the network | Read, Write |
|
||||
| totalClients | Number | Total number of clients connected | Read |
|
||||
| site | String | UniFi Site the client is associated with | Read |
|
||||
| purpose | String | Purpose of the network (e.g. Corporate, Guest, WAN, VPN, VLAN Only) | Read |
|
||||
|
||||
## Rule Actions
|
||||
|
||||
As an alternative to using the `guestVoucher` and `guestVouchersGenerate` channels on the `site` Thing, it is possible to use rule actions on the Thing to generate, revoke and list guest vouchers.
|
||||
The following actions are available:
|
||||
|
||||
- `boolean success = generateVoucher(Integer expire, Integer users, Integer upLimit, Integer downLimit, Integer dataQuota)`
|
||||
- `boolean success = generateVouchers(Integer count, Integer expire, Integer users, Integer upLimit, Integer downLimit, Integer dataQuota)`
|
||||
- `boolean success = revokeVoucher(String voucherCode)`
|
||||
- `boolean success = revokeVouchers(List<String> voucherCodes)`
|
||||
- `boolean success = revokeAllVouchers()`
|
||||
- `String vouchers = listVouchers()`
|
||||
|
||||
Since there is a separate rule action instance for each `site` Thing, this needs to be retrieved through `getActions(scope, thingUID)`.
|
||||
The first parameter always has to be `unifi` and the second is the full Thing UID of the site that should be used.
|
||||
Once this action instance is retrieved, you can invoke the action method on it.
|
||||
|
||||
Boolean return values for the actions indicate success or failure.
|
||||
|
||||
The `generateVoucher(s)` actions parameters match the configuration parameters for the `guestVouchersGenerate` channel.
|
||||
With the actions, these parameters can be controlled in a rule or script.
|
||||
`null` values for the parameters are allowed, and will set the parameter to the default value.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
| --------- | -------------------------------------------------------------- | ------- |
|
||||
| count | Number of vouchers to create | 1 |
|
||||
| expire | Minutes a voucher is valid after activation (default is 1 day) | 1440 |
|
||||
| users | Number of users for voucher, 0 for no limit | 1 |
|
||||
| upLimit | Upload speed limit in kbps, no limit if not set | |
|
||||
| downLimit | Download speed limit in kbps, no limit if not set | |
|
||||
| dataQuota | Data transfer quota in MB per user, no limit if not set | |
|
||||
|
||||
The `revoke...` actions allow you to revoke previously created vouchers.
|
||||
The parameter is the voucher code or a list of voucher codes to be revoked.
|
||||
|
||||
The `listVouchers` action will return a json string representing the currently available vouchers for the site.
|
||||
The json contains all parameters for the voucher, therefore it is possible to filter on these in a rule or script.
|
||||
For example, one could retrieve all vouchers created before a certain time and use the `revokeVouchers` action to delete these.
|
||||
The structure of the returned json is (depending on content, some fields may be missing):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"code": "3867791284",
|
||||
"createTime": "2023-01-31T14:40:47Z",
|
||||
"duration": 1440,
|
||||
"quota": 2,
|
||||
"used": 0,
|
||||
"qosUsageQuota": 300,
|
||||
"qosRateMaxUp": 200,
|
||||
"qosRateMaxDown": 100,
|
||||
"qosOverwrite": true,
|
||||
"note": "I added a note when creating vouchers in the UniFi hotspot UI",
|
||||
"status": "VALID_MULTI"
|
||||
},
|
||||
{
|
||||
"code": "0021952641",
|
||||
"createTime": "2023-01-31T14:38:47Z",
|
||||
"duration": 1440,
|
||||
"quota": 1,
|
||||
"used": 0,
|
||||
"qosOverwrite": false,
|
||||
"status": "VALID_ONE"
|
||||
},
|
||||
{ ... }
|
||||
]
|
||||
```
|
||||
|
||||
## Full Example
|
||||
|
||||
### `things/unifi.things`
|
||||
|
||||
Network things are nested inside the `unifi:controller` bridge from the parent UniFi binding:
|
||||
|
||||
```java
|
||||
Bridge unifi:controller:home "UniFi Controller" [ host="192.168.1.1", username="openhab", password="secret" ] {
|
||||
Thing wirelessClient matthewsPhone "Matthew's iPhone" [ cid="aa:bb:cc:dd:ee:ff", site="default", considerHome=180 ]
|
||||
Thing site mysite "My Site" [ sid="default" ]
|
||||
}
|
||||
```
|
||||
|
||||
### `items/unifi.items`
|
||||
|
||||
```java
|
||||
Switch MatthewsPhone "Matthew's iPhone [MAP(unifi.map):%s]" { channel="unifi:wirelessClient:home:matthewsPhone:online" }
|
||||
String MatthewsPhoneSite "Matthew's iPhone: Site [%s]" { channel="unifi:wirelessClient:home:matthewsPhone:site" }
|
||||
String MatthewsPhoneMAC "Matthew's iPhone: MAC [%s]" { channel="unifi:wirelessClient:home:matthewsPhone:macAddress" }
|
||||
String MatthewsPhoneIP "Matthew's iPhone: IP [%s]" { channel="unifi:wirelessClient:home:matthewsPhone:ipAddress" }
|
||||
String MatthewsPhoneAP "Matthew's iPhone: AP [%s]" { channel="unifi:wirelessClient:home:matthewsPhone:ap" }
|
||||
String MatthewsPhoneESSID "Matthew's iPhone: ESSID [%s]" { channel="unifi:wirelessClient:home:matthewsPhone:essid" }
|
||||
Number:Power MatthewsPhoneRSSI "Matthew's iPhone: RSSI [%d dBm]" { channel="unifi:wirelessClient:home:matthewsPhone:rssi" }
|
||||
Number:Time MatthewsPhoneUptime "Matthew's iPhone: Uptime [%d %unit%]" { channel="unifi:wirelessClient:home:matthewsPhone:uptime" }
|
||||
DateTime MatthewsPhoneLastSeen "Matthew's iPhone: Last Seen [%1$tH:%1$tM:%1$tS]" { channel="unifi:wirelessClient:home:matthewsPhone:lastSeen" }
|
||||
Switch MatthewsPhoneBlocked "Matthew's iPhone: Blocked" { channel="unifi:wirelessClient:home:matthewsPhone:blocked" }
|
||||
Switch MatthewsPhoneReconnect "Matthew's iPhone: Reconnect" { channel="unifi:wirelessClient:home:matthewsPhone:reconnect" }
|
||||
```
|
||||
|
||||
### `transform/unifi.map`
|
||||
|
||||
```text
|
||||
ON=Home
|
||||
OFF=Away
|
||||
```
|
||||
|
||||
### `sitemaps/unifi.sitemap`
|
||||
|
||||
```perl
|
||||
sitemap unifi label="UniFi Binding"
|
||||
{
|
||||
Frame {
|
||||
Text item=MatthewsPhone
|
||||
Text item=MatthewsPhoneSite
|
||||
Text item=MatthewsPhoneMAC
|
||||
Text item=MatthewsPhoneIP
|
||||
Text item=MatthewsPhoneAP
|
||||
Text item=MatthewsPhoneESSID
|
||||
Text item=MatthewsPhoneRSSI
|
||||
Text item=MatthewsPhoneUptime
|
||||
Text item=MatthewsPhoneLastSeen
|
||||
Switch item=MatthewsPhoneBlocked
|
||||
Switch item=MatthewsPhoneReconnect
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `rule actions` for `site` Thing
|
||||
|
||||
```java
|
||||
val uniFiActions = getActions("unifi","unifi:site:home:mysite")
|
||||
val success = uniFiActions.generateVoucher(null, null, null, 100, 500, 250)
|
||||
```
|
||||
|
||||
```java
|
||||
val uniFiActions = getActions("unifi","unifi:site:home:mysite")
|
||||
val vouchersJson = uniFiActions.listVouchers()
|
||||
```
|
||||
|
||||
```java
|
||||
import java.util.List
|
||||
|
||||
val List<String> voucherList = newArrayList("38677-91284", "46415-36104")
|
||||
val uniFiActions = getActions("unifi","unifi:site:home:mysite")
|
||||
val success = uniFiActions.revokeVouchers(voucherList)
|
||||
```
|
||||
@@ -0,0 +1,721 @@
|
||||
# UniFi Protect Binding
|
||||
|
||||
This binding integrates Ubiquiti UniFi Protect into openHAB.
|
||||
It connects to your Protect NVR/CloudKey/UNVR and provides live events and configurable settings for Cameras, Floodlights, Sensors, Doorlocks, and Chimes.
|
||||
|
||||
It primarily uses the Private API for device control and monitoring, with the official Protect Integration API (Public API) used for real-time event streaming, WebRTC/RTSPS streams, and talkback sessions.
|
||||
The binding automatically manages API authentication, including auto-creation of API keys when needed.
|
||||
|
||||
> **Thing IDs.** Newly discovered or created Protect things use the `unifi:` binding ID (e.g. `unifi:nvr`, `unifi:camera`). Things created with an earlier release keep their legacy `unifiprotect:` IDs and continue to work unchanged.
|
||||
|
||||
## Features
|
||||
|
||||
- Supports multiple Protect devices (Cameras, Doorbells, Floodlights, Sensors)
|
||||
- Has granular triggers and channels for realtime motion events including AI object detection, audio, and line crossing events.
|
||||
- Uses websockets for realtime updates without polling
|
||||
- Supports [WebRTC streaming](#real-time-media) for cameras with very low server CPU overhead
|
||||
- Supports [2-way audio](#talkback-support-2-way-audio) for cameras that support it
|
||||
- Uses STUN for external access to cameras when outside your local network (e.g. when using the openHAB cloud service)
|
||||
- Provides general purpose image snapshot API endpoints for cameras
|
||||
|
||||
## Native Binaries
|
||||
|
||||
- Uses [go2rtc](https://github.com/AlexxIT/go2rtc) and [FFmpeg](https://ffmpeg.org/) for WebRTC playback and publishing.
|
||||
- The binding will automatically download and extract the binaries if they are not present on linux, mac, windows and freeBSD.
|
||||
- By default the binding will first try and find the binaries on the system PATH before downloading them.
|
||||
- If your platform is not supported, or downloading the binaries is not possible, install the binaries manually and ensure they are on the system PATH.
|
||||
|
||||
See [Binding Configuration](#binding-configuration) to enable/disable downloading the binaries.
|
||||
|
||||
## Supported Things
|
||||
|
||||
- `nvr` (Bridge): The Protect NVR/CloudKey/UNVR.
|
||||
Required to discover and manage child devices. Provides NVR monitoring channels.
|
||||
- `camera`: A Protect camera.
|
||||
Channels are added dynamically based on device capabilities (mic, HDR, smart detection, PTZ, etc.).
|
||||
- `light`: A Protect Floodlight.
|
||||
- `sensor`: A Protect environmental/contact sensor.
|
||||
- `doorlock`: A Protect Smart Doorlock.
|
||||
- `chime`: A Protect Chime device.
|
||||
|
||||
> **Bridge hierarchy.** The `unifi:nvr` bridge is a **child of the shared `unifi:controller` bridge** from the parent `org.openhab.binding.unifi` binding.
|
||||
> The NVR thing itself does not carry `hostname` / `username` / `password` — host and credentials come from the parent controller bridge.
|
||||
> If you are upgrading from an earlier version of the UniFi Protect Binding, see the [UniFi binding README — Upgrading from earlier UniFi bindings](../org.openhab.binding.unifi/README.md#upgrading-from-earlier-unifi-bindings) section for the reparenting steps.
|
||||
|
||||
## Discovery
|
||||
|
||||
- First create a `unifi:controller` bridge from the parent UniFi binding pointing at your UniFi console with a local user account.
|
||||
- Then add an `nvr` bridge as a child of that controller bridge.
|
||||
No host/username/password is needed on the NVR itself — they are inherited from the parent.
|
||||
- The binding automatically creates a Public API key (named `openHAB-<thing-id>`) on first connection if no `token` is provided.
|
||||
- Once the NVR is ONLINE, Cameras, Floodlights, Sensors, Doorlocks, and Chimes are discovered automatically and appear in the Inbox.
|
||||
- Approve discovered things to add them to your system.
|
||||
Manual creation is also possible using `deviceId`.
|
||||
|
||||
## Binding Configuration
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- |
|
||||
| downloadBinaries | boolean | Download binaries if they are not on the system PATH. This setting controls whether the binding should download the native binaries if they are not found. By default, the binding will download the binaries if they are not on the system PATH for supported platforms | yes |
|
||||
| useStun | boolean | Use STUN for external IP discovery. This will allow camera streams to work behind NATs when outside your local network (e.g. when using the openHAB cloud service) and is enabled by default. | yes |
|
||||
|
||||
Note: Enabling STUN will incur an approximately 5-second delay delivering the stream to clients as it discovers your external IP and pins a port on your router for streams. If you are not using the openHAB cloud service with cameras, disabling STUN will allow for near-instant stream starts (video will start within a second of loading) on your local network or over a VPN.
|
||||
|
||||
## Thing Configuration
|
||||
|
||||
### NVR (Bridge) `nvr`
|
||||
|
||||
The `nvr` bridge is a child of the `unifi:controller` bridge.
|
||||
Host, port, username, and password come from the parent controller bridge — the NVR itself only carries Protect-specific settings.
|
||||
|
||||
| Name | Type | Description | Default | Required | Advanced |
|
||||
| ----- | ---- | --------------------------------------------- | ------- | -------- | -------- |
|
||||
| token | text | Public API token. Auto-created if left blank. | N/A | no | yes |
|
||||
|
||||
**Authentication:**
|
||||
|
||||
The parent `unifi:controller` bridge handles console authentication — you configure the local user once on the controller bridge, and the Protect binding reuses that session.
|
||||
|
||||
- **API Key (Token)**: If not provided, the binding automatically creates an API key named `openHAB-<thing-id>` for the Public API on first connection.
|
||||
The key is stored in the thing configuration and reused on restarts.
|
||||
|
||||
**Manual API Key Creation** (optional):
|
||||
|
||||
If you prefer to manually create an API key:
|
||||
|
||||
1. In the UniFi Protect UI, go to Settings → Control Plane → Integrations and create an API token.
|
||||
1. Copy the token and paste it into the `token` field in the NVR bridge configuration.
|
||||
|
||||
<img src="keys.png" width="600" alt="Protect API key creation">
|
||||
|
||||
### `camera` Configuration
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ------------ | ------- | -------------------------------------- | -------- |
|
||||
| deviceId | text | Unique device identifier of the camera | yes |
|
||||
| enableWebRTC | boolean | Enable WebRTC streaming | yes |
|
||||
|
||||
When WebRTC is enabled, the camera will be able to stream through openHAB using WebRTC.
|
||||
You can disable WebRTC by setting `enableWebRTC` to `false`.
|
||||
|
||||
### `light` Configuration
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| -------- | ---- | ------------------------------------------ | -------- |
|
||||
| deviceId | text | Unique device identifier of the floodlight | yes |
|
||||
|
||||
### `sensor` Configuration
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| -------- | ---- | -------------------------------------- | -------- |
|
||||
| deviceId | text | Unique device identifier of the sensor | yes |
|
||||
|
||||
### `doorlock` Configuration
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| -------- | ---- | ---------------------------------------- | -------- |
|
||||
| deviceId | text | Unique device identifier of the doorlock | yes |
|
||||
|
||||
### `chime` Configuration
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| -------- | ---- | ------------------------------------- | -------- |
|
||||
| deviceId | text | Unique device identifier of the chime | yes |
|
||||
|
||||
## Channels
|
||||
|
||||
Below are the channels exposed by each thing type.
|
||||
Some camera channels are created dynamically depending on device capabilities.
|
||||
|
||||
### `nvr` Bridge Channels
|
||||
|
||||
The NVR bridge provides comprehensive monitoring channels for system health, storage, and configuration.
|
||||
|
||||
| Channel ID | Item Type | RW | Description | Advanced |
|
||||
| ----------------------------- | -------------------- | --- | ------------------------------ | -------- |
|
||||
| storage-total | Number:DataAmount | R | Total storage capacity | false |
|
||||
| storage-used | Number:DataAmount | R | Used storage | false |
|
||||
| storage-available | Number:DataAmount | R | Available storage | false |
|
||||
| storage-utilization | Number:Dimensionless | R | Storage utilization percentage | false |
|
||||
| recording-retention | Number:Time | R | Recording retention duration | false |
|
||||
| nvr-storage-device-healthy | Switch | R | All storage devices healthy | false |
|
||||
| nvr-camera-utilization | Number | R | Camera capacity utilization | true |
|
||||
| nvr-recording-mode | String | R | Global camera recording mode | true |
|
||||
| nvr-recording-disabled | Switch | R | Recording globally disabled | true |
|
||||
| nvr-recording-motion-only | Switch | R | Recording motion only | true |
|
||||
| nvr-is-away | Switch | R | NVR away mode status | true |
|
||||
| nvr-geofencing-enabled | Switch | R | Geofencing enabled | true |
|
||||
| nvr-smart-detection-available | Switch | R | Smart detection available | true |
|
||||
| nvr-insights-enabled | Switch | R | Insights enabled | true |
|
||||
| nvr-can-auto-update | Switch | R | Can auto-update | true |
|
||||
| nvr-last-update-at | DateTime | R | Last update timestamp | true |
|
||||
| nvr-protect-updatable | Switch | R | Protect is updatable | true |
|
||||
|
||||
### `camera` Channels
|
||||
|
||||
- The following are dynamically created depending on the supported features.
|
||||
- Advanced channels are hidden by default in the MainUI, select "Show Advanced" to see them.
|
||||
|
||||
#### Basic Control & Settings
|
||||
|
||||
| Channel ID | Item Type | RW | Description | Advanced |
|
||||
| ------------------ | --------- | --- | ------------------------------------------------------ | -------- |
|
||||
| mic-volume | Dimmer | RW | Microphone volume (0-100) | true |
|
||||
| video-mode | String | RW | Camera video mode (`default`, `highFps`, `sport`, ...) | true |
|
||||
| hdr-type | String | RW | HDR mode (`auto`, `on`, `off`) | true |
|
||||
| osd-name | Switch | RW | Show name on OSD | true |
|
||||
| osd-date | Switch | RW | Show date on OSD | true |
|
||||
| osd-logo | Switch | RW | Show logo on OSD | true |
|
||||
| led-enabled | Switch | RW | Enable/disable camera status LED | true |
|
||||
| active-patrol-slot | Number | RW | Active PTZ patrol slot (set 0 to stop) | false |
|
||||
|
||||
#### Streaming URLs
|
||||
|
||||
| Channel ID | Item Type | RW | Description | Advanced |
|
||||
| ------------------ | --------- | --- | ------------------------------------- | -------- |
|
||||
| webrtc-url-high | String | R | WebRTC stream URL for high quality | true |
|
||||
| webrtc-url-medium | String | R | WebRTC stream URL for medium quality | true |
|
||||
| webrtc-url-low | String | R | WebRTC stream URL for low quality | true |
|
||||
| webrtc-url-package | String | R | WebRTC stream URL for package quality | true |
|
||||
| rtsp-url-high | String | R | RTSP stream URL for high quality | true |
|
||||
| rtsp-url-medium | String | R | RTSP stream URL for medium quality | true |
|
||||
| rtsp-url-low | String | R | RTSP stream URL for low quality | true |
|
||||
| rtsp-url-package | String | R | RTSP stream URL for package quality | true |
|
||||
|
||||
#### Snapshots & Images
|
||||
|
||||
| Channel ID | Item Type | RW | Description | Advanced |
|
||||
| ---------------------------- | --------- | --- | -------------------------------------------------- | -------- |
|
||||
| snapshot | Image | R | Snapshot image. Send a `REFRESH` command to update | false |
|
||||
| snapshot-url | String | R | Snapshot image URL | false |
|
||||
| motion-snapshot | Image | R | Snapshot captured around motion event | false |
|
||||
| smart-detect-audio-snapshot | Image | R | Snapshot captured around smart audio detection | false |
|
||||
| smart-detect-zone-snapshot | Image | R | Snapshot captured around smart zone detection | false |
|
||||
| smart-detect-line-snapshot | Image | R | Snapshot captured around smart line detection | false |
|
||||
| smart-detect-loiter-snapshot | Image | R | Snapshot captured around smart loiter detection | false |
|
||||
| motion-thumbnail | Image | R | Motion event thumbnail image | false |
|
||||
| motion-heatmap | Image | R | Motion event heatmap image | false |
|
||||
|
||||
#### Motion & Detection
|
||||
|
||||
| Channel ID | Item Type | RW | Description | Advanced |
|
||||
| --------------------------- | --------- | --- | ----------------------------------------------- | -------- |
|
||||
| motion-contact | Contact | R | Motion state (OPEN = motion detected) | false |
|
||||
| smart-detect-audio-contact | Contact | R | Smart audio detection active state | false |
|
||||
| smart-detect-zone-contact | Contact | R | Smart zone detection active state | false |
|
||||
| smart-detect-line-contact | Contact | R | Smart line detection active state | false |
|
||||
| smart-detect-loiter-contact | Contact | R | Smart loiter detection active state | false |
|
||||
| is-motion-detected | Switch | R | Motion currently detected | false |
|
||||
| last-motion | DateTime | R | Timestamp of last motion | true |
|
||||
| last-smart-detect | DateTime | R | Timestamp of last smart detection | false |
|
||||
| last-smart-detect-types | String | R | Types of last smart detection (comma-separated) | false |
|
||||
|
||||
#### Doorbell
|
||||
|
||||
| Channel ID | Item Type | RW | Description | Advanced |
|
||||
| -------------------------------------- | ----------- | --- | ------------------------------------------ | -------- |
|
||||
| doorbell-default-message | String | RW | Default doorbell LCD message text | false |
|
||||
| doorbell-default-message-reset-timeout | Number:Time | RW | Default doorbell LCD message reset timeout | true |
|
||||
| doorbell-ring-volume | Dimmer | RW | Doorbell ring volume (0-100) | true |
|
||||
| doorbell-chime-duration | Number:Time | RW | Doorbell chime duration | true |
|
||||
| lcd-message | String | R | Current LCD message text | false |
|
||||
| is-ringing | Switch | R | Doorbell is currently ringing | false |
|
||||
| last-ring | DateTime | R | Timestamp of last doorbell ring | true |
|
||||
| ring-contact | Contact | R | Ring state (OPEN = ring detected) | false |
|
||||
| ring-snapshot | Image | R | Snapshot captured around ring event | false |
|
||||
|
||||
#### Device Status & Health
|
||||
|
||||
| Channel ID | Item Type | RW | Description | Advanced |
|
||||
| --------------------------------- | ----------------------- | --- | --------------------------------- | -------- |
|
||||
| is-connected | Switch | R | Camera is connected | false |
|
||||
| is-dark | Switch | R | Scene is currently dark | false |
|
||||
| is-recording | Switch | R | Camera is currently recording | false |
|
||||
| is-smart-detected | Switch | R | Smart detection currently active | false |
|
||||
| is-live-heatmap-enabled | Switch | R | Live heatmap enabled | true |
|
||||
| video-reconfiguration-in-progress | Switch | R | Video reconfiguration in progress | true |
|
||||
| device-state | String | R | Device operational state | true |
|
||||
| device-uptime | Number:Time | R | Device uptime | true |
|
||||
| uptime-started | DateTime | R | Device up since timestamp | true |
|
||||
| connected-since | DateTime | R | Connected since timestamp | true |
|
||||
| last-seen | DateTime | R | Last seen timestamp | true |
|
||||
| last-smart | DateTime | R | Last smart detection timestamp | true |
|
||||
| connection-host | String | R | Connection host address | true |
|
||||
| last-disconnect | DateTime | R | Last disconnect timestamp | true |
|
||||
| connection-state | String | R | Connection state | true |
|
||||
| state | String | R | Device state | true |
|
||||
| wired-connection-state | String | R | Wired connection state | true |
|
||||
| physical-connection-rate | Number:DataTransferRate | R | Physical connection rate | true |
|
||||
|
||||
#### Network & WiFi
|
||||
|
||||
| Channel ID | Item Type | RW | Description | Advanced |
|
||||
| -------------------------- | -------------------- | --- | ------------------------------ | -------- |
|
||||
| phy-rate | Number | R | Physical connection rate | true |
|
||||
| is-probing-for-wifi | Switch | R | Probing for WiFi | true |
|
||||
| is-poor-network | Switch | R | Network quality is poor | true |
|
||||
| is-wireless-uplink-enabled | Switch | R | Wireless uplink enabled | true |
|
||||
| ap-mac | String | R | Access point MAC address | true |
|
||||
| wifi-channel | Number | R | WiFi channel number | true |
|
||||
| wifi-frequency | Number:Frequency | R | WiFi frequency | true |
|
||||
| wifi-signal-quality | Number:Dimensionless | R | WiFi signal quality percentage | true |
|
||||
| wifi-signal-strength | Number:Power | R | WiFi signal strength (dBm) | true |
|
||||
|
||||
#### Storage & Recording
|
||||
|
||||
| Channel ID | Item Type | RW | Description | Advanced |
|
||||
| ------------ | ----------------------- | --- | --------------------------- | -------- |
|
||||
| storage-used | Number:DataAmount | R | Storage used by this camera | false |
|
||||
| storage-rate | Number:DataTransferRate | R | Recording storage rate | true |
|
||||
|
||||
#### Power & Battery
|
||||
|
||||
| Channel ID | Item Type | RW | Description | Advanced |
|
||||
| ------------------- | ------------------------ | --- | ------------------------- | -------- |
|
||||
| voltage | Number:ElectricPotential | R | Input voltage | true |
|
||||
| battery-percentage | Number:Dimensionless | R | Battery charge percentage | false |
|
||||
| battery-is-charging | Switch | R | Battery is charging | false |
|
||||
| battery-sleep-state | String | R | Battery sleep state | true |
|
||||
|
||||
#### Recording & Detection Settings
|
||||
|
||||
| Channel ID | Item Type | RW | Description | Advanced |
|
||||
| ------------------------ | --------- | --- | --------------------------------------------- | -------- |
|
||||
| recording-mode | String | RW | Recording mode | true |
|
||||
| motion-detection-enabled | Switch | RW | Enable/disable motion detection | true |
|
||||
| use-global-settings | Switch | RW | Use NVR global settings instead of per-camera | true |
|
||||
| ir-mode | String | RW | IR LED mode | true |
|
||||
| hdr-enabled | Switch | RW | Enable HDR mode | true |
|
||||
| high-fps-enabled | Switch | RW | Enable high frame rate mode | true |
|
||||
| mic-enabled | Switch | RW | Enable/disable microphone | true |
|
||||
| camera-speaker-volume | Dimmer | RW | Speaker volume (0-100) | true |
|
||||
| camera-zoom-level | Dimmer | RW | Optical zoom level (0-100) | false |
|
||||
| camera-wdr-level | Number | RW | Wide dynamic range level (0-3) | true |
|
||||
| device-reboot | Switch | W | Reboot device (send ON command) | true |
|
||||
|
||||
#### Smart Detection Controls
|
||||
|
||||
| Channel ID | Item Type | RW | Description | Advanced |
|
||||
| ---------------------------------- | --------- | --- | ------------------------------ | -------- |
|
||||
| smart-detect-person-enabled | Switch | RW | Enable person detection | true |
|
||||
| smart-detect-vehicle-enabled | Switch | RW | Enable vehicle detection | true |
|
||||
| smart-detect-face-enabled | Switch | RW | Enable face detection | true |
|
||||
| smart-detect-license-plate-enabled | Switch | RW | Enable license plate detection | true |
|
||||
| smart-detect-package-enabled | Switch | RW | Enable package detection | true |
|
||||
| smart-detect-animal-enabled | Switch | RW | Enable animal detection | true |
|
||||
|
||||
#### PTZ Controls
|
||||
|
||||
| Channel ID | Item Type | RW | Description | Advanced |
|
||||
| ----------------- | --------- | --- | ---------------------------------------------------- | -------- |
|
||||
| ptz-relative-pan | Number | RW | Relative pan (negative = left, positive = right) | false |
|
||||
| ptz-relative-tilt | Number | RW | Relative tilt (negative = down, positive = up) | false |
|
||||
| ptz-relative-zoom | Number | RW | Relative zoom (negative = out, positive = in) | false |
|
||||
| ptz-center | String | W | Center on coordinates (format: `x,y,z` where 0-1000) | false |
|
||||
| ptz-set-home | Switch | W | Set home position to current (send ON) | true |
|
||||
| ptz-create-preset | String | W | Create preset at current position (`slot,name`) | true |
|
||||
| ptz-delete-preset | Number | W | Delete preset by slot number | true |
|
||||
|
||||
Trigger channels (for rules):
|
||||
|
||||
- Triggers Channels are hidden by default in the MainUI, select "Show Advanced" to see them.
|
||||
|
||||
| Trigger Channel ID | Payload (if any) | Description |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
|
||||
| ring | `PRESSED`, `RELEASED` | Doorbell ring event |
|
||||
| motion-start | none | Motion started |
|
||||
| motion-update | none | Motion updated (debounced update event) |
|
||||
| smart-detect-audio-start | `alrmSmoke`, `alrmCmonx`, `alrmSiren`, `alrmBabyCry`, `alrmSpeak`, `alrmBark`, `alrmBurglar`, `alrmCarHorn`, `alrmGlassBreak`, `none` | Smart audio detection started |
|
||||
| smart-detect-audio-update | `alrmSmoke`, `alrmCmonx`, `alrmSiren`, `alrmBabyCry`, `alrmSpeak`, `alrmBark`, `alrmBurglar`, `alrmCarHorn`, `alrmGlassBreak`, `none` | Smart audio detection updated |
|
||||
| smart-detect-zone-start | `person`, `vehicle`, `package`, `licensePlate`, `face`, `animal`, `none` | Zone smart detection started |
|
||||
| smart-detect-zone-update | `person`, `vehicle`, `package`, `licensePlate`, `face`, `animal`, `none` | Zone smart detection updated |
|
||||
| smart-detect-line-start | `person`, `vehicle`, `package`, `licensePlate`, `face`, `animal`, `none` | Line smart detection started |
|
||||
| smart-detect-line-update | `person`, `vehicle`, `package`, `licensePlate`, `face`, `animal`, `none` | Line smart detection updated |
|
||||
| smart-detect-loiter-start | `person`, `vehicle`, `package`, `licensePlate`, `face`, `animal`, `none` | Loiter smart detection started |
|
||||
| smart-detect-loiter-update | `person`, `vehicle`, `package`, `licensePlate`, `face`, `animal`, `none` | Loiter smart detection updated |
|
||||
|
||||
#### Snapshot Channels
|
||||
|
||||
Snapshot channels can be configured to take a snapshot before or after the trigger event or item state change.
|
||||
By default, a snapshot is taken before the trigger event fires or item state updates so its immediately available for use in rules.
|
||||
This however can cause a slight delay in the rule execution if the snapshot itself is delayed.
|
||||
The order in which the snapshot is taken can be configured via the snapshot channel configuration through textual files or the MainUI.
|
||||
To take a snapshot after the event trigger fires or item state updates, you can set the sequence to "after".
|
||||
If you do not want to take a snapshot on a trigger event or item state update, you can set the sequence to "none".
|
||||
|
||||
#### Motion Contact Channels
|
||||
|
||||
Motion contact channels are configured to latch/close after a defined delay after a motion event.
|
||||
By default, the contact is considered latched/closed after 5 seconds of no motion events.
|
||||
This delay can be configured via the contact channel configuration through textual files or the MainUI.
|
||||
|
||||
### Floodlight (`light`)
|
||||
|
||||
| Channel ID | Item Type | RW | Description | Advanced |
|
||||
| ------------------- | -------------------- | --- | ------------------------------------------ | -------- |
|
||||
| light | Switch | RW | Main floodlight on/off (forces light) | false |
|
||||
| is-dark | Switch | R | Scene is currently dark | false |
|
||||
| pir-motion | Trigger | - | PIR motion event | false |
|
||||
| last-motion | DateTime | R | Timestamp of last motion | true |
|
||||
| light-mode | String | RW | Light mode (`always`, `motion`, `off`) | true |
|
||||
| enable-at | String | RW | When mode is relevant (`fulltime`, `dark`) | true |
|
||||
| indicator-enabled | Switch | RW | Status LED indicator on floodlight | true |
|
||||
| pir-duration | Number:Time | RW | How long the light stays on after motion | true |
|
||||
| pir-sensitivity | Number:Dimensionless | RW | PIR motion sensitivity (0-100) | true |
|
||||
| led-level | Number | RW | LED brightness level (1-6) | true |
|
||||
| light-mode-advanced | String | RW | Advanced light mode settings | true |
|
||||
| device-reboot | Switch | W | Reboot device (send ON command) | true |
|
||||
|
||||
### Sensor (`sensor`)
|
||||
|
||||
| Channel ID | Item Type | RW | Description | Advanced |
|
||||
| ------------------- | ------------------ | --- | ---------------------------------------- | -------- |
|
||||
| battery | Number | R | Battery charge level (%) | false |
|
||||
| contact | Contact | R | Contact state (OPEN/CLOSED) | false |
|
||||
| temperature | Number:Temperature | R | Ambient temperature | false |
|
||||
| humidity | Number | R | Ambient humidity | false |
|
||||
| illuminance | Number:Illuminance | R | Ambient light (Lux) | false |
|
||||
| alarm-contact | Contact | R | Smoke/CO alarm contact (OPEN = alarming) | false |
|
||||
| water-leak-contact | Contact | R | Water leak contact (OPEN = leak) | false |
|
||||
| tamper-contact | Contact | R | Tamper contact (OPEN = tampering) | false |
|
||||
| sensor-tamper-reset | Switch | W | Reset tamper status (send ON) | true |
|
||||
| device-reboot | Switch | W | Reboot device (send ON command) | true |
|
||||
|
||||
### Doorlock (`doorlock`)
|
||||
|
||||
| Channel ID | Item Type | RW | Description | Advanced |
|
||||
| ------------------ | -------------------- | --- | -------------------------------------- | -------- |
|
||||
| lock | Switch | RW | Lock control (ON=locked, OFF=unlocked) | false |
|
||||
| lock-status | String | R | Current lock status | false |
|
||||
| calibrate | Switch | W | Calibrate lock (send ON) | true |
|
||||
| auto-close-time | Number:Time | RW | Auto-close time in seconds | true |
|
||||
| battery-percentage | Number:Dimensionless | R | Battery charge percentage | false |
|
||||
|
||||
### Chime (`chime`)
|
||||
|
||||
| Channel ID | Item Type | RW | Description | Advanced |
|
||||
| ------------ | --------- | --- | ------------------------------- | -------- |
|
||||
| play-chime | Switch | W | Play chime sound (send ON) | true |
|
||||
| play-buzzer | Switch | W | Play buzzer sound (send ON) | false |
|
||||
| volume | Dimmer | RW | Chime volume (0-100) | false |
|
||||
| repeat-times | Number | RW | Number of times to repeat sound | true |
|
||||
|
||||
Trigger channels (for rules):
|
||||
|
||||
| Trigger Channel ID | Payload (if any) | Description |
|
||||
| ------------------ | ------------------------------------------ | -------------------- |
|
||||
| opened | `door`, `window`, `garage`, `leak`, `none` | Sensor opened |
|
||||
| closed | `door`, `window`, `garage`, `leak`, `none` | Sensor closed |
|
||||
| alarm | `smoke`, `CO` (optional) | Smoke/CO alarm event |
|
||||
| water-leak | `door`, `window`, `garage`, `leak`, `none` | Water leak detected |
|
||||
| tamper | none | Tampering detected |
|
||||
|
||||
## Real-time Media
|
||||
|
||||
If enabled in the binding configuration, openHAB will proxy live media using WebRTC which is compatible with the MainUI video widget.
|
||||
|
||||
### Stream URLs
|
||||
|
||||
The URL for WebRTC streams can be found in 2 different ways
|
||||
|
||||
1. As a property on the Camera Thing (webrtc-url-high, webrtc-url-medium, webrtc-url-low, webrtc-url-package)
|
||||
1. As an Item linked to a channel on the Camera Thing (webrtc-url-high, webrtc-url-medium, webrtc-url-low, webrtc-url-package)
|
||||
|
||||
All of the above URLs are relative to the openHAB instance.
|
||||
|
||||
The playback URLs can be used in the MainUI video widget by enabling WebRTC in the advanced settings in the video widget and using an above URL or Item as the source.
|
||||
|
||||
An example WebRTC stream URL would be:
|
||||
|
||||
`/unifi/media/play/unifi:camera:home:1234567890:high`
|
||||
|
||||
Where `unifi:camera:home:1234567890` is the camera's Thing UID and `high` is the quality (high, medium, low, package) if supported by the camera.
|
||||
|
||||
You can either use the String URL or select the Item linked to the channel in the MainUI video widget.
|
||||
|
||||
<img src="video-card.png" width="600" alt="video widget settings">
|
||||
|
||||
It's also highly recommended to use the camera's Snapshot URL property or the Item linked to the `snapshot-url` channel to get the live snapshot image URL which can be used for the poster image option in the MainUI video widget.
|
||||
|
||||
An example snapshot image URL would be:
|
||||
|
||||
`/unifi/media/image/unifi:camera:home:1234567890`
|
||||
|
||||
Where `unifi:camera:home:1234567890` is the camera's Thing UID.
|
||||
You can append `?quality=high` to the URL to get the a higher quality snapshot image if supported by the camera, but can fail if not supported.
|
||||
The default quality level is suitable for most use cases, and supported by all cameras.
|
||||
|
||||
### Talkback Support (2-way audio)
|
||||
|
||||
Some UniFi Protect cameras support "Talkback", which allows you to publish audio back to the camera in a push to talk manner.
|
||||
If supported, you can enable "Two Way Audio" in the MainUI video widget (along with selecting WebRTC under advanced settings in the video widget) which will display a microphone icon for push to talk functionality.
|
||||
This is automatically supported by the binding and will be enabled if supported by the camera.
|
||||
|
||||
## Full Examples (Textual Configuration)
|
||||
|
||||
Replace the IDs with your own thing and item names.
|
||||
|
||||
### Things (`.things`)
|
||||
|
||||
The Protect NVR is a child of the `unifi:controller` bridge from the parent UniFi binding.
|
||||
Host and credentials live on the controller bridge; the NVR bridge only carries Protect-specific settings.
|
||||
|
||||
```java
|
||||
Bridge unifi:controller:myConsole "UniFi Console" [ host="192.168.1.10", username="localadmin", password="your_password" ] {
|
||||
// Protect NVR — token is optional, auto-created if left blank
|
||||
Bridge unifi:nvr myNvr "UniFi Protect NVR" [] {
|
||||
Thing camera frontdoor "Front Door Camera" [ deviceId="60546f80e4b0abcd12345678", enableWebRTC=true ]
|
||||
Thing light driveway "Driveway Floodlight" [ deviceId="60a1b2c3d4e5f67890123456" ]
|
||||
Thing sensor garagedoor "Garage Door Sensor" [ deviceId="60112233445566778899aabb" ]
|
||||
Thing doorlock frontdoorlock "Front Door Lock" [ deviceId="60c1d2e3f4a5b67890123456" ]
|
||||
Thing chime hallwaychime "Hallway Chime" [ deviceId="60d1e2f3a4b5c67890123456" ]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Items (`.items`)
|
||||
|
||||
```java
|
||||
// NVR Monitoring
|
||||
Number:DataAmount NVR_Storage_Total "Total Storage [%.0f GB]" { channel="unifi:nvr:myNvr:storage-total" }
|
||||
Number:DataAmount NVR_Storage_Used "Used Storage [%.0f GB]" { channel="unifi:nvr:myNvr:storage-used" }
|
||||
Number:DataAmount NVR_Storage_Available "Available Storage [%.0f GB]" { channel="unifi:nvr:myNvr:storage-available" }
|
||||
Number NVR_Storage_Utilization "Storage Utilization [%.1f %%]" { channel="unifi:nvr:myNvr:storage-utilization" }
|
||||
Switch NVR_Devices_Healthy "All Devices Healthy" { channel="unifi:nvr:myNvr:nvr-storage-device-healthy" }
|
||||
Number NVR_Camera_Util "Camera Utilization [%.0f %%]" { channel="unifi:nvr:myNvr:nvr-camera-utilization" }
|
||||
String NVR_Recording_Mode "Recording Mode [%s]" { channel="unifi:nvr:myNvr:nvr-recording-mode" }
|
||||
Switch NVR_Away_Mode "Away Mode" { channel="unifi:nvr:myNvr:nvr-is-away" }
|
||||
|
||||
// Camera
|
||||
Dimmer Cam_Front_MicVolume "Mic Volume [%d %%]" { channel="unifi:camera:myNvr:frontdoor:mic-volume" }
|
||||
String Cam_Front_VideoMode "Video Mode [%s]" { channel="unifi:camera:myNvr:frontdoor:video-mode" }
|
||||
String Cam_Front_HDR "HDR [%s]" { channel="unifi:camera:myNvr:frontdoor:hdr-type" }
|
||||
Switch Cam_Front_OSD_Name "OSD Name" { channel="unifi:camera:myNvr:frontdoor:osd-name" }
|
||||
Switch Cam_Front_OSD_Date "OSD Date" { channel="unifi:camera:myNvr:frontdoor:osd-date" }
|
||||
Switch Cam_Front_OSD_Logo "OSD Logo" { channel="unifi:camera:myNvr:frontdoor:osd-logo" }
|
||||
Switch Cam_Front_LED "Status LED" { channel="unifi:camera:myNvr:frontdoor:led-enabled" }
|
||||
Number Cam_Front_PatrolSlot "PTZ Patrol Slot [%d]" { channel="unifi:camera:myNvr:frontdoor:active-patrol-slot" }
|
||||
String Cam_Front_WebRTC_High "WebRTC High [%s]" { channel="unifi:camera:myNvr:frontdoor:webrtc-url-high" }
|
||||
Contact Cam_Front_Motion "Motion [%s]" { channel="unifi:camera:myNvr:frontdoor:motion-contact" }
|
||||
Image Cam_Front_MotionSnapshot "Motion Snapshot" { channel="unifi:camera:myNvr:frontdoor:motion-snapshot" }
|
||||
Switch Cam_Front_Connected "Camera Connected" { channel="unifi:camera:myNvr:frontdoor:is-connected" }
|
||||
Switch Cam_Front_IsDark "Is Dark Outside" { channel="unifi:camera:myNvr:frontdoor:is-dark" }
|
||||
Number Cam_Front_WiFi_Quality "WiFi Quality [%.0f %%]" { channel="unifi:camera:myNvr:frontdoor:wifi-quality" }
|
||||
DateTime Cam_Front_LastMotion "Last Motion [%1$ta %1$tR]" { channel="unifi:camera:myNvr:frontdoor:last-motion" }
|
||||
|
||||
// Floodlight
|
||||
Switch Light_Driveway_OnOff "Driveway Light" { channel="unifi:light:myNvr:driveway:light" }
|
||||
Switch Light_Driveway_IsDark "Is Dark" { channel="unifi:light:myNvr:driveway:is-dark" }
|
||||
DateTime Light_Driveway_LastMotion "Last Motion [%1$ta %1$tR]" { channel="unifi:light:myNvr:driveway:last-motion" }
|
||||
String Light_Driveway_Mode "Mode [%s]" { channel="unifi:light:myNvr:driveway:light-mode" }
|
||||
String Light_Driveway_EnableAt "Enable At [%s]" { channel="unifi:light:myNvr:driveway:enable-at" }
|
||||
Switch Light_Driveway_Indicator "Indicator LED" { channel="unifi:light:myNvr:driveway:indicator-enabled" }
|
||||
Number Light_Driveway_PIR_Dur "PIR Duration [%.0f ms]" { channel="unifi:light:myNvr:driveway:pir-duration" }
|
||||
Number Light_Driveway_PIR_Sens "PIR Sensitivity [%.0f]" { channel="unifi:light:myNvr:driveway:pir-sensitivity" }
|
||||
Number Light_Driveway_LED_Level "LED Level [%.0f]" { channel="unifi:light:myNvr:driveway:led-level" }
|
||||
|
||||
// Sensor
|
||||
Number Sensor_Garage_Battery "Battery [%.0f %%]" { channel="unifi:sensor:myNvr:garagedoor:battery" }
|
||||
Contact Sensor_Garage_Contact "Contact [%s]" { channel="unifi:sensor:myNvr:garagedoor:contact" }
|
||||
Number:Temperature Sensor_Garage_T "Temperature [%.1f %unit%]" { channel="unifi:sensor:myNvr:garagedoor:temperature" }
|
||||
Number Sensor_Garage_Humidity "Humidity [%.0f %%]" { channel="unifi:sensor:myNvr:garagedoor:humidity" }
|
||||
Number:Illuminance Sensor_Garage_L "Illuminance [%.0f lx]" { channel="unifi:sensor:myNvr:garagedoor:illuminance" }
|
||||
Contact Sensor_Garage_Alarm "Alarm [%s]" { channel="unifi:sensor:myNvr:garagedoor:alarm-contact" }
|
||||
Contact Sensor_Garage_Leak "Leak [%s]" { channel="unifi:sensor:myNvr:garagedoor:water-leak-contact" }
|
||||
Contact Sensor_Garage_Tamper "Tamper [%s]" { channel="unifi:sensor:myNvr:garagedoor:tamper-contact" }
|
||||
|
||||
// Doorlock
|
||||
Switch Lock_Front_Lock "Front Door Lock" { channel="unifi:doorlock:myNvr:frontdoorlock:lock" }
|
||||
String Lock_Front_Status "Lock Status [%s]" { channel="unifi:doorlock:myNvr:frontdoorlock:lock-status" }
|
||||
Number:Time Lock_Front_AutoClose "Auto-Close Time [%.0f s]" { channel="unifi:doorlock:myNvr:frontdoorlock:auto-close-time" }
|
||||
Number Lock_Front_Battery "Battery [%.0f %%]" { channel="unifi:doorlock:myNvr:frontdoorlock:battery-percentage" }
|
||||
|
||||
// Chime
|
||||
Dimmer Chime_Hallway_Volume "Chime Volume [%d %%]" { channel="unifi:chime:myNvr:hallwaychime:volume" }
|
||||
Number Chime_Hallway_Repeat "Repeat Times [%.0f]" { channel="unifi:chime:myNvr:hallwaychime:repeat-times" }
|
||||
```
|
||||
|
||||
### Sitemap (`.sitemap`)
|
||||
|
||||
```perl
|
||||
sitemap home label="Home" {
|
||||
Frame label="Front Door Camera" {
|
||||
Text item=Cam_Front_Motion
|
||||
Image item=Cam_Front_MotionSnapshot
|
||||
}
|
||||
Frame label="Driveway Floodlight" {
|
||||
Switch item=Light_Driveway_OnOff
|
||||
Text item=Light_Driveway_IsDark
|
||||
Text item=Light_Driveway_LastMotion
|
||||
Selection item=Light_Driveway_Mode mappings=[always="Always", motion="Motion", off="Off"]
|
||||
Selection item=Light_Driveway_EnableAt mappings=[fulltime="Full time", dark="Dark"]
|
||||
Setpoint item=Light_Driveway_PIR_Sens minValue=0 maxValue=100 step=1
|
||||
Setpoint item=Light_Driveway_LED_Level minValue=1 maxValue=6 step=1
|
||||
}
|
||||
Frame label="Garage Sensor" {
|
||||
Text item=Sensor_Garage_Contact
|
||||
Text item=Sensor_Garage_T
|
||||
Text item=Sensor_Garage_Humidity
|
||||
Text item=Sensor_Garage_L
|
||||
Text item=Sensor_Garage_Battery
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
Examples showing trigger channels.
|
||||
|
||||
```java
|
||||
// Camera motion start/update
|
||||
rule "Front door motion alert"
|
||||
when
|
||||
Channel "unifi:camera:myNvr:frontdoor:motion-start" triggered
|
||||
then
|
||||
logInfo("protect", "Front door motion started")
|
||||
end
|
||||
|
||||
rule "Front door motion update"
|
||||
when
|
||||
Channel "unifi:camera:myNvr:frontdoor:motion-update" triggered
|
||||
then
|
||||
logInfo("protect", "Front door motion update")
|
||||
end
|
||||
|
||||
// Camera smart detection with payload
|
||||
rule "Front door smart zone detect"
|
||||
when
|
||||
Channel "unifi:camera:myNvr:frontdoor:smart-detect-zone-start" triggered
|
||||
then
|
||||
// Access payload from the trigger channel event (person, vehicle, package, licensePlate, face, animal, none)
|
||||
val String payload = receivedEvent.getEvent()
|
||||
logInfo("protect", "Smart zone detection started: {}", payload)
|
||||
end
|
||||
|
||||
rule "Front door smart zone update"
|
||||
when
|
||||
Channel "unifi:camera:myNvr:frontdoor:smart-detect-zone-update" triggered
|
||||
then
|
||||
val String payload = receivedEvent.getEvent()
|
||||
logInfo("protect", "Smart zone detection updated: {}", payload)
|
||||
end
|
||||
|
||||
// Camera smart audio detect with payload
|
||||
rule "Front door smart audio detect"
|
||||
when
|
||||
Channel "unifi:camera:myNvr:frontdoor:smart-audio-detect-start" triggered
|
||||
then
|
||||
val String payload = receivedEvent.getEvent() // alrmSmoke, alrmCmonx, alrmSiren, alrmBabyCry, alrmSpeak, alrmBark, alrmBurglar, alrmCarHorn, alrmGlassBreak, none
|
||||
logInfo("protect", "Smart audio detected: {}", payload)
|
||||
end
|
||||
|
||||
rule "Front door smart audio update"
|
||||
when
|
||||
Channel "unifi:camera:myNvr:frontdoor:smart-audio-detect-update" triggered
|
||||
then
|
||||
val String payload = receivedEvent.getEvent()
|
||||
logInfo("protect", "Smart audio detection updated: {}", payload)
|
||||
end
|
||||
|
||||
// Camera doorbell ring with payload filtering
|
||||
rule "Front doorbell pressed"
|
||||
when
|
||||
Channel "unifi:camera:myNvr:frontdoor:ring" triggered PRESSED
|
||||
then
|
||||
logInfo("protect", "Doorbell pressed")
|
||||
end
|
||||
|
||||
// Or handle any ring payload generically
|
||||
rule "Front doorbell ring generic"
|
||||
when
|
||||
Channel "unifi:camera:myNvr:frontdoor:ring" triggered
|
||||
then
|
||||
val String payload = receivedEvent.getEvent() // PRESSED, RELEASED
|
||||
logInfo("protect", "Doorbell ring event: {}", payload)
|
||||
end
|
||||
|
||||
// Floodlight PIR motion trigger
|
||||
rule "Driveway PIR motion"
|
||||
when
|
||||
Channel "unifi:light:myNvr:driveway:pir-motion" triggered
|
||||
then
|
||||
logInfo("protect", "Driveway PIR motion")
|
||||
// Optionally turn on the light for a bit
|
||||
sendCommand(Light_Driveway_OnOff, ON)
|
||||
createTimer(now.plusSeconds(30), [ | sendCommand(Light_Driveway_OnOff, OFF) ])
|
||||
end
|
||||
|
||||
// Sensor opened/closed with payload
|
||||
rule "Garage sensor opened"
|
||||
when
|
||||
Channel "unifi:sensor:myNvr:garagedoor:opened" triggered
|
||||
then
|
||||
val String payload = receivedEvent.getEvent() // door, window, garage, leak, none
|
||||
logInfo("protect", "Garage sensor opened: {}", payload)
|
||||
end
|
||||
|
||||
rule "Garage sensor closed"
|
||||
when
|
||||
Channel "unifi:sensor:myNvr:garagedoor:closed" triggered
|
||||
then
|
||||
val String payload = receivedEvent.getEvent() // door, window, garage, leak, none
|
||||
logInfo("protect", "Garage sensor closed: {}", payload)
|
||||
end
|
||||
|
||||
// Sensor water leak
|
||||
rule "Garage water leak"
|
||||
when
|
||||
Channel "unifi:sensor:myNvr:garagedoor:water-leak" triggered
|
||||
then
|
||||
val String payload = receivedEvent.getEvent() // door, window, garage, leak, none
|
||||
logWarn("protect", "Water leak detected by garage sensor: {}", payload)
|
||||
end
|
||||
```
|
||||
|
||||
## Tips and Tricks
|
||||
|
||||
### Main UI Widgets
|
||||
|
||||
It can be helpful to display a live preview of multiple cameras in the MainUI using low quality streams, much like the UniFi Protect app, where clicking on a camera preview opens a higher quality version in a popup.
|
||||
The following widget creates a preview card for a camera, muting the audio and will open another widget or page when clicked anywhere on the card.
|
||||
Use the "low" quality stream for this preview widget, and the "high" quality stream for the popup, such as a dedicated page with a single video widget for the camera.
|
||||
A modest server can support dozens of simultaneous streams for MainUI clients with many camera views in a single page.
|
||||
|
||||
```yaml
|
||||
uid: unifi-webrtc-video-preview
|
||||
tags: []
|
||||
props:
|
||||
parameters:
|
||||
- description: Thing UID
|
||||
label: Thing UID
|
||||
name: thingUid
|
||||
required: true
|
||||
type: TEXT
|
||||
parameterGroups:
|
||||
- name: clickAction
|
||||
context: action
|
||||
label: Click Action
|
||||
component: f7-card
|
||||
config: {}
|
||||
slots:
|
||||
default:
|
||||
- component: oh-video
|
||||
config:
|
||||
hideControls: true
|
||||
playerType: webrtc
|
||||
posterURL: ='/unifi/media/image/' + props.thingUid + '?quality=low'
|
||||
startMuted: true
|
||||
style:
|
||||
position: absolute
|
||||
url: ='/unifi/media/play/' + props.thingUid + ':low'
|
||||
- component: oh-button
|
||||
config:
|
||||
actionPropsParameterGroup: clickAction
|
||||
style:
|
||||
height: 100%
|
||||
margin: 0px
|
||||
opacity: 100%
|
||||
position: absolute
|
||||
top: 0px
|
||||
width: 100%
|
||||
```
|
||||
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
+2
-106
@@ -12,125 +12,21 @@
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
|
||||
/**
|
||||
* The {@link UniFiBindingConstants} class defines common constants, which are
|
||||
* used across the UniFi binding.
|
||||
* Constants for the shared UniFi parent binding that owns the {@code unifi:controller} bridge.
|
||||
*
|
||||
* @author Matthew Bowman - Initial contribution
|
||||
* @author Patrik Wimnell - Blocking / Unblocking client support
|
||||
* @author Hilbrand Bouwkamp - Added poePort
|
||||
* @author Mark Herwege - Added guest vouchers
|
||||
* @author Dan Cunningham - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public final class UniFiBindingConstants {
|
||||
|
||||
public static final String BINDING_ID = "unifi";
|
||||
|
||||
// List of all Thing Types
|
||||
public static final ThingTypeUID THING_TYPE_CONTROLLER = new ThingTypeUID(BINDING_ID, "controller");
|
||||
public static final ThingTypeUID THING_TYPE_SITE = new ThingTypeUID(BINDING_ID, "site");
|
||||
public static final ThingTypeUID THING_TYPE_NETWORK = new ThingTypeUID(BINDING_ID, "network");
|
||||
public static final ThingTypeUID THING_TYPE_WLAN = new ThingTypeUID(BINDING_ID, "wlan");
|
||||
public static final ThingTypeUID THING_TYPE_WIRED_CLIENT = new ThingTypeUID(BINDING_ID, "wiredClient");
|
||||
public static final ThingTypeUID THING_TYPE_WIRELESS_CLIENT = new ThingTypeUID(BINDING_ID, "wirelessClient");
|
||||
public static final ThingTypeUID THING_TYPE_POE_PORT = new ThingTypeUID(BINDING_ID, "poePort");
|
||||
public static final ThingTypeUID THING_TYPE_ACCESS_POINT = new ThingTypeUID(BINDING_ID, "accessPoint");
|
||||
public static final Set<ThingTypeUID> ALL_THING_TYPE_SUPPORTED = Set.of(THING_TYPE_CONTROLLER, THING_TYPE_SITE,
|
||||
THING_TYPE_NETWORK, THING_TYPE_WLAN, THING_TYPE_WIRED_CLIENT, THING_TYPE_WIRELESS_CLIENT,
|
||||
THING_TYPE_POE_PORT, THING_TYPE_ACCESS_POINT);
|
||||
public static final Set<ThingTypeUID> THING_TYPE_SUPPORTED = Set.of(THING_TYPE_SITE, THING_TYPE_NETWORK,
|
||||
THING_TYPE_WLAN, THING_TYPE_WIRED_CLIENT, THING_TYPE_WIRELESS_CLIENT, THING_TYPE_POE_PORT,
|
||||
THING_TYPE_ACCESS_POINT);
|
||||
|
||||
// List of site channels
|
||||
public static final String CHANNEL_TOTAL_CLIENTS = "totalClients";
|
||||
public static final String CHANNEL_WIRELESS_CLIENTS = "wirelessClients";
|
||||
public static final String CHANNEL_WIRED_CLIENTS = "wiredClients";
|
||||
public static final String CHANNEL_GUEST_CLIENTS = "guestClients";
|
||||
public static final String CHANNEL_GUEST_VOUCHER = "guestVoucher";
|
||||
public static final String CHANNEL_GUEST_VOUCHERS_GENERATE = "guestVouchersGenerate";
|
||||
|
||||
// List of network channels
|
||||
public static final String CHANNEL_PURPOSE = "purpose";
|
||||
|
||||
// List of wlan channels
|
||||
public static final String CHANNEL_SECURITY = "security";
|
||||
public static final String CHANNEL_WLANBAND = "wlanBand";
|
||||
public static final String CHANNEL_WPAENC = "wpaEnc";
|
||||
public static final String CHANNEL_WPAMODE = "wpaMode";
|
||||
public static final String CHANNEL_PASSPHRASE = "passphrase";
|
||||
public static final String CHANNEL_QRCODE_ENCODING = "qrcodeEncoding";
|
||||
|
||||
// List of common wired + wireless client + device channels
|
||||
public static final String CHANNEL_ONLINE = "online";
|
||||
public static final String CHANNEL_NAME = "name";
|
||||
public static final String CHANNEL_SITE = "site";
|
||||
public static final String CHANNEL_IP_ADDRESS = "ipAddress";
|
||||
public static final String CHANNEL_UPTIME = "uptime";
|
||||
public static final String CHANNEL_LAST_SEEN = "lastSeen";
|
||||
public static final String CHANNEL_EXPERIENCE = "experience";
|
||||
|
||||
// List of common wired + wireless client channels
|
||||
public static final String CHANNEL_HOSTNAME = "hostname";
|
||||
public static final String CHANNEL_MAC_ADDRESS = "macAddress";
|
||||
public static final String CHANNEL_GUEST = "guest";
|
||||
public static final String CHANNEL_BLOCKED = "blocked";
|
||||
public static final String CHANNEL_RECONNECT = "reconnect";
|
||||
public static final String CHANNEL_CMD = "cmd";
|
||||
public static final String CHANNEL_CMD_RECONNECT = "reconnect";
|
||||
|
||||
// List of additional wireless client channels
|
||||
public static final String CHANNEL_AP = "ap";
|
||||
public static final String CHANNEL_ESSID = "essid";
|
||||
public static final String CHANNEL_RSSI = "rssi";
|
||||
|
||||
// List of switch port channels
|
||||
public static final String CHANNEL_ENABLE = "enable";
|
||||
public static final String CHANNEL_ENABLE_PARAMETER_MODE = "mode";
|
||||
public static final String CHANNEL_ENABLE_PARAMETER_MODE_OFF = "off";
|
||||
public static final String CHANNEL_ENABLE_PARAMETER_MODE_AUTO = "auto";
|
||||
public static final String CHANNEL_PORT_POE_MODE = "mode";
|
||||
public static final String CHANNEL_PORT_POE_CMD = "cmd";
|
||||
public static final String CHANNEL_PORT_POE_CMD_POWER_CYCLE = "powercycle";
|
||||
public static final String CHANNEL_PORT_POE_ENABLE = "enable";
|
||||
public static final String CHANNEL_PORT_POE_POWER = "power";
|
||||
public static final String CHANNEL_PORT_POE_VOLTAGE = "voltage";
|
||||
public static final String CHANNEL_PORT_POE_CURRENT = "current";
|
||||
|
||||
// List of access point device channels
|
||||
public static final String CHANNEL_AP_ENABLE = "enable";
|
||||
public static final String CHANNEL_AP_STATE = "state";
|
||||
public static final String CHANNEL_AP_LED = "led";
|
||||
|
||||
// List of all Parameters
|
||||
public static final String PARAMETER_HOST = "host";
|
||||
public static final String PARAMETER_PORT = "port";
|
||||
public static final String PARAMETER_USERNAME = "username";
|
||||
public static final String PARAMETER_PASSWORD = "password";
|
||||
public static final String PARAMETER_UNIFIOS = "unifios";
|
||||
public static final String PARAMETER_SITE = "site";
|
||||
public static final String PARAMETER_CID = "cid";
|
||||
public static final String PARAMETER_SID = "sid";
|
||||
public static final String PARAMETER_WID = "wid";
|
||||
public static final String PARAMETER_NID = "nid";
|
||||
public static final String PARAMETER_VOUCHER_COUNT = "voucherCount";
|
||||
public static final String PARAMETER_VOUCHER_EXPIRATION = "voucherExpiration";
|
||||
public static final String PARAMETER_VOUCHER_USERS = "voucherUsers";
|
||||
public static final String PARAMETER_VOUCHER_UP_LIMIT = "voucherUpLimit";
|
||||
public static final String PARAMETER_VOUCHER_DOWN_LIMIT = "voucherDownLimit";
|
||||
public static final String PARAMETER_VOUCHER_DATA_QUOTA = "voucherDataQuota";
|
||||
public static final String PARAMETER_PORT_NUMBER = "portNumber";
|
||||
public static final String PARAMETER_MAC_ADDRESS = "macAddress";
|
||||
|
||||
// UniFi device types
|
||||
public static final String DEVICE_TYPE_UAP = "uap";
|
||||
|
||||
private UniFiBindingConstants() {
|
||||
// Constants class
|
||||
}
|
||||
}
|
||||
|
||||
+8
-4
@@ -10,19 +10,23 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.config;
|
||||
package org.openhab.binding.unifi.internal;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link UnifiAccessBridgeConfiguration} class contains fields mapping thing configuration parameters.
|
||||
* Configuration fields for the {@code unifi:controller} bridge. Bound by the framework from the thing's
|
||||
* configuration.
|
||||
*
|
||||
* @author Dan Cunningham - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class UnifiAccessBridgeConfiguration {
|
||||
public class UniFiControllerConfiguration {
|
||||
|
||||
public String host = "";
|
||||
public String username = "";
|
||||
public String password = "";
|
||||
public int refreshInterval = 300;
|
||||
public int port = 443;
|
||||
public boolean unifios = true;
|
||||
public int timeoutSeconds = 30;
|
||||
}
|
||||
-114
@@ -1,114 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.unifi.internal.handler.UniFiControllerThingHandler;
|
||||
|
||||
/**
|
||||
* The {@link UniFiControllerThingConfig} encapsulates all the configuration options for an instance of the
|
||||
* {@link UniFiControllerThingHandler}.
|
||||
*
|
||||
* @author Matthew Bowman - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@SuppressWarnings("unused")
|
||||
public class UniFiControllerThingConfig {
|
||||
|
||||
private String host = "unifi";
|
||||
|
||||
private int port = 8443;
|
||||
|
||||
private String username = "";
|
||||
|
||||
private String password = "";
|
||||
|
||||
private int refresh = 10;
|
||||
|
||||
private int timeoutSeconds = 5;
|
||||
|
||||
private boolean unifios = false;
|
||||
|
||||
public String getHost() {
|
||||
return host;
|
||||
}
|
||||
|
||||
private void setHost(final String host) {
|
||||
// method to avoid ide auto format mark the field as final
|
||||
this.host = host;
|
||||
}
|
||||
|
||||
public int getPort() {
|
||||
return port;
|
||||
}
|
||||
|
||||
private void setPort(final int port) {
|
||||
// method to avoid ide auto format mark the field as final
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
private void setUsername(final String username) {
|
||||
// method to avoid ide auto format mark the field as final
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
private void setPassword(final String password) {
|
||||
// method to avoid ide auto format mark the field as final
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public int getRefresh() {
|
||||
return refresh;
|
||||
}
|
||||
|
||||
private void setRefresh(final int refresh) {
|
||||
// method to avoid ide auto format mark the field as final
|
||||
this.refresh = refresh;
|
||||
}
|
||||
|
||||
public int getTimeoutSeconds() {
|
||||
return timeoutSeconds;
|
||||
}
|
||||
|
||||
public void setTimeoutSeconds(int timeoutSeconds) {
|
||||
this.timeoutSeconds = timeoutSeconds;
|
||||
}
|
||||
|
||||
public boolean isUniFiOS() {
|
||||
return unifios;
|
||||
}
|
||||
|
||||
private void setUnifiOS(final boolean unifios) {
|
||||
// method to avoid ide auto format mark the field as final
|
||||
this.unifios = unifios;
|
||||
}
|
||||
|
||||
public boolean isValid() {
|
||||
return !host.isBlank() && !username.isBlank() && !password.isBlank();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "UniFiControllerConfig{host = " + host + ", port = " + port + ", username = " + username
|
||||
+ ", password = *****, refresh = " + refresh + ", timeout = " + timeoutSeconds + ", unifios = "
|
||||
+ unifios + "}";
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal;
|
||||
|
||||
import static org.openhab.binding.unifi.internal.UniFiBindingConstants.THING_TYPE_CONTROLLER;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.unifi.internal.handler.UniFiControllerBridgeHandler;
|
||||
import org.openhab.core.io.net.http.HttpClientFactory;
|
||||
import org.openhab.core.thing.Bridge;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
|
||||
import org.openhab.core.thing.binding.ThingHandler;
|
||||
import org.openhab.core.thing.binding.ThingHandlerFactory;
|
||||
import org.osgi.service.component.annotations.Activate;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
import org.osgi.service.component.annotations.Reference;
|
||||
|
||||
/**
|
||||
* OSGi handler factory for the shared UniFi parent binding. Creates the {@code unifi:controller} bridge handler
|
||||
* that child bindings depend on for a shared authenticated session.
|
||||
*
|
||||
* @author Dan Cunningham - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@Component(configurationPid = "binding.unifi", service = ThingHandlerFactory.class)
|
||||
public class UniFiHandlerFactory extends BaseThingHandlerFactory {
|
||||
|
||||
private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(THING_TYPE_CONTROLLER);
|
||||
|
||||
private final HttpClientFactory httpClientFactory;
|
||||
|
||||
@Activate
|
||||
public UniFiHandlerFactory(@Reference HttpClientFactory httpClientFactory) {
|
||||
this.httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
|
||||
return SUPPORTED_THING_TYPES.contains(thingTypeUID);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @Nullable ThingHandler createHandler(Thing thing) {
|
||||
if (THING_TYPE_CONTROLLER.equals(thing.getThingTypeUID()) && thing instanceof Bridge bridge) {
|
||||
return new UniFiControllerBridgeHandler(bridge, httpClientFactory);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
-99
@@ -1,99 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal;
|
||||
|
||||
import static org.openhab.binding.unifi.internal.UniFiBindingConstants.*;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.eclipse.jetty.util.ssl.SslContextFactory;
|
||||
import org.openhab.binding.unifi.internal.handler.UniFiAccessPointThingHandler;
|
||||
import org.openhab.binding.unifi.internal.handler.UniFiClientThingHandler;
|
||||
import org.openhab.binding.unifi.internal.handler.UniFiControllerThingHandler;
|
||||
import org.openhab.binding.unifi.internal.handler.UniFiNetworkThingHandler;
|
||||
import org.openhab.binding.unifi.internal.handler.UniFiPoePortThingHandler;
|
||||
import org.openhab.binding.unifi.internal.handler.UniFiSiteThingHandler;
|
||||
import org.openhab.binding.unifi.internal.handler.UniFiWlanThingHandler;
|
||||
import org.openhab.core.io.net.http.HttpClientFactory;
|
||||
import org.openhab.core.io.net.http.HttpClientInitializationException;
|
||||
import org.openhab.core.thing.Bridge;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
|
||||
import org.openhab.core.thing.binding.ThingHandler;
|
||||
import org.openhab.core.thing.binding.ThingHandlerFactory;
|
||||
import org.osgi.service.component.ComponentContext;
|
||||
import org.osgi.service.component.annotations.Activate;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
import org.osgi.service.component.annotations.Reference;
|
||||
|
||||
/**
|
||||
* The {@link UniFiThingHandlerFactory} is responsible for creating things and thing
|
||||
* handlers.
|
||||
*
|
||||
* @author Matthew Bowman - Initial contribution
|
||||
*/
|
||||
@Component(service = ThingHandlerFactory.class, configurationPid = "binding.unifi")
|
||||
@NonNullByDefault
|
||||
public class UniFiThingHandlerFactory extends BaseThingHandlerFactory {
|
||||
|
||||
private final HttpClient httpClient;
|
||||
|
||||
@Activate
|
||||
public UniFiThingHandlerFactory(@Reference final HttpClientFactory httpClientFactory) {
|
||||
httpClient = httpClientFactory.createHttpClient(BINDING_ID, new SslContextFactory.Client(true));
|
||||
try {
|
||||
httpClient.start();
|
||||
} catch (final Exception e) {
|
||||
throw new HttpClientInitializationException("Could not start HttpClient", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void deactivate(final ComponentContext componentContext) {
|
||||
try {
|
||||
httpClient.stop();
|
||||
} catch (final Exception e) {
|
||||
// Eat http client stop exception.
|
||||
} finally {
|
||||
super.deactivate(componentContext);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsThingType(final ThingTypeUID thingTypeUID) {
|
||||
return ALL_THING_TYPE_SUPPORTED.contains(thingTypeUID);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @Nullable ThingHandler createHandler(final Thing thing) {
|
||||
final ThingTypeUID thingTypeUID = thing.getThingTypeUID();
|
||||
if (THING_TYPE_CONTROLLER.equals(thingTypeUID)) {
|
||||
return new UniFiControllerThingHandler((Bridge) thing, httpClient);
|
||||
} else if (THING_TYPE_SITE.equals(thingTypeUID)) {
|
||||
return new UniFiSiteThingHandler(thing);
|
||||
} else if (THING_TYPE_NETWORK.equals(thingTypeUID)) {
|
||||
return new UniFiNetworkThingHandler(thing);
|
||||
} else if (THING_TYPE_WLAN.equals(thingTypeUID)) {
|
||||
return new UniFiWlanThingHandler(thing);
|
||||
} else if (THING_TYPE_WIRELESS_CLIENT.equals(thingTypeUID) || THING_TYPE_WIRED_CLIENT.equals(thingTypeUID)) {
|
||||
return new UniFiClientThingHandler(thing);
|
||||
} else if (THING_TYPE_POE_PORT.equals(thingTypeUID)) {
|
||||
return new UniFiPoePortThingHandler(thing);
|
||||
} else if (THING_TYPE_ACCESS_POINT.equals(thingTypeUID)) {
|
||||
return new UniFiAccessPointThingHandler(thing);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal;
|
||||
package org.openhab.binding.unifi.internal.access;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
@@ -24,12 +24,19 @@ import org.openhab.core.thing.ThingTypeUID;
|
||||
@NonNullByDefault
|
||||
public class UnifiAccessBindingConstants {
|
||||
|
||||
public static final String BINDING_ID = "unifiaccess";
|
||||
public static final String BINDING_ID = "unifi";
|
||||
|
||||
// Canonical unifi:* thing types used for discovery and new things
|
||||
public static final ThingTypeUID BRIDGE_THING_TYPE = new ThingTypeUID(BINDING_ID, "bridge");
|
||||
public static final ThingTypeUID DOOR_THING_TYPE = new ThingTypeUID(BINDING_ID, "door");
|
||||
public static final ThingTypeUID DEVICE_THING_TYPE = new ThingTypeUID(BINDING_ID, "device");
|
||||
|
||||
// Legacy Thing Type UIDs (pre-merge unifiaccess:* binding ID, kept so existing things keep working)
|
||||
public static final String LEGACY_BINDING_ID = "unifiaccess";
|
||||
public static final ThingTypeUID BRIDGE_THING_TYPE_LEGACY = new ThingTypeUID(LEGACY_BINDING_ID, "bridge");
|
||||
public static final ThingTypeUID DOOR_THING_TYPE_LEGACY = new ThingTypeUID(LEGACY_BINDING_ID, "door");
|
||||
public static final ThingTypeUID DEVICE_THING_TYPE_LEGACY = new ThingTypeUID(LEGACY_BINDING_ID, "device");
|
||||
|
||||
public static final String CONFIG_DEVICE_ID = "deviceId";
|
||||
|
||||
public static final String CHANNEL_LOCK = "lock";
|
||||
+28
-16
@@ -10,21 +10,22 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal;
|
||||
package org.openhab.binding.unifi.internal.access;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.unifiaccess.internal.api.UniFiAccessApiClient;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Device;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Door;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.UniFiAccessApiException;
|
||||
import org.openhab.binding.unifiaccess.internal.handler.UnifiAccessBridgeHandler;
|
||||
import org.openhab.binding.unifi.internal.access.api.UnifiAccessApiClient;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Device;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Door;
|
||||
import org.openhab.binding.unifi.internal.access.dto.UnifiAccessApiException;
|
||||
import org.openhab.binding.unifi.internal.access.handler.UnifiAccessBridgeHandler;
|
||||
import org.openhab.core.config.discovery.AbstractThingHandlerDiscoveryService;
|
||||
import org.openhab.core.config.discovery.DiscoveryResult;
|
||||
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
import org.openhab.core.thing.ThingUID;
|
||||
import org.openhab.core.thing.binding.ThingHandler;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
@@ -45,8 +46,19 @@ public class UnifiAccessDiscoveryService extends AbstractThingHandlerDiscoverySe
|
||||
|
||||
public UnifiAccessDiscoveryService() {
|
||||
super(UnifiAccessBridgeHandler.class,
|
||||
Set.of(UnifiAccessBindingConstants.DOOR_THING_TYPE, UnifiAccessBindingConstants.DEVICE_THING_TYPE), 30,
|
||||
false);
|
||||
Set.of(UnifiAccessBindingConstants.DOOR_THING_TYPE, UnifiAccessBindingConstants.DEVICE_THING_TYPE,
|
||||
UnifiAccessBindingConstants.DOOR_THING_TYPE_LEGACY,
|
||||
UnifiAccessBindingConstants.DEVICE_THING_TYPE_LEGACY),
|
||||
30, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remaps a canonical {@code unifi:*} thing type to the binding ID of this Access bridge, so a
|
||||
* discovered child stays in the same namespace as its parent (legacy {@code unifiaccess:*}
|
||||
* children under a legacy bridge, {@code unifi:*} children under a new bridge).
|
||||
*/
|
||||
private ThingTypeUID forBridge(ThingTypeUID canonical) {
|
||||
return new ThingTypeUID(thingHandler.getThing().getThingTypeUID().getBindingId(), canonical.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -60,7 +72,7 @@ public class UnifiAccessDiscoveryService extends AbstractThingHandlerDiscoverySe
|
||||
@Override
|
||||
protected void startScan() {
|
||||
removeOlderResults(getTimestampOfLastScan());
|
||||
final UniFiAccessApiClient client = thingHandler.getApiClient();
|
||||
final UnifiAccessApiClient client = thingHandler.getApiClient();
|
||||
if (client == null) {
|
||||
return;
|
||||
}
|
||||
@@ -69,18 +81,18 @@ public class UnifiAccessDiscoveryService extends AbstractThingHandlerDiscoverySe
|
||||
discoverDoors(doors);
|
||||
List<Device> devices = client.getDevices();
|
||||
discoverDevices(devices);
|
||||
} catch (UniFiAccessApiException e) {
|
||||
} catch (UnifiAccessApiException e) {
|
||||
logger.debug("Error discovering doors: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void discoverDoors(List<Door> doors) {
|
||||
ThingTypeUID doorType = forBridge(UnifiAccessBindingConstants.DOOR_THING_TYPE);
|
||||
for (Door d : doors) {
|
||||
ThingUID uid = new ThingUID(UnifiAccessBindingConstants.DOOR_THING_TYPE, thingHandler.getThing().getUID(),
|
||||
d.id);
|
||||
ThingUID uid = new ThingUID(doorType, thingHandler.getThing().getUID(), d.id);
|
||||
Map<String, Object> props = Map.of(UnifiAccessBindingConstants.CONFIG_DEVICE_ID, d.id);
|
||||
DiscoveryResult result = DiscoveryResultBuilder.create(uid).withBridge(thingHandler.getThing().getUID())
|
||||
.withThingType(UnifiAccessBindingConstants.DOOR_THING_TYPE).withProperties(props)
|
||||
.withThingType(doorType).withProperties(props)
|
||||
.withRepresentationProperty(UnifiAccessBindingConstants.CONFIG_DEVICE_ID)
|
||||
.withLabel("UniFi Door: " + (d.fullName != null ? d.fullName : d.name)).build();
|
||||
thingDiscovered(result);
|
||||
@@ -88,18 +100,18 @@ public class UnifiAccessDiscoveryService extends AbstractThingHandlerDiscoverySe
|
||||
}
|
||||
|
||||
public void discoverDevices(List<Device> devices) {
|
||||
ThingTypeUID deviceType = forBridge(UnifiAccessBindingConstants.DEVICE_THING_TYPE);
|
||||
for (Device d : devices) {
|
||||
String devId = d.id;
|
||||
if (devId == null || d.isHub()) {
|
||||
continue;
|
||||
}
|
||||
ThingUID uid = new ThingUID(UnifiAccessBindingConstants.DEVICE_THING_TYPE, thingHandler.getThing().getUID(),
|
||||
devId);
|
||||
ThingUID uid = new ThingUID(deviceType, thingHandler.getThing().getUID(), devId);
|
||||
Map<String, Object> props = Map.of(UnifiAccessBindingConstants.CONFIG_DEVICE_ID, devId);
|
||||
String label = "UniFi Device: " + (d.type != null ? d.type : "") + " - "
|
||||
+ (d.alias != null ? d.alias : (d.name != null ? d.name : devId));
|
||||
DiscoveryResult result = DiscoveryResultBuilder.create(uid).withBridge(thingHandler.getThing().getUID())
|
||||
.withThingType(UnifiAccessBindingConstants.DEVICE_THING_TYPE).withProperties(props)
|
||||
.withThingType(deviceType).withProperties(props)
|
||||
.withRepresentationProperty(UnifiAccessBindingConstants.CONFIG_DEVICE_ID).withLabel(label).build();
|
||||
thingDiscovered(result);
|
||||
}
|
||||
+17
-30
@@ -10,27 +10,24 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal;
|
||||
package org.openhab.binding.unifi.internal.access;
|
||||
|
||||
import static org.openhab.binding.unifiaccess.internal.UnifiAccessBindingConstants.*;
|
||||
import static org.openhab.binding.unifi.internal.access.UnifiAccessBindingConstants.*;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.unifiaccess.internal.handler.UnifiAccessBridgeHandler;
|
||||
import org.openhab.binding.unifiaccess.internal.handler.UnifiAccessDeviceHandler;
|
||||
import org.openhab.binding.unifiaccess.internal.handler.UnifiAccessDoorHandler;
|
||||
import org.openhab.core.io.net.http.HttpClientFactory;
|
||||
import org.openhab.binding.unifi.internal.access.handler.UnifiAccessBridgeHandler;
|
||||
import org.openhab.binding.unifi.internal.access.handler.UnifiAccessDeviceHandler;
|
||||
import org.openhab.binding.unifi.internal.access.handler.UnifiAccessDoorHandler;
|
||||
import org.openhab.core.thing.Bridge;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
|
||||
import org.openhab.core.thing.binding.ThingHandler;
|
||||
import org.openhab.core.thing.binding.ThingHandlerFactory;
|
||||
import org.osgi.service.component.annotations.Activate;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
import org.osgi.service.component.annotations.Reference;
|
||||
|
||||
/**
|
||||
* The {@link UnifiAccessHandlerFactory} is responsible for creating things and thing
|
||||
@@ -43,14 +40,7 @@ import org.osgi.service.component.annotations.Reference;
|
||||
public class UnifiAccessHandlerFactory extends BaseThingHandlerFactory {
|
||||
|
||||
private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(BRIDGE_THING_TYPE, DOOR_THING_TYPE,
|
||||
DEVICE_THING_TYPE);
|
||||
|
||||
private final HttpClientFactory httpClientFactory;
|
||||
|
||||
@Activate
|
||||
public UnifiAccessHandlerFactory(@Reference HttpClientFactory httpClientFactory) {
|
||||
this.httpClientFactory = httpClientFactory;
|
||||
}
|
||||
DEVICE_THING_TYPE, BRIDGE_THING_TYPE_LEGACY, DOOR_THING_TYPE_LEGACY, DEVICE_THING_TYPE_LEGACY);
|
||||
|
||||
@Override
|
||||
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
|
||||
@@ -59,20 +49,17 @@ public class UnifiAccessHandlerFactory extends BaseThingHandlerFactory {
|
||||
|
||||
@Override
|
||||
protected @Nullable ThingHandler createHandler(Thing thing) {
|
||||
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
|
||||
|
||||
if (BRIDGE_THING_TYPE.equals(thingTypeUID)) {
|
||||
return new UnifiAccessBridgeHandler((Bridge) thing, httpClientFactory);
|
||||
// Dispatch on the thing-type id so one handler serves both the canonical unifi:*
|
||||
// and the legacy unifiaccess:* binding IDs.
|
||||
switch (thing.getThingTypeUID().getId()) {
|
||||
case "bridge":
|
||||
return new UnifiAccessBridgeHandler((Bridge) thing);
|
||||
case "door":
|
||||
return new UnifiAccessDoorHandler(thing);
|
||||
case "device":
|
||||
return new UnifiAccessDeviceHandler(thing);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DOOR_THING_TYPE.equals(thingTypeUID)) {
|
||||
return new UnifiAccessDoorHandler(thing);
|
||||
}
|
||||
|
||||
if (DEVICE_THING_TYPE.equals(thingTypeUID)) {
|
||||
return new UnifiAccessDeviceHandler(thing);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+382
-225
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.api;
|
||||
package org.openhab.binding.unifi.internal.access.api;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
@@ -27,8 +27,10 @@ import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
@@ -43,15 +45,17 @@ import org.eclipse.jetty.websocket.api.Session;
|
||||
import org.eclipse.jetty.websocket.api.WebSocketAdapter;
|
||||
import org.eclipse.jetty.websocket.client.ClientUpgradeRequest;
|
||||
import org.eclipse.jetty.websocket.client.WebSocketClient;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Device;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.DeviceAccessMethodSettings;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Door;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.DoorEmergencySettings;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.DoorLockRule;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.DoorState;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Image;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Notification;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.UniFiAccessApiException;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Device;
|
||||
import org.openhab.binding.unifi.internal.access.dto.DeviceAccessMethodSettings;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Door;
|
||||
import org.openhab.binding.unifi.internal.access.dto.DoorEmergencySettings;
|
||||
import org.openhab.binding.unifi.internal.access.dto.DoorLockRule;
|
||||
import org.openhab.binding.unifi.internal.access.dto.DoorState;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Image;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Notification;
|
||||
import org.openhab.binding.unifi.internal.access.dto.UnifiAccessApiException;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiException.AuthState;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiSession;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -62,29 +66,29 @@ import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
/**
|
||||
* UniFi Access v2 API client using session-based authentication.
|
||||
* <p>
|
||||
* Authenticates against the internal v2 proxy API on port 443 using
|
||||
* username/password credentials. Maintains a session cookie and CSRF token
|
||||
* for all subsequent requests. Automatically re-authenticates on 401 responses.
|
||||
* UniFi Access v2 API client that delegates authentication to a shared
|
||||
* {@link org.openhab.binding.unifi.internal.api.UniFiSession} obtained from
|
||||
* {@link org.openhab.binding.unifi.internal.api.UniFiSessions}. The client previously maintained its own
|
||||
* {@code /api/auth/login} + CSRF probe + 401 re-auth logic; those concerns are now owned by the shared parent
|
||||
* binding ({@code org.openhab.binding.unifi}) so the Network, Protect, and Access family bindings all reuse the
|
||||
* same auth plumbing, rate limiter, and session cache directory.
|
||||
*
|
||||
* @author Dan Cunningham - Initial contribution
|
||||
* @author Dan Cunningham - Initial contribution (original implementation)
|
||||
* @author Dan Cunningham - Refactored onto shared UniFiSession
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public final class UniFiAccessApiClient implements Closeable {
|
||||
public final class UnifiAccessApiClient implements Closeable {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(UniFiAccessApiClient.class);
|
||||
private final Logger logger = LoggerFactory.getLogger(UnifiAccessApiClient.class);
|
||||
|
||||
private static final long HTTP_TIMEOUT_MS = 30_000;
|
||||
private static final String V2_BASE = "/proxy/access/api/v2";
|
||||
private static final String CSRF_HEADER = "x-csrf-token";
|
||||
private static final String UPDATED_CSRF_HEADER = "X-Updated-CSRF-Token";
|
||||
/** WebSocket close code reported when the heartbeat monitor escalates a silent-peer close. */
|
||||
private static final int WS_CLOSE_HEARTBEAT_TIMEOUT = 4000;
|
||||
|
||||
private final HttpClient httpClient;
|
||||
private final String host;
|
||||
private final Gson gson;
|
||||
private final String username;
|
||||
private final String password;
|
||||
private final ScheduledExecutorService executorService;
|
||||
|
||||
private final WebSocketClient wsClient;
|
||||
@@ -92,22 +96,24 @@ public final class UniFiAccessApiClient implements Closeable {
|
||||
private long lastHeartbeatEpochMs;
|
||||
private @Nullable ScheduledFuture<?> wsMonitorFuture;
|
||||
private boolean closed = false;
|
||||
private volatile @Nullable BiConsumer<Integer, String> onClosedCallback;
|
||||
private final AtomicBoolean closedNotified = new AtomicBoolean(false);
|
||||
|
||||
/** Session cookie from login (e.g. "TOKEN=..."). */
|
||||
private @Nullable String sessionCookie;
|
||||
/** CSRF token for authenticated requests. */
|
||||
private @Nullable String csrfToken;
|
||||
/** Flag to prevent recursive re-auth loops. */
|
||||
private boolean reauthenticating = false;
|
||||
/** Shared authenticated session owned by the parent {@code unifi:controller} bridge. */
|
||||
private final UniFiSession unifiSession;
|
||||
private volatile @Nullable String apiToken;
|
||||
private final Supplier<@Nullable String> tokenRecovery;
|
||||
private final Object tokenRecoveryLock = new Object();
|
||||
|
||||
public UniFiAccessApiClient(HttpClient httpClient, String host, Gson gson, String username, String password,
|
||||
ScheduledExecutorService executor) {
|
||||
public UnifiAccessApiClient(HttpClient httpClient, String host, Gson gson, UniFiSession session,
|
||||
ScheduledExecutorService executor, @Nullable String apiToken, Supplier<@Nullable String> tokenRecovery) {
|
||||
this.httpClient = httpClient;
|
||||
this.host = host;
|
||||
this.gson = gson;
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.unifiSession = session;
|
||||
this.executorService = executor;
|
||||
this.apiToken = (apiToken != null && !apiToken.isBlank()) ? apiToken : null;
|
||||
this.tokenRecovery = tokenRecovery;
|
||||
this.wsClient = new WebSocketClient(httpClient);
|
||||
this.wsClient.unmanage(this.httpClient);
|
||||
try {
|
||||
@@ -120,6 +126,9 @@ public final class UniFiAccessApiClient implements Closeable {
|
||||
@Override
|
||||
public synchronized void close() {
|
||||
closed = true;
|
||||
// Suppress the close callback so a deliberate shutdown doesn't trigger a bridge reconnect.
|
||||
closedNotified.set(true);
|
||||
onClosedCallback = null;
|
||||
try {
|
||||
Session s = wsSession;
|
||||
if (s != null) {
|
||||
@@ -140,88 +149,6 @@ public final class UniFiAccessApiClient implements Closeable {
|
||||
stopWsMonitor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs session-based authentication against the v2 API.
|
||||
* <ol>
|
||||
* <li>GET / to obtain the initial CSRF token from the X-CSRF-Token response header</li>
|
||||
* <li>POST /api/auth/login with credentials to establish a session</li>
|
||||
* <li>Extracts the session cookie and updated CSRF token from the login response</li>
|
||||
* </ol>
|
||||
*/
|
||||
public void login() throws UniFiAccessApiException {
|
||||
try {
|
||||
// Step 1: Try to GET the landing page for an initial CSRF token (optional)
|
||||
String initialCsrf = "";
|
||||
try {
|
||||
ContentResponse csrfResp = httpClient.newRequest(baseUri("/")).method(HttpMethod.GET)
|
||||
.timeout(HTTP_TIMEOUT_MS, TimeUnit.MILLISECONDS).send();
|
||||
String csrf = csrfResp.getHeaders().get("X-CSRF-Token");
|
||||
if (csrf != null && !csrf.isBlank()) {
|
||||
initialCsrf = csrf;
|
||||
logger.debug("Obtained initial CSRF token");
|
||||
} else {
|
||||
logger.debug("No initial CSRF token from GET / - will obtain from login response");
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
logger.debug("Interrupted while fetching initial CSRF token: {}", e.getMessage());
|
||||
} catch (TimeoutException | ExecutionException e) {
|
||||
logger.debug("Failed to fetch initial CSRF token: {}", e.getMessage());
|
||||
}
|
||||
|
||||
// Step 2: POST login with credentials
|
||||
JsonObject loginBody = new JsonObject();
|
||||
loginBody.addProperty("username", username);
|
||||
loginBody.addProperty("password", password);
|
||||
loginBody.addProperty("rememberMe", true);
|
||||
loginBody.addProperty("token", "");
|
||||
|
||||
var loginReq = httpClient.newRequest(baseUri("/api/auth/login")).method(HttpMethod.POST)
|
||||
.header(HttpHeader.CONTENT_TYPE, "application/json")
|
||||
.content(new StringContentProvider(loginBody.toString(), StandardCharsets.UTF_8))
|
||||
.timeout(HTTP_TIMEOUT_MS, TimeUnit.MILLISECONDS);
|
||||
if (!initialCsrf.isEmpty()) {
|
||||
loginReq.header(CSRF_HEADER, initialCsrf);
|
||||
}
|
||||
ContentResponse loginResp = loginReq.send();
|
||||
|
||||
int status = loginResp.getStatus();
|
||||
if (status == HttpStatus.UNAUTHORIZED_401 || status == HttpStatus.FORBIDDEN_403) {
|
||||
throw new UniFiAccessApiException("Authentication failed: " + status, true);
|
||||
}
|
||||
if (!HttpStatus.isSuccess(status)) {
|
||||
throw new UniFiAccessApiException("Login failed with status " + status);
|
||||
}
|
||||
|
||||
// Step 3: Extract session cookie
|
||||
String setCookie = loginResp.getHeaders().get("Set-Cookie");
|
||||
if (setCookie != null && !setCookie.isBlank()) {
|
||||
int semicolon = setCookie.indexOf(';');
|
||||
this.sessionCookie = semicolon > 0 ? setCookie.substring(0, semicolon) : setCookie;
|
||||
} else {
|
||||
throw new UniFiAccessApiException("No Set-Cookie header in login response");
|
||||
}
|
||||
|
||||
// Step 4: Extract updated CSRF token (prefer X-Updated-CSRF-Token, fall back to X-CSRF-Token)
|
||||
String updatedCsrf = loginResp.getHeaders().get(UPDATED_CSRF_HEADER);
|
||||
if (updatedCsrf == null || updatedCsrf.isBlank()) {
|
||||
updatedCsrf = loginResp.getHeaders().get("X-CSRF-Token");
|
||||
}
|
||||
if (updatedCsrf != null && !updatedCsrf.isBlank()) {
|
||||
this.csrfToken = updatedCsrf;
|
||||
} else {
|
||||
// Fall back to initial CSRF if no updated token provided
|
||||
this.csrfToken = initialCsrf;
|
||||
}
|
||||
|
||||
logger.debug("Login successful, session established");
|
||||
} catch (UniFiAccessApiException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new UniFiAccessApiException("Login failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Bootstrap ----
|
||||
|
||||
private volatile @Nullable JsonObject cachedBootstrap;
|
||||
@@ -234,7 +161,7 @@ public final class UniFiAccessApiClient implements Closeable {
|
||||
*
|
||||
* @return the raw bootstrap JSON object (first element of the data array)
|
||||
*/
|
||||
public synchronized JsonObject getBootstrap() throws UniFiAccessApiException {
|
||||
public synchronized JsonObject getBootstrap() throws UnifiAccessApiException {
|
||||
JsonObject cached = this.cachedBootstrap;
|
||||
if (cached != null && (System.currentTimeMillis() - bootstrapCacheTimeMs) < BOOTSTRAP_CACHE_TTL_MS) {
|
||||
return cached;
|
||||
@@ -247,7 +174,7 @@ public final class UniFiAccessApiClient implements Closeable {
|
||||
|
||||
JsonArray dataArray = root.getAsJsonArray("data");
|
||||
if (dataArray == null || dataArray.isEmpty()) {
|
||||
throw new UniFiAccessApiException("Empty bootstrap data array");
|
||||
throw new UnifiAccessApiException("Empty bootstrap data array");
|
||||
}
|
||||
JsonObject result = dataArray.get(0).getAsJsonObject();
|
||||
this.cachedBootstrap = result;
|
||||
@@ -261,7 +188,7 @@ public final class UniFiAccessApiClient implements Closeable {
|
||||
* Extracts doors from the bootstrap topology.
|
||||
* Flattens: bootstrap.floors[].doors[] into a single list.
|
||||
*/
|
||||
public List<Door> getDoors() throws UniFiAccessApiException {
|
||||
public List<Door> getDoors() throws UnifiAccessApiException {
|
||||
JsonObject bootstrap = getBootstrap();
|
||||
List<Door> doors = new ArrayList<>();
|
||||
|
||||
@@ -280,9 +207,100 @@ public final class UniFiAccessApiClient implements Closeable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve lock/DPS state for doors whose hub is at the building level (not in the door's own
|
||||
// device_groups). Building-level hubs map ports to doors via their "extensions" array.
|
||||
JsonArray buildingDeviceGroups = bootstrap.getAsJsonArray("device_groups");
|
||||
if (buildingDeviceGroups != null) {
|
||||
resolveBuildingLevelHubState(buildingDeviceGroups, doors);
|
||||
}
|
||||
|
||||
return doors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans building-level device_groups for hub devices that have extensions mapping ports to specific doors.
|
||||
* For doors that didn't already get lock/position state from their own device_groups (parsed in parseDoor),
|
||||
* this fills in the state from the building-level hub's configs using the relay_key and dps_key defined in
|
||||
* the extension's target_config.
|
||||
*/
|
||||
private void resolveBuildingLevelHubState(JsonArray deviceGroups, List<Door> doors) {
|
||||
Map<String, Door> doorsById = new HashMap<>(doors.size());
|
||||
for (Door d : doors) {
|
||||
if (d.id != null) {
|
||||
doorsById.put(d.id, d);
|
||||
}
|
||||
}
|
||||
for (JsonElement dgEl : deviceGroups) {
|
||||
JsonObject hubObj = findHubDevice(dgEl);
|
||||
if (hubObj == null) {
|
||||
continue;
|
||||
}
|
||||
Map<String, String> hubConfigs = parseConfigs(hubObj.getAsJsonArray("configs"));
|
||||
JsonArray extensions = hubObj.getAsJsonArray("extensions");
|
||||
if (extensions == null) {
|
||||
continue;
|
||||
}
|
||||
for (JsonElement extEl : extensions) {
|
||||
if (!extEl.isJsonObject()) {
|
||||
continue;
|
||||
}
|
||||
JsonObject ext = extEl.getAsJsonObject();
|
||||
String targetType = getStringOrNull(ext, "target_type");
|
||||
String targetDoorId = getStringOrNull(ext, "target_value");
|
||||
if (!"door".equals(targetType) || targetDoorId == null) {
|
||||
continue;
|
||||
}
|
||||
Door door = doorsById.get(targetDoorId);
|
||||
if (door == null || (door.doorLockRelayStatus != null && door.doorPositionStatus != null)) {
|
||||
continue;
|
||||
}
|
||||
// Read relay_key, dps_key, and inversion settings from the extension's target_config
|
||||
String relayKey = null;
|
||||
String dpsKey = null;
|
||||
boolean dpsReverse = false;
|
||||
boolean relayNc = false;
|
||||
JsonArray targetConfig = ext.getAsJsonArray("target_config");
|
||||
if (targetConfig != null) {
|
||||
for (JsonElement tcEl : targetConfig) {
|
||||
if (!tcEl.isJsonObject()) {
|
||||
continue;
|
||||
}
|
||||
JsonObject tc = tcEl.getAsJsonObject();
|
||||
String configKey = getStringOrNull(tc, "config_key");
|
||||
if ("relay_key".equals(configKey)) {
|
||||
relayKey = getStringOrNull(tc, "config_value");
|
||||
} else if ("dps_key".equals(configKey)) {
|
||||
dpsKey = getStringOrNull(tc, "config_value");
|
||||
} else if ("dps_reverse".equals(configKey) || "gate_dps_reverse".equals(configKey)) {
|
||||
dpsReverse = getBooleanOrFalse(tc, "config_value");
|
||||
} else if ("is_relay_nc".equals(configKey)) {
|
||||
relayNc = getBooleanOrFalse(tc, "config_value");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Apply lock state from the hub's configs
|
||||
if (relayKey != null && door.doorLockRelayStatus == null) {
|
||||
String relayValue = hubConfigs.get(relayKey);
|
||||
if (relayValue != null) {
|
||||
boolean relayOn = "on".equalsIgnoreCase(relayValue);
|
||||
boolean locked = relayNc ? relayOn : !relayOn;
|
||||
door.doorLockRelayStatus = locked ? DoorState.LockState.LOCKED : DoorState.LockState.UNLOCKED;
|
||||
}
|
||||
}
|
||||
// Apply DPS (door position) state from the hub's configs
|
||||
if (dpsKey != null && door.doorPositionStatus == null) {
|
||||
String dpsValue = hubConfigs.get(dpsKey);
|
||||
if (dpsValue != null) {
|
||||
boolean dpsOn = "on".equalsIgnoreCase(dpsValue);
|
||||
boolean closed = dpsReverse ? !dpsOn : dpsOn;
|
||||
door.doorPositionStatus = closed ? DoorState.DoorPosition.CLOSE : DoorState.DoorPosition.OPEN;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a door JSON object from the bootstrap topology into a Door DTO.
|
||||
*/
|
||||
@@ -362,17 +380,52 @@ public final class UniFiAccessApiClient implements Closeable {
|
||||
}
|
||||
}
|
||||
|
||||
// Some hubs (e.g. UA Hub Gate) do not sit inside the door's device_groups; only the reader/camera
|
||||
// does, and the actual hub lives in a higher-level (building) group. The reader still references the
|
||||
// hub via connected_uah_id, which is the device id the lock_rule endpoint is keyed by. Fall back to
|
||||
// that when no in-group is_hub device was found, so lock-rule commands target the correct hub.
|
||||
if (door.hubDeviceId == null && deviceGroups != null) {
|
||||
for (JsonElement dgEl : deviceGroups) {
|
||||
String uah = findConnectedUahId(dgEl);
|
||||
if (uah != null) {
|
||||
door.hubDeviceId = uah;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Floor ID from up_id
|
||||
door.floorId = getStringOrNull(doorObj, "up_id");
|
||||
|
||||
return door;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first {@code connected_uah_id} found among the devices in a device-group element (which may
|
||||
* be a single device object or a nested array of devices), or {@code null} if none carry one.
|
||||
*/
|
||||
private @Nullable String findConnectedUahId(JsonElement dgEl) {
|
||||
if (dgEl.isJsonObject()) {
|
||||
return getStringOrNull(dgEl.getAsJsonObject(), "connected_uah_id");
|
||||
}
|
||||
if (dgEl.isJsonArray()) {
|
||||
for (JsonElement inner : dgEl.getAsJsonArray()) {
|
||||
if (inner.isJsonObject()) {
|
||||
String uah = getStringOrNull(inner.getAsJsonObject(), "connected_uah_id");
|
||||
if (uah != null) {
|
||||
return uah;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all devices from the bootstrap topology.
|
||||
* Flattens device_groups from floors and doors into a single list.
|
||||
*/
|
||||
public List<Device> getDevices() throws UniFiAccessApiException {
|
||||
public List<Device> getDevices() throws UnifiAccessApiException {
|
||||
JsonObject bootstrap = getBootstrap();
|
||||
List<Device> devices = new ArrayList<>();
|
||||
|
||||
@@ -540,7 +593,7 @@ public final class UniFiAccessApiClient implements Closeable {
|
||||
/**
|
||||
* Unlocks a door by its location ID.
|
||||
*/
|
||||
public boolean unlockDoor(String locationId) throws UniFiAccessApiException {
|
||||
public boolean unlockDoor(String locationId) throws UnifiAccessApiException {
|
||||
ContentResponse resp = execPut(V2_BASE + "/location/" + locationId + "/unlock", "{}");
|
||||
ensure2xx(resp, "unlockDoor");
|
||||
return checkV2SuccessBool(resp, "unlockDoor");
|
||||
@@ -551,14 +604,21 @@ public final class UniFiAccessApiClient implements Closeable {
|
||||
* The v2 API does not support actor parameters on unlock, so they are ignored.
|
||||
*/
|
||||
public boolean unlockDoor(String locationId, @Nullable String actorId, @Nullable String actorName,
|
||||
@Nullable Map<String, Object> extra) throws UniFiAccessApiException {
|
||||
@Nullable Map<String, Object> extra) throws UnifiAccessApiException {
|
||||
return unlockDoor(locationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a lock rule on a device.
|
||||
* Sets a lock rule on a door. The lock_rule endpoint is keyed by the hub device id in the path and the
|
||||
* door's location id in the {@code location_id} query parameter, matching the UniFi Access web client.
|
||||
* Passing the door/location id in the path instead is rejected by recent firmware with
|
||||
* {@code GetAdoptedDeviceByUniqueId failed; NotExist}.
|
||||
*
|
||||
* @param hubDeviceId the hub's unique_id (path key)
|
||||
* @param locationId the door/location id
|
||||
*/
|
||||
public boolean setDoorLockRule(String deviceId, DoorLockRule rule) throws UniFiAccessApiException {
|
||||
public boolean setDoorLockRule(String hubDeviceId, String locationId, DoorLockRule rule)
|
||||
throws UnifiAccessApiException {
|
||||
JsonObject body = new JsonObject();
|
||||
if (rule.type != null) {
|
||||
body.addProperty("type", gson.toJson(rule.type).replace("\"", ""));
|
||||
@@ -566,56 +626,116 @@ public final class UniFiAccessApiClient implements Closeable {
|
||||
if (rule.interval != null && rule.interval > 0) {
|
||||
body.addProperty("interval", rule.interval);
|
||||
}
|
||||
ContentResponse resp = execPut(V2_BASE + "/device/" + deviceId + "/lock_rule?get_result=true", body.toString());
|
||||
ContentResponse resp = execPut(
|
||||
V2_BASE + "/device/" + hubDeviceId + "/lock_rule?get_result=true&location_id=" + locationId,
|
||||
body.toString());
|
||||
ensure2xx(resp, "setDoorLockRule");
|
||||
return checkV2SuccessBool(resp, "setDoorLockRule");
|
||||
}
|
||||
|
||||
public boolean keepDoorUnlocked(String deviceId) throws UniFiAccessApiException {
|
||||
return setDoorLockRule(deviceId, DoorLockRule.keepUnlock());
|
||||
public boolean keepDoorUnlocked(String hubDeviceId, String locationId) throws UnifiAccessApiException {
|
||||
return setDoorLockRule(hubDeviceId, locationId, DoorLockRule.keepUnlock());
|
||||
}
|
||||
|
||||
public boolean keepDoorLocked(String deviceId) throws UniFiAccessApiException {
|
||||
return setDoorLockRule(deviceId, DoorLockRule.keepLock());
|
||||
public boolean keepDoorLocked(String hubDeviceId, String locationId) throws UnifiAccessApiException {
|
||||
return setDoorLockRule(hubDeviceId, locationId, DoorLockRule.keepLock());
|
||||
}
|
||||
|
||||
public boolean unlockForMinutes(String deviceId, int minutes) throws UniFiAccessApiException {
|
||||
public boolean unlockForMinutes(String hubDeviceId, String locationId, int minutes) throws UnifiAccessApiException {
|
||||
if (minutes <= 0) {
|
||||
throw new IllegalArgumentException("minutes must be > 0");
|
||||
}
|
||||
return setDoorLockRule(deviceId, DoorLockRule.customMinutes(minutes));
|
||||
return setDoorLockRule(hubDeviceId, locationId, DoorLockRule.customMinutes(minutes));
|
||||
}
|
||||
|
||||
public boolean resetDoorLockRule(String deviceId) throws UniFiAccessApiException {
|
||||
return setDoorLockRule(deviceId, DoorLockRule.reset());
|
||||
public boolean resetDoorLockRule(String hubDeviceId, String locationId) throws UnifiAccessApiException {
|
||||
return setDoorLockRule(hubDeviceId, locationId, DoorLockRule.reset());
|
||||
}
|
||||
|
||||
/** End an active keep-unlock/custom early (lock immediately). */
|
||||
public boolean lockEarly(String deviceId) throws UniFiAccessApiException {
|
||||
return setDoorLockRule(deviceId, DoorLockRule.lockEarly());
|
||||
public boolean lockEarly(String hubDeviceId, String locationId) throws UnifiAccessApiException {
|
||||
return setDoorLockRule(hubDeviceId, locationId, DoorLockRule.lockEarly());
|
||||
}
|
||||
|
||||
/** Terminate both unlock schedule and temporary unlock, locking immediately. */
|
||||
public boolean lockNow(String deviceId) throws UniFiAccessApiException {
|
||||
return setDoorLockRule(deviceId, DoorLockRule.lockNow());
|
||||
public boolean lockNow(String hubDeviceId, String locationId) throws UnifiAccessApiException {
|
||||
return setDoorLockRule(hubDeviceId, locationId, DoorLockRule.lockNow());
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current lock rule for a door. The v2 API does not have a dedicated
|
||||
* endpoint for this, so we return a default reset rule.
|
||||
* Gets the current lock rule for a door. Recent device firmware exposes the rule via a v2
|
||||
* {@code lock_rule} read-back keyed by the hub device id and door location id; the response carries
|
||||
* the active {@code temp_lock_type} and the {@code unlock_period} end time (epoch seconds). Older
|
||||
* firmware without this endpoint falls back to a default reset rule so callers still get a value.
|
||||
*
|
||||
* @param hubDeviceId the hub's unique_id (path key for lock_rule), as used by {@link #setDoorLockRule}
|
||||
* @param locationId the door/location id
|
||||
*/
|
||||
public DoorLockRule getDoorLockRule(String doorId) throws UniFiAccessApiException {
|
||||
// The v2 topology does not expose a separate lock_rule GET endpoint.
|
||||
// Return a default "reset" rule; actual state is derived from bootstrap/notifications.
|
||||
return DoorLockRule.reset();
|
||||
public DoorLockRule getDoorLockRule(String hubDeviceId, String locationId) throws UnifiAccessApiException {
|
||||
ContentResponse resp = execGet(
|
||||
V2_BASE + "/device/" + hubDeviceId + "/lock_rule?get_result=true&location_id=" + locationId);
|
||||
if (resp.getStatus() == 404) {
|
||||
// Firmware without the lock_rule read-back; state is derived from bootstrap/notifications.
|
||||
return DoorLockRule.reset();
|
||||
}
|
||||
ensure2xx(resp, "getDoorLockRule");
|
||||
JsonObject root = JsonParser.parseString(resp.getContentAsString()).getAsJsonObject();
|
||||
checkV2Success(root, "getDoorLockRule");
|
||||
JsonElement dataEl = root.get("data");
|
||||
if (dataEl == null || dataEl.isJsonNull()) {
|
||||
return DoorLockRule.reset();
|
||||
}
|
||||
DoorLockRule rule = gson.fromJson(dataEl, DoorLockRule.class);
|
||||
if (rule == null || rule.type == null) {
|
||||
return DoorLockRule.reset();
|
||||
}
|
||||
return rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a doorbell ring on a device.
|
||||
* Not yet supported on the v2 internal API.
|
||||
* Trigger a doorbell ring on a device via the Developer API.
|
||||
* Requires a valid API token (auto-provisioned or manually configured).
|
||||
* On auth failure, attempts token recovery once before giving up.
|
||||
*/
|
||||
public void triggerDoorbell(String deviceId) throws UniFiAccessApiException {
|
||||
throw new UniFiAccessApiException("triggerDoorbell is not supported on the v2 internal API");
|
||||
public void triggerDoorbell(String deviceId) throws UnifiAccessApiException {
|
||||
execDeveloperWithRecovery("/api/v1/developer/devices/" + deviceId + "/doorbell", null, "triggerDoorbell");
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a Developer API POST with automatic token recovery on auth failure. Recovery is serialized
|
||||
* via {@link #tokenRecoveryLock} so that concurrent callers share a single re-provisioning attempt
|
||||
* (otherwise each call's freshly-issued token would invalidate the previous caller's).
|
||||
*/
|
||||
private void execDeveloperWithRecovery(String path, @Nullable String body, String action)
|
||||
throws UnifiAccessApiException {
|
||||
String token = this.apiToken;
|
||||
if (token == null) {
|
||||
throw new UnifiAccessApiException("Developer API token not available");
|
||||
}
|
||||
try {
|
||||
execDeveloperPost(path, body, token, action);
|
||||
} catch (UnifiAccessApiException e) {
|
||||
if (e.getAuthState() != AuthState.REJECTED) {
|
||||
throw e;
|
||||
}
|
||||
String retryToken;
|
||||
synchronized (tokenRecoveryLock) {
|
||||
// Another caller may have already re-provisioned while we waited.
|
||||
String current = this.apiToken;
|
||||
if (current != null && !current.equals(token)) {
|
||||
retryToken = current;
|
||||
} else {
|
||||
String newToken = tokenRecovery.get();
|
||||
if (newToken == null) {
|
||||
throw e;
|
||||
}
|
||||
this.apiToken = newToken;
|
||||
retryToken = newToken;
|
||||
}
|
||||
}
|
||||
logger.debug("Token rejected, retrying {} with re-provisioned token", action);
|
||||
execDeveloperPost(path, body, retryToken, action);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Device Settings ----
|
||||
@@ -625,10 +745,10 @@ public final class UniFiAccessApiClient implements Closeable {
|
||||
* The v2 API does not have a separate /settings endpoint; settings are in the
|
||||
* device's configs array with tag "open_door_mode" and "device_setting".
|
||||
*/
|
||||
public DeviceAccessMethodSettings getDeviceAccessMethodSettings(String deviceId) throws UniFiAccessApiException {
|
||||
public DeviceAccessMethodSettings getDeviceAccessMethodSettings(String deviceId) throws UnifiAccessApiException {
|
||||
// This is called with a deviceId but we need the configs.
|
||||
// If this is called without configs, search the last bootstrap.
|
||||
throw new UniFiAccessApiException("Use buildSettingsFromConfigs(configMap) instead");
|
||||
throw new UnifiAccessApiException("Use buildSettingsFromConfigs(configMap) instead");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -679,7 +799,7 @@ public final class UniFiAccessApiClient implements Closeable {
|
||||
/**
|
||||
* Updates a single device config key/value via the v2 configs API.
|
||||
*/
|
||||
public void updateDeviceConfig(String deviceId, String key, String value) throws UniFiAccessApiException {
|
||||
public void updateDeviceConfig(String deviceId, String key, String value) throws UnifiAccessApiException {
|
||||
JsonObject entry = new JsonObject();
|
||||
entry.addProperty("key", key);
|
||||
entry.addProperty("value", value);
|
||||
@@ -696,7 +816,7 @@ public final class UniFiAccessApiClient implements Closeable {
|
||||
* Updates device access method settings for a reader (legacy - uses configs API).
|
||||
*/
|
||||
public DeviceAccessMethodSettings updateDeviceAccessMethodSettings(String deviceId,
|
||||
DeviceAccessMethodSettings settings) throws UniFiAccessApiException {
|
||||
DeviceAccessMethodSettings settings) throws UnifiAccessApiException {
|
||||
ContentResponse resp = execPut(V2_BASE + "/device/" + deviceId + "/configs", gson.toJson(settings));
|
||||
ensure2xx(resp, "updateDeviceAccessMethodSettings");
|
||||
String json = resp.getContentAsString();
|
||||
@@ -705,7 +825,7 @@ public final class UniFiAccessApiClient implements Closeable {
|
||||
|
||||
JsonElement dataEl = root.get("data");
|
||||
if (dataEl == null || dataEl.isJsonNull()) {
|
||||
throw new UniFiAccessApiException("Missing data in updateDeviceAccessMethodSettings response");
|
||||
throw new UnifiAccessApiException("Missing data in updateDeviceAccessMethodSettings response");
|
||||
}
|
||||
DeviceAccessMethodSettings result = gson.fromJson(dataEl, DeviceAccessMethodSettings.class);
|
||||
return Objects.requireNonNull(result);
|
||||
@@ -713,7 +833,7 @@ public final class UniFiAccessApiClient implements Closeable {
|
||||
|
||||
// ---- Emergency Settings ----
|
||||
|
||||
public DoorEmergencySettings getEmergencySettings() throws UniFiAccessApiException {
|
||||
public DoorEmergencySettings getEmergencySettings() throws UnifiAccessApiException {
|
||||
ContentResponse resp = execGet(V2_BASE + "/doors/settings/emergency");
|
||||
ensure2xx(resp, "getEmergencySettings");
|
||||
String json = resp.getContentAsString();
|
||||
@@ -722,22 +842,23 @@ public final class UniFiAccessApiClient implements Closeable {
|
||||
|
||||
JsonElement dataEl = root.get("data");
|
||||
if (dataEl == null || dataEl.isJsonNull()) {
|
||||
throw new UniFiAccessApiException("Missing data in getEmergencySettings response");
|
||||
throw new UnifiAccessApiException("Missing data in getEmergencySettings response");
|
||||
}
|
||||
DoorEmergencySettings result = gson.fromJson(dataEl, DoorEmergencySettings.class);
|
||||
return Objects.requireNonNull(result);
|
||||
}
|
||||
|
||||
public void setEmergencySettings(DoorEmergencySettings settings) throws UniFiAccessApiException {
|
||||
public void setEmergencySettings(DoorEmergencySettings settings) throws UnifiAccessApiException {
|
||||
ContentResponse resp = execPut(V2_BASE + "/doors/settings/emergency", gson.toJson(settings));
|
||||
ensure2xx(resp, "setEmergencySettings");
|
||||
}
|
||||
|
||||
// ---- Thumbnails ----
|
||||
|
||||
public Image getDoorThumbnail(String path) throws UniFiAccessApiException {
|
||||
// Thumbnails are served from the same host via the proxy path
|
||||
String thumbnailPath = path.startsWith("/") ? path : "/" + path;
|
||||
public Image getDoorThumbnail(String path) throws UnifiAccessApiException {
|
||||
// Thumbnails are served by the Access service behind the UniFi OS proxy
|
||||
String normalized = path.startsWith("/") ? path : "/" + path;
|
||||
String thumbnailPath = "/proxy/access" + normalized;
|
||||
ContentResponse resp = execGet(thumbnailPath);
|
||||
ensure2xx(resp, "getDoorThumbnail");
|
||||
Image image = new Image();
|
||||
@@ -756,20 +877,21 @@ public final class UniFiAccessApiClient implements Closeable {
|
||||
* Auth: Cookie header with session cookie
|
||||
*/
|
||||
public synchronized void openNotifications(Runnable onOpen, Consumer<Notification> onMessage,
|
||||
Consumer<Throwable> onError, BiConsumer<Integer, String> onClosed) throws UniFiAccessApiException {
|
||||
Consumer<Throwable> onError, BiConsumer<Integer, String> onClosed) throws UnifiAccessApiException {
|
||||
Session session = wsSession;
|
||||
if (session != null && session.isOpen()) {
|
||||
return;
|
||||
}
|
||||
// Ensure we have a valid session before connecting
|
||||
if (sessionCookie == null || csrfToken == null) {
|
||||
login();
|
||||
}
|
||||
this.onClosedCallback = onClosed;
|
||||
this.closedNotified.set(false);
|
||||
// Seed the heartbeat timestamp so the monitor doesn't see the default 0 and immediately escalate
|
||||
// if its first tick races with onWebSocketConnect.
|
||||
this.lastHeartbeatEpochMs = System.currentTimeMillis();
|
||||
try {
|
||||
URI wsUri = URI.create("wss://" + host + V2_BASE + "/ws/notification");
|
||||
logger.debug("Notifications WebSocket URI: {}", wsUri);
|
||||
ClientUpgradeRequest req = new ClientUpgradeRequest();
|
||||
String cookie = sessionCookie;
|
||||
String cookie = unifiSession.getAuthCookie();
|
||||
if (cookie != null) {
|
||||
req.setHeader("Cookie", cookie);
|
||||
}
|
||||
@@ -833,65 +955,82 @@ public final class UniFiAccessApiClient implements Closeable {
|
||||
@NonNullByDefault({})
|
||||
public void onWebSocketClose(int statusCode, String reason) {
|
||||
logger.debug("Notifications WebSocket closed: {} - {}", statusCode, reason);
|
||||
try {
|
||||
onClosed.accept(statusCode, reason);
|
||||
} catch (Exception callbackEx) {
|
||||
logger.debug("Error in onClosed callback: {}", callbackEx.getMessage());
|
||||
}
|
||||
stopWsMonitor();
|
||||
notifyClosedOnce(statusCode, reason != null ? reason : "");
|
||||
}
|
||||
};
|
||||
|
||||
wsClient.connect(socket, wsUri, req);
|
||||
startWsMonitor();
|
||||
} catch (IOException e) {
|
||||
throw new UniFiAccessApiException("WebSocket connect failed: " + e.getMessage(), e);
|
||||
throw new UnifiAccessApiException("WebSocket connect failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- HTTP Helpers ----
|
||||
|
||||
private ContentResponse execGet(String path) throws UniFiAccessApiException {
|
||||
private ContentResponse execGet(String path) throws UnifiAccessApiException {
|
||||
try {
|
||||
return newAuthenticatedRequest(HttpMethod.GET, path, null).send();
|
||||
} catch (UniFiAccessApiException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new UniFiAccessApiException("GET failed for " + path + ": " + e.getMessage(), e);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new UnifiAccessApiException("GET interrupted for " + path, e);
|
||||
} catch (TimeoutException | ExecutionException e) {
|
||||
throw new UnifiAccessApiException("GET failed for " + path + ": " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private ContentResponse execPut(String path, String body) throws UniFiAccessApiException {
|
||||
private ContentResponse execPut(String path, String body) throws UnifiAccessApiException {
|
||||
try {
|
||||
return newAuthenticatedRequest(HttpMethod.PUT, path, body).send();
|
||||
} catch (UniFiAccessApiException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new UniFiAccessApiException("PUT failed for " + path + ": " + e.getMessage(), e);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new UnifiAccessApiException("PUT interrupted for " + path, e);
|
||||
} catch (TimeoutException | ExecutionException e) {
|
||||
throw new UnifiAccessApiException("PUT failed for " + path + ": " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void execDeveloperPost(String path, @Nullable String body, String token, String action)
|
||||
throws UnifiAccessApiException {
|
||||
try {
|
||||
URI uri = baseUri(path);
|
||||
Request req = httpClient.newRequest(uri).method(HttpMethod.POST)
|
||||
.header(HttpHeader.ACCEPT, "application/json").header("X-API-KEY", token)
|
||||
.timeout(HTTP_TIMEOUT_MS, TimeUnit.MILLISECONDS);
|
||||
if (body != null) {
|
||||
req.header(HttpHeader.CONTENT_TYPE, "application/json");
|
||||
req.content(new StringContentProvider(body, StandardCharsets.UTF_8));
|
||||
}
|
||||
logger.debug("Developer POST {}", uri);
|
||||
ContentResponse resp = req.send();
|
||||
int sc = resp.getStatus();
|
||||
if (sc == HttpStatus.UNAUTHORIZED_401 || sc == HttpStatus.FORBIDDEN_403) {
|
||||
throw new UnifiAccessApiException(action + " failed: invalid API token (HTTP " + sc + ")",
|
||||
AuthState.REJECTED);
|
||||
}
|
||||
if (!HttpStatus.isSuccess(sc)) {
|
||||
throw new UnifiAccessApiException(action + " failed: HTTP " + sc);
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new UnifiAccessApiException(action + " interrupted", e);
|
||||
} catch (TimeoutException | ExecutionException e) {
|
||||
throw new UnifiAccessApiException(action + " failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an authenticated request with session cookie and CSRF token.
|
||||
* If no session exists, triggers a login first.
|
||||
* Builds an authenticated request by delegating auth-header stamping to the shared
|
||||
* {@link org.openhab.binding.unifi.internal.api.UniFiSession}. If no session exists, triggers a login first.
|
||||
*/
|
||||
private Request newAuthenticatedRequest(HttpMethod method, String path, @Nullable String body)
|
||||
throws UniFiAccessApiException {
|
||||
if (sessionCookie == null || csrfToken == null) {
|
||||
login();
|
||||
}
|
||||
|
||||
throws UnifiAccessApiException {
|
||||
URI uri = baseUri(path);
|
||||
Request req = httpClient.newRequest(uri).method(method).header(HttpHeader.ACCEPT, "application/json")
|
||||
.timeout(HTTP_TIMEOUT_MS, TimeUnit.MILLISECONDS);
|
||||
|
||||
String cookie = sessionCookie;
|
||||
if (cookie != null) {
|
||||
req.header("Cookie", cookie);
|
||||
}
|
||||
String csrf = csrfToken;
|
||||
if (csrf != null) {
|
||||
req.header(CSRF_HEADER, csrf);
|
||||
}
|
||||
unifiSession.addAuthHeaders(req);
|
||||
|
||||
if (body != null) {
|
||||
req.header(HttpHeader.CONTENT_TYPE, "application/json");
|
||||
@@ -910,7 +1049,7 @@ public final class UniFiAccessApiClient implements Closeable {
|
||||
/**
|
||||
* Ensures the HTTP response is 2xx. On 401, attempts re-authentication.
|
||||
*/
|
||||
private void ensure2xx(ContentResponse resp, String action) throws UniFiAccessApiException {
|
||||
private void ensure2xx(ContentResponse resp, String action) throws UnifiAccessApiException {
|
||||
if (logger.isTraceEnabled()) {
|
||||
String mediaType = resp.getMediaType();
|
||||
if ("image/jpeg".equalsIgnoreCase(mediaType) || "image/png".equalsIgnoreCase(mediaType)) {
|
||||
@@ -923,29 +1062,27 @@ public final class UniFiAccessApiClient implements Closeable {
|
||||
|
||||
int sc = resp.getStatus();
|
||||
if (sc == HttpStatus.UNAUTHORIZED_401) {
|
||||
if (!reauthenticating) {
|
||||
reauthenticating = true;
|
||||
try {
|
||||
logger.debug("Received 401 for {}, re-authenticating", action);
|
||||
login();
|
||||
} finally {
|
||||
reauthenticating = false;
|
||||
}
|
||||
throw new UniFiAccessApiException("Re-authenticated after 401 for " + action + ", retry required");
|
||||
try {
|
||||
logger.debug("Received 401 for {}, re-authenticating via shared session", action);
|
||||
unifiSession.reauthenticate().get(30, TimeUnit.SECONDS);
|
||||
} catch (Exception e) {
|
||||
throw new UnifiAccessApiException("Authentication failed for " + action + ": " + e.getMessage(),
|
||||
AuthState.REJECTED);
|
||||
}
|
||||
throw new UniFiAccessApiException("Authentication failed for " + action + ": " + sc, true);
|
||||
throw new UnifiAccessApiException("Re-authenticated after 401 for " + action + ", retry required");
|
||||
}
|
||||
if (sc == HttpStatus.FORBIDDEN_403) {
|
||||
throw new UniFiAccessApiException("Forbidden for " + action + ": " + sc, true);
|
||||
throw new UnifiAccessApiException("Forbidden for " + action + " (likely throttled): " + sc,
|
||||
AuthState.THROTTLED);
|
||||
}
|
||||
if (!HttpStatus.isSuccess(sc)) {
|
||||
throw new UniFiAccessApiException("Non 2xx response for " + action + ": " + sc);
|
||||
throw new UnifiAccessApiException("Non 2xx response for " + action + ": " + sc);
|
||||
}
|
||||
|
||||
// Update CSRF token from response if present
|
||||
String updatedCsrf = resp.getHeaders().get(UPDATED_CSRF_HEADER);
|
||||
// Update CSRF token from response if present — delegate to session so all family bindings see it.
|
||||
String updatedCsrf = resp.getHeaders().get("X-Updated-CSRF-Token");
|
||||
if (updatedCsrf != null && !updatedCsrf.isBlank()) {
|
||||
this.csrfToken = updatedCsrf;
|
||||
unifiSession.updateCsrfToken(updatedCsrf);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -954,11 +1091,11 @@ public final class UniFiAccessApiClient implements Closeable {
|
||||
* V2 responses use: {"code":200,"codeS":"SUCCESS","msg":"...","data":{...}}
|
||||
* Success check: codeS === "SUCCESS"
|
||||
*/
|
||||
private void checkV2Success(JsonObject root, String action) throws UniFiAccessApiException {
|
||||
private void checkV2Success(JsonObject root, String action) throws UnifiAccessApiException {
|
||||
String codeS = getStringOrNull(root, "codeS");
|
||||
if (codeS == null || !"SUCCESS".equals(codeS)) {
|
||||
String msg = getStringOrNull(root, "msg");
|
||||
throw new UniFiAccessApiException(
|
||||
throw new UnifiAccessApiException(
|
||||
action + " failed: codeS=" + codeS + ", msg=" + (msg != null ? msg : "unknown"));
|
||||
}
|
||||
}
|
||||
@@ -966,7 +1103,7 @@ public final class UniFiAccessApiClient implements Closeable {
|
||||
/**
|
||||
* Checks a v2 response for success and returns a boolean.
|
||||
*/
|
||||
private boolean checkV2SuccessBool(ContentResponse resp, String action) throws UniFiAccessApiException {
|
||||
private boolean checkV2SuccessBool(ContentResponse resp, String action) throws UnifiAccessApiException {
|
||||
String json = resp.getContentAsString();
|
||||
if (json.isBlank()) {
|
||||
return true; // Some endpoints return empty body on success
|
||||
@@ -1035,7 +1172,7 @@ public final class UniFiAccessApiClient implements Closeable {
|
||||
*/
|
||||
@SuppressWarnings("unused")
|
||||
private <E> List<E> parseListMaybeWrapped(String json, java.lang.reflect.Type wrappedListType,
|
||||
java.lang.reflect.Type rawListType, String action, String... altArrayKeys) throws UniFiAccessApiException {
|
||||
java.lang.reflect.Type rawListType, String action, String... altArrayKeys) throws UnifiAccessApiException {
|
||||
if (json.isBlank()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
@@ -1056,9 +1193,9 @@ public final class UniFiAccessApiClient implements Closeable {
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new UniFiAccessApiException("Failed to parse list response for " + action + ": " + e.getMessage(), e);
|
||||
throw new UnifiAccessApiException("Failed to parse list response for " + action + ": " + e.getMessage(), e);
|
||||
}
|
||||
throw new UniFiAccessApiException("Failed to parse list response for " + action + ": unexpected JSON");
|
||||
throw new UnifiAccessApiException("Failed to parse list response for " + action + ": unexpected JSON");
|
||||
}
|
||||
|
||||
// ---- WebSocket Monitor ----
|
||||
@@ -1073,17 +1210,9 @@ public final class UniFiAccessApiClient implements Closeable {
|
||||
if (s != null && s.isOpen()) {
|
||||
long sinceMs = System.currentTimeMillis() - lastHeartbeatEpochMs;
|
||||
if (sinceMs > 10_000L) {
|
||||
logger.debug("Notifications heartbeat missing ({} ms). Reconnecting...", sinceMs);
|
||||
try {
|
||||
s.close();
|
||||
} catch (Exception e) {
|
||||
logger.debug("Error closing notifications WebSocket", e);
|
||||
} finally {
|
||||
wsSession = null;
|
||||
}
|
||||
logger.debug("Notifications heartbeat missing ({} ms), escalating close", sinceMs);
|
||||
escalateClose(s, "Heartbeat timeout after " + sinceMs + " ms");
|
||||
}
|
||||
} else {
|
||||
logger.debug("Notifications WebSocket not open");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.debug("WS monitor error: ", e);
|
||||
@@ -1094,6 +1223,34 @@ public final class UniFiAccessApiClient implements Closeable {
|
||||
}
|
||||
}
|
||||
|
||||
// Signal closure synchronously instead of waiting on Jetty's async close handshake,
|
||||
// which can stall for many minutes when the remote side has silently disappeared.
|
||||
private void escalateClose(Session s, String reason) {
|
||||
stopWsMonitor();
|
||||
wsSession = null;
|
||||
try {
|
||||
s.close();
|
||||
} catch (Exception e) {
|
||||
logger.debug("Error closing notifications WebSocket: {}", e.getMessage());
|
||||
}
|
||||
notifyClosedOnce(WS_CLOSE_HEARTBEAT_TIMEOUT, reason);
|
||||
}
|
||||
|
||||
// Fires the onClosed callback at most once per WebSocket lifecycle so the
|
||||
// heartbeat-monitor escalation and Jetty's onWebSocketClose can't double-notify.
|
||||
private void notifyClosedOnce(int statusCode, String reason) {
|
||||
if (closedNotified.compareAndSet(false, true)) {
|
||||
BiConsumer<Integer, String> cb = onClosedCallback;
|
||||
if (cb != null) {
|
||||
try {
|
||||
cb.accept(statusCode, reason);
|
||||
} catch (Exception callbackEx) {
|
||||
logger.debug("Error in onClosed callback: {}", callbackEx.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void stopWsMonitor() {
|
||||
try {
|
||||
ScheduledFuture<?> monitorFuture = wsMonitorFuture;
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.access.config;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link UnifiAccessBridgeConfiguration} class contains fields mapping thing configuration parameters.
|
||||
* <p>
|
||||
* Since Phase F, connection details (host/username/password) come from the parent {@code unifi:controller}
|
||||
* bridge. The Access bridge configuration only carries Access-specific options.
|
||||
*
|
||||
* @author Dan Cunningham - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class UnifiAccessBridgeConfiguration {
|
||||
public int refreshInterval = 300;
|
||||
public String token = "";
|
||||
public boolean autoManageToken = true;
|
||||
/** True when {@link #token} was created by the binding's auto-provisioning, not pasted by the user. */
|
||||
public boolean tokenAutoManaged = false;
|
||||
}
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.config;
|
||||
package org.openhab.binding.unifi.internal.access.config;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.dto;
|
||||
package org.openhab.binding.unifi.internal.access.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.dto;
|
||||
package org.openhab.binding.unifi.internal.access.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZonedDateTime;
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.dto;
|
||||
package org.openhab.binding.unifi.internal.access.dto;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalTime;
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.dto;
|
||||
package org.openhab.binding.unifi.internal.access.dto;
|
||||
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.dto;
|
||||
package org.openhab.binding.unifi.internal.access.dto;
|
||||
|
||||
import java.lang.reflect.Type;
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.dto;
|
||||
package org.openhab.binding.unifi.internal.access.dto;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.dto;
|
||||
package org.openhab.binding.unifi.internal.access.dto;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.dto;
|
||||
package org.openhab.binding.unifi.internal.access.dto;
|
||||
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.dto;
|
||||
package org.openhab.binding.unifi.internal.access.dto;
|
||||
|
||||
/**
|
||||
* Door emergency settings payload for get/set endpoints.
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.dto;
|
||||
package org.openhab.binding.unifi.internal.access.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
+23
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.dto;
|
||||
package org.openhab.binding.unifi.internal.access.dto;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
@@ -20,11 +20,19 @@ import com.google.gson.annotations.SerializedName;
|
||||
* @author Dan Cunningham - Initial contribution
|
||||
*/
|
||||
public class DoorLockRule {
|
||||
@SerializedName(value = "type", alternate = { "temp_lock_type" })
|
||||
public DoorState.DoorLockRuleType type;
|
||||
/** minutes, only for type=custom and for setting the rule */
|
||||
public Integer interval = 0;
|
||||
@SerializedName(value = "until", alternate = { "ended_time", "endtime" })
|
||||
public Long until = 0L; // milliseconds since epoch
|
||||
/**
|
||||
* End of the temporary unlock window, in epoch <em>seconds</em>, as reported by the device's
|
||||
* {@code lock_rule} read-back ({@code unlock_period} / {@code end_time_interval}). The setter-side
|
||||
* {@link #until} is epoch milliseconds; {@link #endInstantMillis()} reconciles the two.
|
||||
*/
|
||||
@SerializedName(value = "unlock_period", alternate = { "end_time_interval" })
|
||||
public Long unlockPeriod = 0L; // seconds since epoch
|
||||
|
||||
public DoorLockRule(DoorState.DoorLockRuleType type, Integer minutes) {
|
||||
this.type = type;
|
||||
@@ -59,4 +67,18 @@ public class DoorLockRule {
|
||||
public static DoorLockRule lockNow() {
|
||||
return new DoorLockRule(DoorState.DoorLockRuleType.LOCK_NOW);
|
||||
}
|
||||
|
||||
/**
|
||||
* The end of the unlock window in epoch milliseconds, preferring the device read-back
|
||||
* {@code unlock_period} (epoch seconds) and falling back to the setter-side {@code until}
|
||||
* (epoch milliseconds). Returns 0 if neither is set.
|
||||
*/
|
||||
public long endInstantMillis() {
|
||||
Long period = unlockPeriod;
|
||||
if (period != null && period > 0) {
|
||||
return period * 1000L;
|
||||
}
|
||||
Long u = until;
|
||||
return u != null ? u : 0L;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.dto;
|
||||
package org.openhab.binding.unifi.internal.access.dto;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.dto;
|
||||
package org.openhab.binding.unifi.internal.access.dto;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.dto;
|
||||
package org.openhab.binding.unifi.internal.access.dto;
|
||||
|
||||
/**
|
||||
* Hold images for door thumbnails.
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.dto;
|
||||
package org.openhab.binding.unifi.internal.access.dto;
|
||||
|
||||
/**
|
||||
* NFC Card model.
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.dto;
|
||||
package org.openhab.binding.unifi.internal.access.dto;
|
||||
|
||||
/**
|
||||
* NFC Card Enrollment Session model
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.dto;
|
||||
package org.openhab.binding.unifi.internal.access.dto;
|
||||
|
||||
/**
|
||||
* NFC Card Enrollment Status model
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.dto;
|
||||
package org.openhab.binding.unifi.internal.access.dto;
|
||||
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.List;
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.dto;
|
||||
package org.openhab.binding.unifi.internal.access.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.dto;
|
||||
package org.openhab.binding.unifi.internal.access.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.dto;
|
||||
package org.openhab.binding.unifi.internal.access.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.access.dto;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiException;
|
||||
|
||||
/**
|
||||
* Exception for UniFi Access API errors. Extends the shared {@link UniFiException} so the
|
||||
* {@link UniFiException.AuthState} classification is consistent across Access, Protect, and Network.
|
||||
*
|
||||
* @author Dan Cunningham - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class UnifiAccessApiException extends UniFiException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public UnifiAccessApiException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public UnifiAccessApiException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public UnifiAccessApiException(String message, AuthState authState) {
|
||||
super(message, authState);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.dto;
|
||||
package org.openhab.binding.unifi.internal.access.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.dto;
|
||||
package org.openhab.binding.unifi.internal.access.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.dto;
|
||||
package org.openhab.binding.unifi.internal.access.dto;
|
||||
|
||||
/**
|
||||
* Webhook Endpoint model
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.dto;
|
||||
package org.openhab.binding.unifi.internal.access.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
+18
-8
@@ -10,23 +10,24 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.handler;
|
||||
package org.openhab.binding.unifi.internal.access.handler;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.unifiaccess.internal.UnifiAccessBindingConstants;
|
||||
import org.openhab.binding.unifiaccess.internal.api.UniFiAccessApiClient;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Notification;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Notification.DeviceUpdateData;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Notification.LocationState;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Notification.LocationUpdateV2Data;
|
||||
import org.openhab.binding.unifi.internal.access.UnifiAccessBindingConstants;
|
||||
import org.openhab.binding.unifi.internal.access.api.UnifiAccessApiClient;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Notification;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Notification.DeviceUpdateData;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Notification.LocationState;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Notification.LocationUpdateV2Data;
|
||||
import org.openhab.core.library.types.OnOffType;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingStatus;
|
||||
import org.openhab.core.thing.ThingStatusDetail;
|
||||
import org.openhab.core.thing.ThingStatusInfo;
|
||||
import org.openhab.core.thing.binding.BaseThingHandler;
|
||||
import org.openhab.core.types.State;
|
||||
|
||||
@@ -56,6 +57,15 @@ public abstract class UnifiAccessBaseHandler extends BaseThingHandler {
|
||||
super.updateStatus(status, statusDetail, description);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
|
||||
if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE) {
|
||||
updateStatus(ThingStatus.UNKNOWN);
|
||||
} else {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets this thing to ONLINE. Callable from the bridge handler.
|
||||
*/
|
||||
@@ -79,7 +89,7 @@ public abstract class UnifiAccessBaseHandler extends BaseThingHandler {
|
||||
return (h instanceof UnifiAccessBridgeHandler) ? (UnifiAccessBridgeHandler) h : null;
|
||||
}
|
||||
|
||||
protected @Nullable UniFiAccessApiClient getApiClient() {
|
||||
protected @Nullable UnifiAccessApiClient getApiClient() {
|
||||
UnifiAccessBridgeHandler bridge = getBridgeHandler();
|
||||
return bridge != null ? bridge.getApiClient() : null;
|
||||
}
|
||||
+174
-68
@@ -10,29 +10,36 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.handler;
|
||||
package org.openhab.binding.unifi.internal.access.handler;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.eclipse.jetty.util.ssl.SslContextFactory;
|
||||
import org.openhab.binding.unifiaccess.internal.UnifiAccessBindingConstants;
|
||||
import org.openhab.binding.unifiaccess.internal.UnifiAccessDiscoveryService;
|
||||
import org.openhab.binding.unifiaccess.internal.api.UniFiAccessApiClient;
|
||||
import org.openhab.binding.unifiaccess.internal.config.UnifiAccessBridgeConfiguration;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Device;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Door;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.DoorEmergencySettings;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.UniFiAccessApiException;
|
||||
import org.openhab.core.io.net.http.HttpClientFactory;
|
||||
import org.openhab.binding.unifi.internal.access.UnifiAccessBindingConstants;
|
||||
import org.openhab.binding.unifi.internal.access.UnifiAccessDiscoveryService;
|
||||
import org.openhab.binding.unifi.internal.access.api.UnifiAccessApiClient;
|
||||
import org.openhab.binding.unifi.internal.access.config.UnifiAccessBridgeConfiguration;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Device;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Door;
|
||||
import org.openhab.binding.unifi.internal.access.dto.DoorEmergencySettings;
|
||||
import org.openhab.binding.unifi.internal.access.dto.UnifiAccessApiException;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiApiKeyManager;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiException;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiException.AuthState;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiSession;
|
||||
import org.openhab.binding.unifi.internal.handler.UniFiControllerBridgeHandler;
|
||||
import org.openhab.core.config.core.Configuration;
|
||||
import org.openhab.core.library.types.OnOffType;
|
||||
import org.openhab.core.library.types.StringType;
|
||||
import org.openhab.core.thing.Bridge;
|
||||
@@ -64,23 +71,36 @@ public class UnifiAccessBridgeHandler extends BaseBridgeHandler {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(UnifiAccessBridgeHandler.class);
|
||||
private static final int MAX_RECONNECT_DELAY_SECONDS = 300;
|
||||
private static final int THROTTLED_INITIAL_DELAY_SECONDS = 60;
|
||||
private static final int THROTTLED_MAX_DELAY_SECONDS = 1800;
|
||||
|
||||
final Gson gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
|
||||
final Map<String, String> remoteViewRequestToDeviceId = new ConcurrentHashMap<>();
|
||||
|
||||
private final HttpClient httpClient;
|
||||
private @Nullable UniFiAccessApiClient apiClient;
|
||||
private volatile @Nullable UnifiAccessApiClient apiClient;
|
||||
private UnifiAccessBridgeConfiguration config = new UnifiAccessBridgeConfiguration();
|
||||
private @Nullable ScheduledFuture<?> reconnectFuture;
|
||||
private @Nullable ScheduledFuture<?> pollingFuture;
|
||||
private @Nullable UnifiAccessDiscoveryService discoveryService;
|
||||
private @Nullable UnifiAccessNotificationRouter notificationRouter;
|
||||
private int reconnectAttempt = 0;
|
||||
private int throttledReconnectAttempt = 0;
|
||||
|
||||
public UnifiAccessBridgeHandler(Bridge bridge, HttpClientFactory httpClientFactory) {
|
||||
public UnifiAccessBridgeHandler(Bridge bridge) {
|
||||
super(bridge);
|
||||
httpClient = httpClientFactory.createHttpClient(UnifiAccessBindingConstants.BINDING_ID,
|
||||
new SslContextFactory.Client(true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent {@code unifi:controller} bridge handler, or {@code null} if this Access bridge has no
|
||||
* parent configured yet (e.g. an orphaned legacy {@code unifiaccess:bridge} thing that has not been
|
||||
* reparented after upgrade).
|
||||
*/
|
||||
private @Nullable UniFiControllerBridgeHandler getParentHandler() {
|
||||
Bridge bridge = getBridge();
|
||||
if (bridge != null && bridge.getHandler() instanceof UniFiControllerBridgeHandler ucbh) {
|
||||
return ucbh;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -91,6 +111,21 @@ public class UnifiAccessBridgeHandler extends BaseBridgeHandler {
|
||||
@Override
|
||||
public void initialize() {
|
||||
logger.debug("Initializing bridge handler");
|
||||
|
||||
UniFiControllerBridgeHandler parentHandler = getParentHandler();
|
||||
if (parentHandler == null) {
|
||||
Bridge parentBridge = getBridge();
|
||||
if (parentBridge != null) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
|
||||
} else {
|
||||
Object storedHost = getThing().getConfiguration().get("host");
|
||||
String hint = storedHost != null ? " (previously configured host: " + storedHost + ")" : "";
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||
"@text/offline.requires-unifi-controller-bridge" + hint);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
config = getConfigAs(UnifiAccessBridgeConfiguration.class);
|
||||
notificationRouter = new UnifiAccessNotificationRouter(gson, this, remoteViewRequestToDeviceId);
|
||||
updateStatus(ThingStatus.UNKNOWN);
|
||||
@@ -102,18 +137,9 @@ public class UnifiAccessBridgeHandler extends BaseBridgeHandler {
|
||||
logger.debug("Disposing bridge handler");
|
||||
cancelReconnect();
|
||||
cancelPolling();
|
||||
try {
|
||||
UniFiAccessApiClient client = this.apiClient;
|
||||
if (client != null) {
|
||||
client.close();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.debug("Failed to close notifications WebSocket: {}", e.getMessage());
|
||||
}
|
||||
try {
|
||||
httpClient.stop();
|
||||
} catch (Exception e) {
|
||||
logger.trace("Error stopping HTTP client: {}", e.getMessage());
|
||||
UnifiAccessApiClient client = this.apiClient;
|
||||
if (client != null) {
|
||||
client.close();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
@@ -126,7 +152,7 @@ public class UnifiAccessBridgeHandler extends BaseBridgeHandler {
|
||||
}
|
||||
String channelId = channelUID.getId();
|
||||
if (UnifiAccessBindingConstants.CHANNEL_BRIDGE_EMERGENCY_STATUS.equals(channelId)) {
|
||||
UniFiAccessApiClient api = this.apiClient;
|
||||
UnifiAccessApiClient api = this.apiClient;
|
||||
if (api == null) {
|
||||
return;
|
||||
}
|
||||
@@ -148,7 +174,7 @@ public class UnifiAccessBridgeHandler extends BaseBridgeHandler {
|
||||
try {
|
||||
api.setEmergencySettings(des);
|
||||
updateState(UnifiAccessBindingConstants.CHANNEL_BRIDGE_EMERGENCY_STATUS, new StringType(status));
|
||||
} catch (UniFiAccessApiException e) {
|
||||
} catch (UnifiAccessApiException e) {
|
||||
logger.debug("Failed to set emergency settings: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -164,26 +190,62 @@ public class UnifiAccessBridgeHandler extends BaseBridgeHandler {
|
||||
}
|
||||
|
||||
private synchronized void connect() {
|
||||
UniFiAccessApiClient client = this.apiClient;
|
||||
if (client != null) {
|
||||
client.close();
|
||||
UniFiControllerBridgeHandler parentHandler = getParentHandler();
|
||||
if (parentHandler == null) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||
"@text/offline.requires-unifi-controller-bridge");
|
||||
return;
|
||||
}
|
||||
if (!httpClient.isStarted()) {
|
||||
|
||||
UniFiSession session;
|
||||
try {
|
||||
session = parentHandler.getSessionAsync().get(30, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
} catch (ExecutionException | TimeoutException e) {
|
||||
logger.debug("Parent bridge session not available: {}", e.getMessage());
|
||||
setOfflineAndReconnect(e.getMessage(), false);
|
||||
return;
|
||||
}
|
||||
|
||||
HttpClient httpClient = parentHandler.getHttpClient();
|
||||
String host = parentHandler.getHost();
|
||||
|
||||
// Auto-create API key for Developer API access (doorbell, etc.) — non-fatal if it fails.
|
||||
String apiToken = config.token;
|
||||
if ((apiToken == null || apiToken.isBlank()) && config.autoManageToken) {
|
||||
try {
|
||||
httpClient.start();
|
||||
} catch (Exception e) {
|
||||
logger.debug("Failed to start HTTP client: {}", e.getMessage());
|
||||
setOfflineAndReconnect(e.getMessage());
|
||||
return;
|
||||
UniFiApiKeyManager keyManager = parentHandler.getApiKeyManager();
|
||||
if (keyManager != null) {
|
||||
String keyName = "openHAB-access-" + getThing().getUID().getId();
|
||||
apiToken = keyManager.provisionApiToken(keyName);
|
||||
logger.debug("Successfully created API key '{}': {}***", keyName,
|
||||
apiToken.substring(0, Math.min(8, apiToken.length())));
|
||||
Configuration thingConfig = editConfiguration();
|
||||
thingConfig.put("token", apiToken);
|
||||
thingConfig.put("tokenAutoManaged", true);
|
||||
updateConfiguration(thingConfig);
|
||||
config.tokenAutoManaged = true;
|
||||
}
|
||||
} catch (UniFiException e) {
|
||||
logger.debug("Failed to auto-create API key (Developer API features will be unavailable)", e);
|
||||
}
|
||||
}
|
||||
client = new UniFiAccessApiClient(httpClient, config.host, gson, config.username, config.password, scheduler);
|
||||
|
||||
UnifiAccessApiClient client = new UnifiAccessApiClient(httpClient, host, gson, session, scheduler, apiToken,
|
||||
this::reprovisionApiToken);
|
||||
UnifiAccessApiClient previous = this.apiClient;
|
||||
this.apiClient = client;
|
||||
if (previous != null) {
|
||||
previous.close();
|
||||
}
|
||||
UnifiAccessNotificationRouter router = this.notificationRouter;
|
||||
try {
|
||||
client.openNotifications(() -> {
|
||||
logger.debug("Notifications WebSocket opened");
|
||||
reconnectAttempt = 0;
|
||||
throttledReconnectAttempt = 0;
|
||||
updateStatus(ThingStatus.ONLINE);
|
||||
startPolling();
|
||||
scheduler.execute(UnifiAccessBridgeHandler.this::syncDevices);
|
||||
@@ -193,17 +255,24 @@ public class UnifiAccessBridgeHandler extends BaseBridgeHandler {
|
||||
}
|
||||
}, error -> {
|
||||
logger.debug("Notifications error: {}", error.getMessage());
|
||||
setOfflineAndReconnect(error.getMessage());
|
||||
setOfflineAndReconnect(error.getMessage(), false);
|
||||
}, (statusCode, reason) -> {
|
||||
logger.debug("Notifications closed: {} - {}", statusCode, reason);
|
||||
setOfflineAndReconnect(reason);
|
||||
setOfflineAndReconnect(reason, false);
|
||||
});
|
||||
} catch (UniFiAccessApiException e) {
|
||||
} catch (UnifiAccessApiException e) {
|
||||
logger.debug("Failed to open notifications WebSocket", e);
|
||||
if (e.isAuthFailure()) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/offline.auth-failed");
|
||||
} else {
|
||||
setOfflineAndReconnect("Failed to open notifications WebSocket");
|
||||
switch (e.getAuthState()) {
|
||||
case REJECTED:
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||
"@text/offline.auth-failed");
|
||||
break;
|
||||
case THROTTLED:
|
||||
setOfflineAndReconnect("@text/offline.login-throttled", true);
|
||||
break;
|
||||
default:
|
||||
setOfflineAndReconnect("@text/offline.notifications-failed", false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,38 +281,48 @@ public class UnifiAccessBridgeHandler extends BaseBridgeHandler {
|
||||
this.discoveryService = discoveryService;
|
||||
}
|
||||
|
||||
private void setOfflineAndReconnect(@Nullable String message) {
|
||||
ScheduledFuture<?> reconnectFuture = this.reconnectFuture;
|
||||
if (reconnectFuture != null && !reconnectFuture.isDone()) {
|
||||
return;
|
||||
private synchronized void setOfflineAndReconnect(@Nullable String message, boolean throttled) {
|
||||
ScheduledFuture<?> existing = this.reconnectFuture;
|
||||
if (existing != null && !existing.isDone()) {
|
||||
// Throttled supersedes any pending fast reconnect, otherwise we'd just get throttled again.
|
||||
if (throttled) {
|
||||
existing.cancel(false);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
cancelPolling();
|
||||
String msg = message != null ? message : "Unknown error";
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, msg);
|
||||
|
||||
int delay = Math.min((int) Math.pow(2, reconnectAttempt) * 5, MAX_RECONNECT_DELAY_SECONDS);
|
||||
reconnectAttempt++;
|
||||
logger.debug("Scheduling reconnect in {} seconds (attempt {})", delay, reconnectAttempt);
|
||||
int delay;
|
||||
if (throttled) {
|
||||
delay = Math.min((int) Math.pow(2, throttledReconnectAttempt) * THROTTLED_INITIAL_DELAY_SECONDS,
|
||||
THROTTLED_MAX_DELAY_SECONDS);
|
||||
throttledReconnectAttempt++;
|
||||
logger.debug("Scheduling reconnect in {} seconds (throttled, attempt {})", delay,
|
||||
throttledReconnectAttempt);
|
||||
} else {
|
||||
delay = Math.min((int) Math.pow(2, reconnectAttempt) * 5, MAX_RECONNECT_DELAY_SECONDS);
|
||||
reconnectAttempt++;
|
||||
logger.debug("Scheduling reconnect in {} seconds (attempt {})", delay, reconnectAttempt);
|
||||
}
|
||||
|
||||
this.reconnectFuture = scheduler.schedule(() -> {
|
||||
try {
|
||||
scheduler.execute(this::connect);
|
||||
} catch (Exception ex) {
|
||||
} catch (RejectedExecutionException ex) {
|
||||
logger.debug("Reconnect attempt failed to schedule connect: {}", ex.getMessage());
|
||||
}
|
||||
}, delay, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private void cancelReconnect() {
|
||||
try {
|
||||
ScheduledFuture<?> f = reconnectFuture;
|
||||
if (f != null) {
|
||||
f.cancel(true);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
} finally {
|
||||
reconnectFuture = null;
|
||||
ScheduledFuture<?> f = reconnectFuture;
|
||||
if (f != null) {
|
||||
f.cancel(true);
|
||||
}
|
||||
reconnectFuture = null;
|
||||
}
|
||||
|
||||
private void startPolling() {
|
||||
@@ -263,7 +342,7 @@ public class UnifiAccessBridgeHandler extends BaseBridgeHandler {
|
||||
}
|
||||
|
||||
synchronized void syncDevices() {
|
||||
UniFiAccessApiClient client = this.apiClient;
|
||||
UnifiAccessApiClient client = this.apiClient;
|
||||
if (client == null) {
|
||||
return;
|
||||
}
|
||||
@@ -321,21 +400,23 @@ public class UnifiAccessBridgeHandler extends BaseBridgeHandler {
|
||||
// Store configs and build settings from bootstrap
|
||||
dh.updateConfigMap(device.configMap);
|
||||
if (!device.configMap.isEmpty()) {
|
||||
var settings = UniFiAccessApiClient.buildSettingsFromConfigs(device.configMap);
|
||||
var settings = UnifiAccessApiClient.buildSettingsFromConfigs(device.configMap);
|
||||
dh.updateFromSettings(settings);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (UniFiAccessApiException e) {
|
||||
} catch (UnifiAccessApiException e) {
|
||||
logger.debug("Polling error: {}", e.getMessage());
|
||||
if (e.isAuthFailure()) {
|
||||
if (e.getAuthState() == AuthState.REJECTED) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/offline.auth-failed");
|
||||
cancelPolling();
|
||||
} else if (e.getAuthState() == AuthState.THROTTLED) {
|
||||
setOfflineAndReconnect("@text/offline.login-throttled", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public @Nullable UniFiAccessApiClient getApiClient() {
|
||||
public @Nullable UnifiAccessApiClient getApiClient() {
|
||||
return apiClient;
|
||||
}
|
||||
|
||||
@@ -343,6 +424,31 @@ public class UnifiAccessBridgeHandler extends BaseBridgeHandler {
|
||||
return config;
|
||||
}
|
||||
|
||||
synchronized @Nullable String reprovisionApiToken() {
|
||||
if (!config.autoManageToken || !config.tokenAutoManaged) {
|
||||
// Don't bulldoze a user-supplied token.
|
||||
return null;
|
||||
}
|
||||
UniFiControllerBridgeHandler parentHandler = getParentHandler();
|
||||
UniFiApiKeyManager keyManager = parentHandler != null ? parentHandler.getApiKeyManager() : null;
|
||||
if (keyManager == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
String keyName = "openHAB-access-" + getThing().getUID().getId();
|
||||
String newToken = keyManager.provisionApiToken(keyName);
|
||||
Configuration thingConfig = editConfiguration();
|
||||
thingConfig.put("token", newToken);
|
||||
thingConfig.put("tokenAutoManaged", true);
|
||||
updateConfiguration(thingConfig);
|
||||
logger.debug("Re-provisioned API token after auth failure");
|
||||
return newToken;
|
||||
} catch (UniFiException e) {
|
||||
logger.debug("Failed to re-provision API token", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// -- Package-private handler lookup methods used by NotificationRouter --
|
||||
|
||||
@Nullable
|
||||
+14
-14
@@ -10,9 +10,9 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.handler;
|
||||
package org.openhab.binding.unifi.internal.access.handler;
|
||||
|
||||
import static org.openhab.binding.unifiaccess.internal.UnifiAccessBindingConstants.*;
|
||||
import static org.openhab.binding.unifi.internal.access.UnifiAccessBindingConstants.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
@@ -20,16 +20,16 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.unifiaccess.internal.api.UniFiAccessApiClient;
|
||||
import org.openhab.binding.unifiaccess.internal.config.UnifiAccessDeviceConfiguration;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Device;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.DeviceAccessMethodSettings;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.DeviceAccessMethodSettings.EnabledFlag;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Door;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.DoorState;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Notification.LocationState;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Notification.RemoteViewChangeData;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Notification.RemoteViewData;
|
||||
import org.openhab.binding.unifi.internal.access.api.UnifiAccessApiClient;
|
||||
import org.openhab.binding.unifi.internal.access.config.UnifiAccessDeviceConfiguration;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Device;
|
||||
import org.openhab.binding.unifi.internal.access.dto.DeviceAccessMethodSettings;
|
||||
import org.openhab.binding.unifi.internal.access.dto.DeviceAccessMethodSettings.EnabledFlag;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Door;
|
||||
import org.openhab.binding.unifi.internal.access.dto.DoorState;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Notification.LocationState;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Notification.RemoteViewChangeData;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Notification.RemoteViewData;
|
||||
import org.openhab.core.library.types.OnOffType;
|
||||
import org.openhab.core.library.types.OpenClosedType;
|
||||
import org.openhab.core.library.types.StringType;
|
||||
@@ -85,7 +85,7 @@ public class UnifiAccessDeviceHandler extends UnifiAccessBaseHandler {
|
||||
return;
|
||||
}
|
||||
UnifiAccessBridgeHandler bridge = getBridgeHandler();
|
||||
UniFiAccessApiClient api = bridge != null ? bridge.getApiClient() : null;
|
||||
UnifiAccessApiClient api = bridge != null ? bridge.getApiClient() : null;
|
||||
if (api == null) {
|
||||
return;
|
||||
}
|
||||
@@ -107,7 +107,7 @@ public class UnifiAccessDeviceHandler extends UnifiAccessBaseHandler {
|
||||
api.updateDeviceConfig(deviceId, configKey, configValue);
|
||||
// Update local configMap and channel
|
||||
lastConfigMap.put(configKey, configValue);
|
||||
var settings = UniFiAccessApiClient.buildSettingsFromConfigs(lastConfigMap);
|
||||
var settings = UnifiAccessApiClient.buildSettingsFromConfigs(lastConfigMap);
|
||||
updateFromSettings(settings);
|
||||
} catch (Exception e) {
|
||||
logger.debug("Command failed for device {}: {}", deviceId, e.getMessage());
|
||||
+32
-32
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.handler;
|
||||
package org.openhab.binding.unifi.internal.access.handler;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
@@ -23,18 +23,18 @@ import java.util.Map;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.unifiaccess.internal.UnifiAccessBindingConstants;
|
||||
import org.openhab.binding.unifiaccess.internal.api.UniFiAccessApiClient;
|
||||
import org.openhab.binding.unifiaccess.internal.config.UnifiAccessDeviceConfiguration;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Door;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.DoorLockRule;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.DoorState;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Image;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Notification;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Notification.LocationState;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Notification.LocationUpdateV2Data;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Notification.RemoteViewChangeData;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.UniFiAccessApiException;
|
||||
import org.openhab.binding.unifi.internal.access.UnifiAccessBindingConstants;
|
||||
import org.openhab.binding.unifi.internal.access.api.UnifiAccessApiClient;
|
||||
import org.openhab.binding.unifi.internal.access.config.UnifiAccessDeviceConfiguration;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Door;
|
||||
import org.openhab.binding.unifi.internal.access.dto.DoorLockRule;
|
||||
import org.openhab.binding.unifi.internal.access.dto.DoorState;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Image;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Notification;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Notification.LocationState;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Notification.LocationUpdateV2Data;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Notification.RemoteViewChangeData;
|
||||
import org.openhab.binding.unifi.internal.access.dto.UnifiAccessApiException;
|
||||
import org.openhab.core.library.types.DateTimeType;
|
||||
import org.openhab.core.library.types.DecimalType;
|
||||
import org.openhab.core.library.types.OnOffType;
|
||||
@@ -97,7 +97,7 @@ public class UnifiAccessDoorHandler extends UnifiAccessBaseHandler {
|
||||
return;
|
||||
}
|
||||
UnifiAccessBridgeHandler bridge = getBridgeHandler();
|
||||
UniFiAccessApiClient api = bridge != null ? bridge.getApiClient() : null;
|
||||
UnifiAccessApiClient api = bridge != null ? bridge.getApiClient() : null;
|
||||
if (api == null) {
|
||||
return;
|
||||
}
|
||||
@@ -109,12 +109,12 @@ public class UnifiAccessDoorHandler extends UnifiAccessBaseHandler {
|
||||
DoorLockRule rule = this.lockRule;
|
||||
String hubId = getHubDeviceId();
|
||||
if (rule != null && rule.type == DoorState.DoorLockRuleType.SCHEDULE) {
|
||||
api.lockEarly(hubId);
|
||||
api.lockEarly(hubId, door.id);
|
||||
} else if (rule != null && (rule.type == DoorState.DoorLockRuleType.CUSTOM
|
||||
|| rule.type == DoorState.DoorLockRuleType.KEEP_UNLOCK)) {
|
||||
api.lockNow(hubId);
|
||||
api.lockNow(hubId, door.id);
|
||||
} else {
|
||||
api.resetDoorLockRule(hubId);
|
||||
api.resetDoorLockRule(hubId, door.id);
|
||||
}
|
||||
} else {
|
||||
// Unlock uses the location/door ID, not the hub device ID
|
||||
@@ -125,18 +125,18 @@ public class UnifiAccessDoorHandler extends UnifiAccessBaseHandler {
|
||||
case UnifiAccessBindingConstants.CHANNEL_KEEP_UNLOCKED:
|
||||
if (command instanceof OnOffType onOff) {
|
||||
if (onOff == OnOffType.ON) {
|
||||
api.keepDoorUnlocked(getHubDeviceId());
|
||||
api.keepDoorUnlocked(getHubDeviceId(), door.id);
|
||||
} else {
|
||||
api.resetDoorLockRule(getHubDeviceId());
|
||||
api.resetDoorLockRule(getHubDeviceId(), door.id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case UnifiAccessBindingConstants.CHANNEL_KEEP_LOCKED:
|
||||
if (command instanceof OnOffType onOff) {
|
||||
if (onOff == OnOffType.ON) {
|
||||
api.keepDoorLocked(getHubDeviceId());
|
||||
api.keepDoorLocked(getHubDeviceId(), door.id);
|
||||
} else {
|
||||
api.resetDoorLockRule(getHubDeviceId());
|
||||
api.resetDoorLockRule(getHubDeviceId(), door.id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -151,9 +151,9 @@ public class UnifiAccessDoorHandler extends UnifiAccessBaseHandler {
|
||||
minutes = Integer.parseInt(command.toString());
|
||||
}
|
||||
if (minutes > 0) {
|
||||
api.unlockForMinutes(getHubDeviceId(), minutes);
|
||||
api.unlockForMinutes(getHubDeviceId(), door.id, minutes);
|
||||
} else {
|
||||
api.resetDoorLockRule(getHubDeviceId());
|
||||
api.resetDoorLockRule(getHubDeviceId(), door.id);
|
||||
}
|
||||
updateState(UnifiAccessBindingConstants.CHANNEL_UNLOCK_MINUTES, UnDefType.UNDEF);
|
||||
}
|
||||
@@ -162,12 +162,12 @@ public class UnifiAccessDoorHandler extends UnifiAccessBaseHandler {
|
||||
if (command instanceof DateTimeType dateTime) {
|
||||
int minutes = (int) ChronoUnit.MINUTES.between(Instant.now(), dateTime.getInstant());
|
||||
if (minutes > 0) {
|
||||
api.unlockForMinutes(getHubDeviceId(), minutes);
|
||||
api.unlockForMinutes(getHubDeviceId(), door.id, minutes);
|
||||
} else {
|
||||
api.resetDoorLockRule(getHubDeviceId());
|
||||
api.resetDoorLockRule(getHubDeviceId(), door.id);
|
||||
}
|
||||
} else {
|
||||
api.resetDoorLockRule(getHubDeviceId());
|
||||
api.resetDoorLockRule(getHubDeviceId(), door.id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -204,7 +204,7 @@ public class UnifiAccessDoorHandler extends UnifiAccessBaseHandler {
|
||||
protected void handleLocationUpdateV2(LocationUpdateV2Data locationUpdate) {
|
||||
if (locationUpdate.thumbnail != null) {
|
||||
UnifiAccessBridgeHandler bridge = getBridgeHandler();
|
||||
UniFiAccessApiClient api = bridge != null ? bridge.getApiClient() : null;
|
||||
UnifiAccessApiClient api = bridge != null ? bridge.getApiClient() : null;
|
||||
if (api == null) {
|
||||
return;
|
||||
}
|
||||
@@ -257,7 +257,7 @@ public class UnifiAccessDoorHandler extends UnifiAccessBaseHandler {
|
||||
// Fetch initial thumbnail from bootstrap if available
|
||||
String thumbPath = door.doorThumbnail;
|
||||
if (thumbPath != null) {
|
||||
UniFiAccessApiClient api = getApiClient();
|
||||
UnifiAccessApiClient api = getApiClient();
|
||||
if (api != null) {
|
||||
try {
|
||||
Image thumbnail = api.getDoorThumbnail(thumbPath);
|
||||
@@ -268,12 +268,12 @@ public class UnifiAccessDoorHandler extends UnifiAccessBaseHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
UniFiAccessApiClient api = getApiClient();
|
||||
UnifiAccessApiClient api = getApiClient();
|
||||
if (api != null) {
|
||||
try {
|
||||
DoorLockRule rule = api.getDoorLockRule(door.id);
|
||||
DoorLockRule rule = api.getDoorLockRule(getHubDeviceId(), door.id);
|
||||
updateLockRule(rule);
|
||||
} catch (UniFiAccessApiException e) {
|
||||
} catch (UnifiAccessApiException e) {
|
||||
logger.debug("Failed to get door lock rule for door {}: {}", door.id, e.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -344,7 +344,7 @@ public class UnifiAccessDoorHandler extends UnifiAccessBaseHandler {
|
||||
break;
|
||||
case CUSTOM:
|
||||
updateState(UnifiAccessBindingConstants.CHANNEL_UNLOCK_UNTIL,
|
||||
new DateTimeType(Instant.ofEpochMilli(rule.until)));
|
||||
new DateTimeType(Instant.ofEpochMilli(rule.endInstantMillis())));
|
||||
lockChannels.remove(UnifiAccessBindingConstants.CHANNEL_UNLOCK_UNTIL);
|
||||
break;
|
||||
default:
|
||||
+3
-3
@@ -10,15 +10,15 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.handler;
|
||||
package org.openhab.binding.unifi.internal.access.handler;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Notification.InsightLogsAddData;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Notification.LogsAddData;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Notification.InsightLogsAddData;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Notification.LogsAddData;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
|
||||
+11
-11
@@ -10,22 +10,22 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiaccess.internal.handler;
|
||||
package org.openhab.binding.unifi.internal.access.handler;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.unifiaccess.internal.UnifiAccessBindingConstants;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Notification;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Notification.DeviceUpdateData;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Notification.DeviceUpdateV2Data;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Notification.InsightLogsAddData;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Notification.LocationUpdateV2Data;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Notification.LogsAddData;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Notification.RemoteUnlockData;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Notification.RemoteViewChangeData;
|
||||
import org.openhab.binding.unifiaccess.internal.dto.Notification.RemoteViewData;
|
||||
import org.openhab.binding.unifi.internal.access.UnifiAccessBindingConstants;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Notification;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Notification.DeviceUpdateData;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Notification.DeviceUpdateV2Data;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Notification.InsightLogsAddData;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Notification.LocationUpdateV2Data;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Notification.LogsAddData;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Notification.RemoteUnlockData;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Notification.RemoteViewChangeData;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Notification.RemoteViewData;
|
||||
import org.openhab.core.library.types.StringType;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
+14
-37
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifiprotect.internal.api.priv.client;
|
||||
package org.openhab.binding.unifi.internal.api;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
@@ -20,34 +20,32 @@ import java.time.Instant;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.unifiprotect.internal.UnifiProtectBindingConstants;
|
||||
import org.openhab.binding.unifiprotect.internal.api.priv.dto.gson.JsonUtil;
|
||||
import org.openhab.binding.unifi.internal.UniFiBindingConstants;
|
||||
import org.openhab.core.OpenHAB;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
|
||||
/**
|
||||
* Handles persistence of authentication session to disk
|
||||
* Saves cookies and CSRF tokens to avoid re-authenticating on every restart
|
||||
* Persists an authenticated session (cookie + CSRF token) to disk so the binding does not re-login on every
|
||||
* restart. Keyed by {@code (host, username)} so multiple consoles / users get independent cache files.
|
||||
* <p>
|
||||
* Ported from the UniFi Protect binding.
|
||||
*
|
||||
* @author Dan Cunningham - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class SessionPersistence {
|
||||
|
||||
private static final Gson GSON = new Gson();
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(SessionPersistence.class);
|
||||
|
||||
private final Path sessionFile;
|
||||
private static final String SESSION_DIR = Paths
|
||||
.get(OpenHAB.getUserDataFolder(), "cache", UnifiProtectBindingConstants.BINDING_ID).toString();
|
||||
.get(OpenHAB.getUserDataFolder(), "cache", UniFiBindingConstants.BINDING_ID).toString();
|
||||
|
||||
/**
|
||||
* Create a session persistence handler for the given host and username
|
||||
*
|
||||
* @param host the UniFi Protect host
|
||||
* @param username the username
|
||||
* @throws IOException if the session directory cannot be created
|
||||
*/
|
||||
public SessionPersistence(String host, String username) throws IOException {
|
||||
Path sessionDir = Paths.get(SESSION_DIR);
|
||||
Files.createDirectories(sessionDir);
|
||||
@@ -57,25 +55,12 @@ public class SessionPersistence {
|
||||
this.sessionFile = sessionDir.resolve(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save session data to disk
|
||||
*
|
||||
* @param sessionData the session data to save
|
||||
* @throws IOException if the session cannot be written to disk
|
||||
*/
|
||||
public void save(SessionData sessionData) throws IOException {
|
||||
String json = JsonUtil.toJson(sessionData);
|
||||
String json = GSON.toJson(sessionData);
|
||||
Files.writeString(sessionFile, json);
|
||||
logger.debug("Session saved to: {}", sessionFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load session data from disk
|
||||
* Returns null if no session exists or if it's expired
|
||||
*
|
||||
* @return the loaded session data, or null if no valid session exists
|
||||
* @throws IOException if the session file exists but cannot be read
|
||||
*/
|
||||
public @Nullable SessionData load() throws IOException {
|
||||
if (!Files.exists(sessionFile)) {
|
||||
logger.debug("No session file found: {}", sessionFile);
|
||||
@@ -83,7 +68,7 @@ public class SessionPersistence {
|
||||
}
|
||||
|
||||
String json = Files.readString(sessionFile);
|
||||
SessionData data = JsonUtil.fromJson(json, SessionData.class);
|
||||
SessionData data = GSON.fromJson(json, SessionData.class);
|
||||
|
||||
if (data != null && !data.isExpired()) {
|
||||
logger.debug("Loaded session from: {}", sessionFile);
|
||||
@@ -95,25 +80,17 @@ public class SessionPersistence {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete session file
|
||||
*
|
||||
* @throws IOException if the session file exists but cannot be deleted
|
||||
*/
|
||||
public void delete() throws IOException {
|
||||
Files.deleteIfExists(sessionFile);
|
||||
logger.debug("Session deleted: {}", sessionFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize filename to be safe for all OS
|
||||
*/
|
||||
private String sanitizeFilename(String input) {
|
||||
return input.replaceAll("[^a-zA-Z0-9.-]", "_");
|
||||
}
|
||||
|
||||
/**
|
||||
* Session data stored to disk
|
||||
* Session data stored to disk.
|
||||
*/
|
||||
public static class SessionData {
|
||||
public @Nullable String cookie;
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* API key model from the UniFi OS user management endpoints ({@code /proxy/users/api/v2/...}).
|
||||
* These keys are console-wide and can be used by any application (Protect, Access, Network).
|
||||
*
|
||||
* @author Dan Cunningham - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class UniFiApiKey {
|
||||
public @Nullable String id;
|
||||
public @Nullable String name;
|
||||
|
||||
@SerializedName("masked_api_key")
|
||||
public @Nullable String maskedApiKey;
|
||||
|
||||
@SerializedName("full_api_key")
|
||||
public @Nullable String fullApiKey;
|
||||
|
||||
@SerializedName("creator_user_id")
|
||||
public @Nullable String creatorUserId;
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* Manages API keys on a UniFi OS console via the {@code /proxy/users/api/v2/...} endpoints.
|
||||
* These endpoints are console-wide (not application-specific), so one manager can serve all
|
||||
* child bindings (Protect, Access, Network).
|
||||
*
|
||||
* @author Dan Cunningham - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public interface UniFiApiKeyManager {
|
||||
|
||||
String getUserId() throws UniFiException;
|
||||
|
||||
List<UniFiApiKey> listApiKeys(String userId) throws UniFiException;
|
||||
|
||||
UniFiApiKey createApiKey(String userId, String name) throws UniFiException;
|
||||
|
||||
void deleteApiKey(String keyId) throws UniFiException;
|
||||
|
||||
default @Nullable UniFiApiKey findApiKeyByName(String userId, String name) throws UniFiException {
|
||||
return listApiKeys(userId).stream().filter(k -> name.equals(k.name)).findFirst().orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures an API key with the given name exists, deleting and recreating it if necessary.
|
||||
* Always recreates because the console only returns the full plaintext key at creation time.
|
||||
*/
|
||||
default UniFiApiKey ensureApiKey(String userId, String name) throws UniFiException {
|
||||
UniFiApiKey existing = findApiKeyByName(userId, name);
|
||||
if (existing != null) {
|
||||
String existingId = existing.id;
|
||||
if (existingId != null) {
|
||||
deleteApiKey(existingId);
|
||||
}
|
||||
}
|
||||
return createApiKey(userId, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-provisions an API key for a child binding, returning the full plaintext token.
|
||||
* Handles the complete flow: resolve user ID, ensure key exists, return token.
|
||||
*
|
||||
* @param keyName the name for the API key (e.g. "openHAB-protect-myNvr")
|
||||
* @return the full plaintext API token
|
||||
* @throws UniFiException if provisioning fails
|
||||
*/
|
||||
default String provisionApiToken(String keyName) throws UniFiException {
|
||||
String userId = getUserId();
|
||||
UniFiApiKey key = ensureApiKey(userId, keyName);
|
||||
String token = key.fullApiKey;
|
||||
if (token == null || token.isBlank()) {
|
||||
throw new UniFiException("API key '" + keyName + "' was created but fullApiKey is empty");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.eclipse.jetty.client.api.ContentResponse;
|
||||
import org.eclipse.jetty.client.api.Request;
|
||||
import org.eclipse.jetty.client.util.StringContentProvider;
|
||||
import org.eclipse.jetty.http.HttpMethod;
|
||||
import org.eclipse.jetty.http.HttpStatus;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
/**
|
||||
* Implementation of {@link UniFiApiKeyManager} that talks to the UniFi OS user management
|
||||
* endpoints using an already-authenticated {@link UniFiSession}.
|
||||
*
|
||||
* @author Dan Cunningham - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class UniFiApiKeyManagerImpl implements UniFiApiKeyManager {
|
||||
|
||||
@FunctionalInterface
|
||||
private interface ApiCall<T> {
|
||||
T execute() throws Exception;
|
||||
}
|
||||
|
||||
private static final long REQUEST_TIMEOUT_SECONDS = 30;
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(UniFiApiKeyManagerImpl.class);
|
||||
private final HttpClient httpClient;
|
||||
private final UniFiSession session;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
private volatile @Nullable String cachedUserId;
|
||||
|
||||
public UniFiApiKeyManagerImpl(HttpClient httpClient, UniFiSession session) {
|
||||
this.httpClient = httpClient;
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUserId() throws UniFiException {
|
||||
String uid = cachedUserId;
|
||||
if (uid != null) {
|
||||
return uid;
|
||||
}
|
||||
return wrapExceptions("getUserId", () -> {
|
||||
ContentResponse response = doGet(session.getBaseUrl() + "/api/users/self", "GET /api/users/self");
|
||||
JsonObject json = JsonParser.parseString(response.getContentAsString()).getAsJsonObject();
|
||||
String id = json.has("id") ? json.get("id").getAsString() : null;
|
||||
if (id == null || id.isBlank()) {
|
||||
throw new UniFiException("User ID not found in /api/users/self response");
|
||||
}
|
||||
cachedUserId = id;
|
||||
logger.debug("Fetched user ID: {}", id);
|
||||
return id;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<UniFiApiKey> listApiKeys(String userId) throws UniFiException {
|
||||
return wrapExceptions("listApiKeys", () -> {
|
||||
String url = session.getBaseUrl() + "/proxy/users/api/v2/user/" + userId + "/keys";
|
||||
ContentResponse response = doGet(url, "listApiKeys");
|
||||
JsonObject root = JsonParser.parseString(response.getContentAsString()).getAsJsonObject();
|
||||
JsonElement data = root.get("data");
|
||||
if (data == null || !data.isJsonArray()) {
|
||||
return List.of();
|
||||
}
|
||||
UniFiApiKey[] keys = gson.fromJson(data, UniFiApiKey[].class);
|
||||
return keys != null ? List.of(keys) : List.of();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public UniFiApiKey createApiKey(String userId, String name) throws UniFiException {
|
||||
return wrapExceptions("createApiKey", () -> {
|
||||
String url = session.getBaseUrl() + "/proxy/users/api/v2/user/" + userId + "/keys";
|
||||
String body = gson.toJson(Map.of("name", name));
|
||||
ContentResponse response = doPost(url, body, "createApiKey");
|
||||
JsonObject root = JsonParser.parseString(response.getContentAsString()).getAsJsonObject();
|
||||
JsonElement data = root.get("data");
|
||||
if (data == null || !data.isJsonObject()) {
|
||||
throw new UniFiException("Unexpected response format from createApiKey");
|
||||
}
|
||||
UniFiApiKey key = gson.fromJson(data, UniFiApiKey.class);
|
||||
if (key == null) {
|
||||
throw new UniFiException("Failed to parse API key from response");
|
||||
}
|
||||
logger.debug("Created API key '{}' (id={})", name, key.id);
|
||||
return key;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteApiKey(String keyId) throws UniFiException {
|
||||
wrapExceptions("deleteApiKey", () -> {
|
||||
String url = session.getBaseUrl() + "/proxy/users/api/v2/keys/" + keyId;
|
||||
Request request = httpClient.newRequest(url).method(HttpMethod.DELETE).timeout(REQUEST_TIMEOUT_SECONDS,
|
||||
TimeUnit.SECONDS);
|
||||
session.addAuthHeaders(request);
|
||||
ContentResponse response = request.send();
|
||||
ensureSuccess(response, "DELETE /proxy/users/api/v2/keys/" + keyId);
|
||||
updateCsrfFromResponse(response);
|
||||
logger.debug("Deleted API key {}", keyId);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private <T> T wrapExceptions(String action, ApiCall<T> call) throws UniFiException {
|
||||
try {
|
||||
return call.execute();
|
||||
} catch (UniFiException e) {
|
||||
throw e;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new UniFiException("Interrupted during " + action, e);
|
||||
} catch (Exception e) {
|
||||
throw new UniFiException(action + " failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private ContentResponse doGet(String url, String action) throws Exception {
|
||||
Request request = httpClient.newRequest(url).method(HttpMethod.GET).timeout(REQUEST_TIMEOUT_SECONDS,
|
||||
TimeUnit.SECONDS);
|
||||
session.addAuthHeaders(request);
|
||||
ContentResponse response = request.send();
|
||||
ensureSuccess(response, action);
|
||||
updateCsrfFromResponse(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
private ContentResponse doPost(String url, String body, String action) throws Exception {
|
||||
Request request = httpClient.newRequest(url).method(HttpMethod.POST).timeout(REQUEST_TIMEOUT_SECONDS,
|
||||
TimeUnit.SECONDS);
|
||||
session.addAuthHeaders(request);
|
||||
request.header("Content-Type", "application/json");
|
||||
request.content(new StringContentProvider(body));
|
||||
ContentResponse response = request.send();
|
||||
ensureSuccess(response, action);
|
||||
updateCsrfFromResponse(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
private void ensureSuccess(ContentResponse response, String action) throws UniFiException {
|
||||
int status = response.getStatus();
|
||||
if (status == HttpStatus.UNAUTHORIZED_401) {
|
||||
throw new UniFiException(action + " returned 401 Unauthorized");
|
||||
}
|
||||
if (!HttpStatus.isSuccess(status)) {
|
||||
throw new UniFiException(action + " returned HTTP " + status);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateCsrfFromResponse(ContentResponse response) {
|
||||
String csrf = response.getHeaders().get("X-Updated-CSRF-Token");
|
||||
if (csrf != null && !csrf.isBlank()) {
|
||||
session.updateCsrfToken(csrf);
|
||||
}
|
||||
}
|
||||
}
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api;
|
||||
|
||||
import java.net.HttpCookie;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.eclipse.jetty.client.api.ContentResponse;
|
||||
import org.eclipse.jetty.client.api.Request;
|
||||
import org.eclipse.jetty.client.util.StringContentProvider;
|
||||
import org.eclipse.jetty.http.HttpMethod;
|
||||
import org.eclipse.jetty.http.HttpStatus;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiException.AuthState;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
/**
|
||||
* Handles authentication against a UniFi console. Supports both the modern UniFiOS endpoint
|
||||
* ({@code /api/auth/login}) and the legacy controller endpoint ({@code /api/login}) via the {@code unifios} flag.
|
||||
* Captures the session cookie (TOKEN/UOS_TOKEN on UniFi OS, unifises on the legacy controller) and CSRF token
|
||||
* returned by login, persists them via {@link SessionPersistence} when enabled, and applies them as headers on
|
||||
* outbound Jetty {@link Request}s via {@link #addAuthHeaders(Request)}.
|
||||
* <p>
|
||||
* Forked from the UniFi Protect binding's {@code UniFiProtectAuthenticator}; adapted to handle the unifios flag
|
||||
* and to throw the public {@link UniFiException} so child bindings only see one exception type.
|
||||
*
|
||||
* @author Dan Cunningham - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class UniFiAuthenticator {
|
||||
|
||||
private static final String UNIFIOS_AUTH_PATH = "/api/auth/login";
|
||||
private static final String LEGACY_AUTH_PATH = "/api/login";
|
||||
private static final Duration SESSION_EXPIRY = Duration.ofHours(24);
|
||||
private static final long REQUEST_TIMEOUT_SECONDS = 30;
|
||||
private static final String COOKIE_TOKEN = "TOKEN";
|
||||
private static final String COOKIE_UOS_TOKEN = "UOS_TOKEN";
|
||||
private static final String COOKIE_UNIFISES = "unifises";
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(UniFiAuthenticator.class);
|
||||
|
||||
private final HttpClient httpClient;
|
||||
private final Executor executor;
|
||||
private final String baseUrl;
|
||||
private final String username;
|
||||
private final String password;
|
||||
private final boolean unifios;
|
||||
private final @Nullable SessionPersistence sessionPersistence;
|
||||
|
||||
private volatile @Nullable String authCookie;
|
||||
private volatile @Nullable String csrfToken;
|
||||
|
||||
public UniFiAuthenticator(HttpClient httpClient, Executor executor, String baseUrl, String username,
|
||||
String password, boolean unifios, boolean enableSessionPersistence) {
|
||||
this.httpClient = httpClient;
|
||||
this.executor = executor;
|
||||
this.baseUrl = baseUrl;
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.unifios = unifios;
|
||||
|
||||
SessionPersistence tempPersistence = null;
|
||||
if (enableSessionPersistence) {
|
||||
try {
|
||||
tempPersistence = new SessionPersistence(baseUrl, username);
|
||||
SessionPersistence.SessionData sessionData = tempPersistence.load();
|
||||
if (sessionData != null) {
|
||||
this.authCookie = sessionData.cookie;
|
||||
this.csrfToken = sessionData.csrfToken;
|
||||
logger.debug("Loaded saved session for {}", username);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.debug("Failed to initialize or load session persistence: {}", e.getMessage(), e);
|
||||
tempPersistence = null;
|
||||
}
|
||||
}
|
||||
this.sessionPersistence = tempPersistence;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a fresh login against the console. Completes when the session cookie and CSRF token have been
|
||||
* captured from the response.
|
||||
*/
|
||||
public CompletableFuture<Void> authenticate() {
|
||||
return CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
doAuthenticate();
|
||||
} catch (UniFiException e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
}, executor);
|
||||
}
|
||||
|
||||
private void doAuthenticate() throws UniFiException {
|
||||
try {
|
||||
logger.debug("Authenticating with UniFi console as {}", username);
|
||||
|
||||
JsonObject loginBody = new JsonObject();
|
||||
loginBody.addProperty("username", username);
|
||||
loginBody.addProperty("password", password);
|
||||
loginBody.addProperty("rememberMe", sessionPersistence != null);
|
||||
|
||||
String url = baseUrl + (unifios ? UNIFIOS_AUTH_PATH : LEGACY_AUTH_PATH);
|
||||
Request request = httpClient.newRequest(url).method(HttpMethod.POST)
|
||||
.header("Content-Type", "application/json").content(new StringContentProvider(loginBody.toString()))
|
||||
.timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
|
||||
ContentResponse response = request.send();
|
||||
|
||||
int status = response.getStatus();
|
||||
if (status == HttpStatus.UNAUTHORIZED_401) {
|
||||
throw new UniFiException("Authentication rejected by console (HTTP " + status + ")",
|
||||
AuthState.REJECTED);
|
||||
}
|
||||
if (status == HttpStatus.FORBIDDEN_403 || status == HttpStatus.TOO_MANY_REQUESTS_429) {
|
||||
throw new UniFiException("Authentication throttled by console (HTTP " + status + ")",
|
||||
AuthState.THROTTLED);
|
||||
}
|
||||
if (status != HttpStatus.OK_200) {
|
||||
throw new UniFiException("Authentication failed: " + status + " " + response.getReason());
|
||||
}
|
||||
|
||||
String csrfHeader = response.getHeaders().get("x-csrf-token");
|
||||
if (csrfHeader != null) {
|
||||
this.csrfToken = csrfHeader;
|
||||
logger.debug("Got CSRF token from login response");
|
||||
}
|
||||
|
||||
List<String> setCookieHeaders = response.getHeaders().getValuesList("Set-Cookie");
|
||||
if (setCookieHeaders != null && !setCookieHeaders.isEmpty()) {
|
||||
for (String setCookieHeader : setCookieHeaders) {
|
||||
parseCookie(setCookieHeader);
|
||||
}
|
||||
logger.debug("Got auth cookie(s) from {} Set-Cookie header(s)", setCookieHeaders.size());
|
||||
}
|
||||
|
||||
if (authCookie == null) {
|
||||
throw new UniFiException("No authentication cookie received from console");
|
||||
}
|
||||
|
||||
logger.debug("Successfully authenticated as {}", username);
|
||||
|
||||
SessionPersistence persistence = sessionPersistence;
|
||||
if (persistence != null) {
|
||||
try {
|
||||
SessionPersistence.SessionData sessionData = new SessionPersistence.SessionData(authCookie,
|
||||
csrfToken, Instant.now().plus(SESSION_EXPIRY));
|
||||
persistence.save(sessionData);
|
||||
} catch (Exception e) {
|
||||
logger.debug("Failed to save session to disk: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new UniFiException("Authentication interrupted", e);
|
||||
} catch (TimeoutException | ExecutionException e) {
|
||||
throw new UniFiException("Authentication failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void parseCookie(String setCookieHeader) {
|
||||
try {
|
||||
List<HttpCookie> cookies = HttpCookie.parse(setCookieHeader);
|
||||
for (HttpCookie cookie : cookies) {
|
||||
String name = cookie.getName();
|
||||
if (COOKIE_TOKEN.equals(name) || COOKIE_UOS_TOKEN.equals(name) || COOKIE_UNIFISES.equals(name)) {
|
||||
this.authCookie = name + "=" + cookie.getValue();
|
||||
logger.debug("Parsed cookie: {}", name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.debug("Failed to parse cookie: {}", setCookieHeader, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the session cookie and CSRF token as headers on the given Jetty request.
|
||||
*/
|
||||
public void addAuthHeaders(Request request) {
|
||||
String cookie = authCookie;
|
||||
if (cookie != null) {
|
||||
request.header("Cookie", cookie);
|
||||
}
|
||||
String csrf = csrfToken;
|
||||
if (csrf != null) {
|
||||
request.header("x-csrf-token", csrf);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isAuthenticated() {
|
||||
return authCookie != null;
|
||||
}
|
||||
|
||||
public @Nullable String getAuthCookie() {
|
||||
return authCookie;
|
||||
}
|
||||
|
||||
public @Nullable String getCsrfToken() {
|
||||
return csrfToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the CSRF token. Called by the session/request wrapper whenever the console rotates the token via the
|
||||
* {@code X-CSRF-Token} response header.
|
||||
*/
|
||||
public void updateCsrfToken(String newToken) {
|
||||
this.csrfToken = newToken;
|
||||
}
|
||||
|
||||
public void clearAuth() {
|
||||
this.authCookie = null;
|
||||
this.csrfToken = null;
|
||||
|
||||
SessionPersistence persistence = sessionPersistence;
|
||||
if (persistence != null) {
|
||||
try {
|
||||
persistence.delete();
|
||||
} catch (Exception e) {
|
||||
logger.debug("Failed to delete saved session: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("Cleared authentication");
|
||||
}
|
||||
}
|
||||
+37
-7
@@ -13,27 +13,57 @@
|
||||
package org.openhab.binding.unifi.internal.api;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* The {@link UniFiException} represents a binding specific {@link Exception}.
|
||||
* Base exception thrown by the shared UniFi parent binding for errors talking to a UniFi console.
|
||||
* <p>
|
||||
* Subclasses (e.g. authentication failure, expired session, communication error) are defined in the internal
|
||||
* implementation and surface through this type so child bindings only need to handle a single exception class.
|
||||
*
|
||||
* @author Matthew Bowman - Initial contribution
|
||||
* @author Dan Cunningham - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class UniFiException extends Exception {
|
||||
|
||||
private static final long serialVersionUID = -7422254981644510570L;
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public UniFiException(final String message) {
|
||||
/**
|
||||
* Classification of authentication-related failures so callers can decide whether to retry.
|
||||
* <ul>
|
||||
* <li>{@link #OK} — not an auth error; retry-worthy (network, 5xx, timeout)</li>
|
||||
* <li>{@link #REJECTED} — credentials definitively rejected (HTTP 401); user must fix config</li>
|
||||
* <li>{@link #THROTTLED} — request forbidden (HTTP 403/429); treat as transient, back off longer</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum AuthState {
|
||||
OK,
|
||||
REJECTED,
|
||||
THROTTLED
|
||||
}
|
||||
|
||||
private final AuthState authState;
|
||||
|
||||
public UniFiException(String message) {
|
||||
super(message);
|
||||
this.authState = AuthState.OK;
|
||||
}
|
||||
|
||||
public UniFiException(final String message, final Throwable cause) {
|
||||
public UniFiException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.authState = AuthState.OK;
|
||||
}
|
||||
|
||||
public UniFiException(final @Nullable Throwable cause) {
|
||||
public UniFiException(Throwable cause) {
|
||||
super(cause);
|
||||
this.authState = AuthState.OK;
|
||||
}
|
||||
|
||||
public UniFiException(String message, AuthState authState) {
|
||||
super(message);
|
||||
this.authState = authState;
|
||||
}
|
||||
|
||||
public AuthState getAuthState() {
|
||||
return authState;
|
||||
}
|
||||
}
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* Token-bucket rate limiter for outbound requests against a UniFi console. Blocks the caller when the window is
|
||||
* full so bursts from multiple child bindings hitting the same console do not trip HTTP 429 or lock out the
|
||||
* underlying user account.
|
||||
* <p>
|
||||
* Ported from the per-bridge throttle originally implemented inside the UniFi Protect binding.
|
||||
*
|
||||
* @author Dan Cunningham - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class UniFiRequestThrottler {
|
||||
|
||||
private final int maxRequestsPerWindow;
|
||||
private final long windowNanos;
|
||||
private final Deque<Long> timestamps = new ArrayDeque<>();
|
||||
private final Object lock = new Object();
|
||||
|
||||
public UniFiRequestThrottler() {
|
||||
this(7, TimeUnit.SECONDS.toNanos(1));
|
||||
}
|
||||
|
||||
public UniFiRequestThrottler(int maxRequestsPerWindow, long windowNanos) {
|
||||
this.maxRequestsPerWindow = maxRequestsPerWindow;
|
||||
this.windowNanos = windowNanos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocks the calling thread until it is safe to issue another request. Records the timestamp of the granted
|
||||
* request so subsequent calls see an updated window.
|
||||
*
|
||||
* @throws InterruptedException if the thread is interrupted while waiting for a free slot
|
||||
*/
|
||||
public void acquire() throws InterruptedException {
|
||||
while (true) {
|
||||
long waitNanos;
|
||||
synchronized (lock) {
|
||||
long now = System.nanoTime();
|
||||
while (!timestamps.isEmpty() && now - timestamps.peekFirst() >= windowNanos) {
|
||||
timestamps.removeFirst();
|
||||
}
|
||||
if (timestamps.size() < maxRequestsPerWindow) {
|
||||
timestamps.addLast(now);
|
||||
return;
|
||||
}
|
||||
Long oldest = timestamps.peekFirst();
|
||||
waitNanos = (oldest + windowNanos) - now;
|
||||
}
|
||||
if (waitNanos > 0L) {
|
||||
TimeUnit.NANOSECONDS.sleep(waitNanos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.client.api.Request;
|
||||
|
||||
/**
|
||||
* An authenticated session against a UniFi console, shared across the UniFi Network, Protect, and Access child
|
||||
* bindings so all three families reuse a single {@code /api/auth/login} on a given console.
|
||||
* <p>
|
||||
* Obtained from a {@link org.openhab.binding.unifi.internal.handler.UniFiControllerBridgeHandler} via
|
||||
* {@code getSessionAsync()}. Implementations are responsible for CSRF-token rotation, 401 re-authentication, and
|
||||
* session persistence.
|
||||
*
|
||||
* @author Dan Cunningham - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public interface UniFiSession {
|
||||
|
||||
/**
|
||||
* @return the base URL of the UniFi console, e.g. {@code https://unifi.example.com:443}
|
||||
*/
|
||||
String getBaseUrl();
|
||||
|
||||
/**
|
||||
* Applies the session's authentication headers (Cookie, x-csrf-token) to the given Jetty HTTP request.
|
||||
*
|
||||
* @param request the request about to be sent
|
||||
*/
|
||||
void addAuthHeaders(Request request);
|
||||
|
||||
/**
|
||||
* @return the raw authentication cookie value (e.g. {@code TOKEN=...}) for use by WebSocket upgrade requests,
|
||||
* or {@code null} if the session is not currently authenticated
|
||||
*/
|
||||
@Nullable
|
||||
String getAuthCookie();
|
||||
|
||||
/**
|
||||
* @return the current CSRF token, or {@code null} if the session is not currently authenticated
|
||||
*/
|
||||
@Nullable
|
||||
String getCsrfToken();
|
||||
|
||||
/**
|
||||
* Trigger an explicit re-authentication. Useful when a child binding receives a 401 outside the normal HTTP
|
||||
* client path (for example, on a WebSocket upgrade failure).
|
||||
*
|
||||
* @return a future that completes when the session has been re-established
|
||||
*/
|
||||
CompletableFuture<Void> reauthenticate();
|
||||
|
||||
/**
|
||||
* Notify the session that the console rotated the CSRF token (typically found in the
|
||||
* {@code X-CSRF-Token} response header). Child bindings should call this from their request wrappers so
|
||||
* subsequent calls on any family binding see the new token.
|
||||
*
|
||||
* @param newToken the replacement CSRF token
|
||||
*/
|
||||
void updateCsrfToken(String newToken);
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.client.api.Request;
|
||||
|
||||
/**
|
||||
* Default {@link UniFiSession} implementation used by {@code UniFiControllerBridgeHandler}. Wraps a
|
||||
* {@link UniFiAuthenticator} and a {@link UniFiRequestThrottler}: the throttler is applied inside
|
||||
* {@link #addAuthHeaders(Request)} so that every caller (Network, Protect, Access) obeys the same rate limit
|
||||
* against a shared console, and the authenticator supplies the Cookie + CSRF headers.
|
||||
*
|
||||
* @author Dan Cunningham - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class UniFiSessionImpl implements UniFiSession {
|
||||
|
||||
private final String baseUrl;
|
||||
private final UniFiAuthenticator authenticator;
|
||||
private final UniFiRequestThrottler throttler;
|
||||
|
||||
public UniFiSessionImpl(String baseUrl, UniFiAuthenticator authenticator, UniFiRequestThrottler throttler) {
|
||||
this.baseUrl = baseUrl;
|
||||
this.authenticator = authenticator;
|
||||
this.throttler = throttler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getBaseUrl() {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addAuthHeaders(Request request) {
|
||||
try {
|
||||
throttler.acquire();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
authenticator.addAuthHeaders(request);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable String getAuthCookie() {
|
||||
return authenticator.getAuthCookie();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable String getCsrfToken() {
|
||||
return authenticator.getCsrfToken();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> reauthenticate() {
|
||||
authenticator.clearAuth();
|
||||
return authenticator.authenticate();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateCsrfToken(String newToken) {
|
||||
authenticator.updateCsrfToken(newToken);
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* Process-wide registry of authenticated {@link UniFiSession} instances, keyed by {@code (host, username)}.
|
||||
* <p>
|
||||
* When the shared {@code unifi:controller} bridge in the parent binding finishes logging in, it
|
||||
* {@link #register(String, String, UniFiSession) registers} its session here. The Network, Protect, and Access
|
||||
* child bindings then call {@link #lookup(String, String)} from their own API clients before creating a fresh
|
||||
* session via
|
||||
* {@link UniFiSessions#create(org.eclipse.jetty.client.HttpClient, java.util.concurrent.Executor, String, String, String, boolean, boolean)}.
|
||||
* If a matching session already exists — which is the typical case on a UniFi OS console (UDM/UDR/UNVR) where
|
||||
* one local user is shared across Network, Protect, and Access — the child binding reuses it and no extra
|
||||
* {@code POST /api/auth/login} is performed.
|
||||
* <p>
|
||||
* Ownership semantics: a session returned from {@link #lookup(String, String)} is <em>not owned</em> by the
|
||||
* caller. The owning {@code unifi:controller} bridge handler is responsible for the session's lifecycle and
|
||||
* re-authentication. Callers may read from it, stamp auth headers on outgoing requests, and trigger
|
||||
* re-authentication; they must not invalidate or replace it.
|
||||
*
|
||||
* @author Dan Cunningham - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public final class UniFiSessionRegistry {
|
||||
|
||||
private static final UniFiSessionRegistry INSTANCE = new UniFiSessionRegistry();
|
||||
|
||||
private final Map<String, UniFiSession> sessions = new ConcurrentHashMap<>();
|
||||
|
||||
private UniFiSessionRegistry() {
|
||||
}
|
||||
|
||||
public static UniFiSessionRegistry getInstance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a freshly-authenticated session. Called by the parent {@code unifi:controller} bridge handler
|
||||
* after its initial login completes. Replaces any prior registration for the same {@code (host, username)}.
|
||||
*/
|
||||
public void register(String host, String username, UniFiSession session) {
|
||||
sessions.put(key(host, username), session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister the session for the given {@code (host, username)}. Called by the parent bridge handler on
|
||||
* dispose so child bindings fall back to creating their own session if the controller bridge goes away.
|
||||
*/
|
||||
public void unregister(String host, String username) {
|
||||
sessions.remove(key(host, username));
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a registered session for the given {@code (host, username)}. Returns {@code null} if no
|
||||
* {@code unifi:controller} bridge is currently online for this console/user combination, in which case the
|
||||
* caller should fall back to creating its own session.
|
||||
*/
|
||||
@Nullable
|
||||
public UniFiSession lookup(String host, String username) {
|
||||
return sessions.get(key(host, username));
|
||||
}
|
||||
|
||||
private static String key(String host, String username) {
|
||||
return host.toLowerCase(Locale.ROOT) + "|" + username;
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
|
||||
/**
|
||||
* Factory for creating standalone {@link UniFiSession} instances that apply the same auth / CSRF / throttle /
|
||||
* persistence policy as the parent {@code unifi:controller} bridge.
|
||||
* <p>
|
||||
* Child bindings that need to talk directly to a UniFi console (for example the UniFi Protect legacy
|
||||
* {@code unifiprotect:nvr} bridge when not yet migrated to {@code unifi:controller}) obtain a session via this
|
||||
* factory instead of rolling their own login. Each session gets its own {@link UniFiRequestThrottler} configured
|
||||
* with the same 7-req/s policy as the parent — these limiters are NOT shared across sessions, so callers that
|
||||
* create multiple sessions for the same console should prefer reusing the parent bridge's session via
|
||||
* {@link org.openhab.binding.unifi.internal.handler.UniFiControllerBridgeHandler#getSessionAsync()} to stay under the
|
||||
* aggregate rate limit. Sessions persist their auth cookie + CSRF token to {@code $OH_USERDATA/cache/unifi/} and
|
||||
* transparently re-authenticate on 401.
|
||||
*
|
||||
* @author Dan Cunningham - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public final class UniFiSessions {
|
||||
|
||||
private UniFiSessions() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new {@link UniFiSession} and perform the initial login against the console.
|
||||
*
|
||||
* @param httpClient the Jetty HTTP client to use (must already be started)
|
||||
* @param executor executor used for async auth work
|
||||
* @param baseUrl the console base URL, for example {@code https://192.168.1.1:443}
|
||||
* @param username local console user
|
||||
* @param password local console password
|
||||
* @param unifios {@code true} for modern UniFiOS consoles ({@code /api/auth/login}), {@code false} for legacy
|
||||
* controllers ({@code /api/login})
|
||||
* @param enableSessionPersistence whether to persist the session cookie to disk so the binding does not
|
||||
* re-login after an openHAB restart
|
||||
* @return a future that completes with the authenticated session once login succeeds
|
||||
*/
|
||||
public static CompletableFuture<UniFiSession> create(HttpClient httpClient, Executor executor, String baseUrl,
|
||||
String username, String password, boolean unifios, boolean enableSessionPersistence) {
|
||||
UniFiAuthenticator authenticator = new UniFiAuthenticator(httpClient, executor, baseUrl, username, password,
|
||||
unifios, enableSessionPersistence);
|
||||
UniFiRequestThrottler throttler = new UniFiRequestThrottler();
|
||||
UniFiSessionImpl session = new UniFiSessionImpl(baseUrl, authenticator, throttler);
|
||||
return authenticator.authenticate().thenApply(ignored -> session);
|
||||
}
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.discovery;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.eclipse.jetty.client.api.ContentResponse;
|
||||
import org.eclipse.jetty.client.api.Request;
|
||||
import org.eclipse.jetty.http.HttpMethod;
|
||||
import org.eclipse.jetty.http.HttpStatus;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiSession;
|
||||
import org.openhab.binding.unifi.internal.handler.UniFiControllerBridgeHandler;
|
||||
import org.openhab.core.config.discovery.AbstractThingHandlerDiscoveryService;
|
||||
import org.openhab.core.config.discovery.DiscoveryResult;
|
||||
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
import org.openhab.core.thing.ThingUID;
|
||||
import org.openhab.core.thing.binding.ThingHandler;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
import org.osgi.service.component.annotations.ServiceScope;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
/**
|
||||
* Discovers which UniFi apps (Network, Protect, Access) are installed on the console behind a
|
||||
* {@code unifi:controller} bridge and emits inbox entries for the corresponding child bridges. Attached as a
|
||||
* {@link ThingHandler} service so the framework starts and stops it with the controller bridge lifecycle.
|
||||
*
|
||||
* @author Dan Cunningham - Initial contribution
|
||||
*/
|
||||
@Component(scope = ServiceScope.PROTOTYPE, service = UniFiAppDiscoveryService.class)
|
||||
@NonNullByDefault
|
||||
public class UniFiAppDiscoveryService extends AbstractThingHandlerDiscoveryService<UniFiControllerBridgeHandler> {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(UniFiAppDiscoveryService.class);
|
||||
|
||||
private static final ThingTypeUID ACCESS_BRIDGE = new ThingTypeUID("unifi", "bridge");
|
||||
private static final ThingTypeUID PROTECT_NVR = new ThingTypeUID("unifi", "nvr");
|
||||
private static final ThingTypeUID NETWORK_SITE = new ThingTypeUID("unifi", "site");
|
||||
|
||||
private static final Set<ThingTypeUID> SUPPORTED_TYPES = Set.of(ACCESS_BRIDGE, PROTECT_NVR, NETWORK_SITE);
|
||||
private static final int SCAN_TIMEOUT_SECONDS = 15;
|
||||
private static final long PROBE_TIMEOUT_MS = 5_000;
|
||||
|
||||
private static final String ACCESS_PROBE_PATH = "/proxy/access/api/v2/devices/topology4";
|
||||
private static final String PROTECT_PROBE_PATH = "/proxy/protect/api/bootstrap";
|
||||
private static final String NETWORK_SITES_PATH = "/proxy/network/api/self/sites";
|
||||
|
||||
public UniFiAppDiscoveryService() {
|
||||
super(UniFiControllerBridgeHandler.class, SUPPORTED_TYPES, SCAN_TIMEOUT_SECONDS, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setThingHandler(ThingHandler handler) {
|
||||
if (handler instanceof UniFiControllerBridgeHandler controller) {
|
||||
this.thingHandler = controller;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void startScan() {
|
||||
removeOlderResults(getTimestampOfLastScan());
|
||||
UniFiControllerBridgeHandler controller = thingHandler;
|
||||
UniFiSession session;
|
||||
try {
|
||||
session = controller.getSessionAsync().get(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS);
|
||||
} catch (Exception e) {
|
||||
logger.debug("Discovery skipped: controller session not available: {}", e.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
ThingUID bridgeUID = controller.getThing().getUID();
|
||||
HttpClient httpClient = controller.getHttpClient();
|
||||
String baseUrl = session.getBaseUrl();
|
||||
|
||||
probeAccess(httpClient, session, baseUrl, bridgeUID);
|
||||
probeProtect(httpClient, session, baseUrl, bridgeUID);
|
||||
probeNetwork(httpClient, session, baseUrl, bridgeUID);
|
||||
}
|
||||
|
||||
private void probeAccess(HttpClient httpClient, UniFiSession session, String baseUrl, ThingUID bridgeUID) {
|
||||
int status = probe(httpClient, session, baseUrl + ACCESS_PROBE_PATH, "Access");
|
||||
if (status == HttpStatus.OK_200) {
|
||||
ThingUID uid = new ThingUID(ACCESS_BRIDGE, bridgeUID, "bridge");
|
||||
DiscoveryResult result = DiscoveryResultBuilder.create(uid).withBridge(bridgeUID)
|
||||
.withThingType(ACCESS_BRIDGE).withLabel("UniFi Access (" + bridgeUID.getId() + ")").build();
|
||||
thingDiscovered(result);
|
||||
}
|
||||
}
|
||||
|
||||
private void probeProtect(HttpClient httpClient, UniFiSession session, String baseUrl, ThingUID bridgeUID) {
|
||||
int status = probe(httpClient, session, baseUrl + PROTECT_PROBE_PATH, "Protect");
|
||||
if (status == HttpStatus.OK_200) {
|
||||
ThingUID uid = new ThingUID(PROTECT_NVR, bridgeUID, "nvr");
|
||||
DiscoveryResult result = DiscoveryResultBuilder.create(uid).withBridge(bridgeUID).withThingType(PROTECT_NVR)
|
||||
.withLabel("UniFi Protect (" + bridgeUID.getId() + ")").build();
|
||||
thingDiscovered(result);
|
||||
}
|
||||
}
|
||||
|
||||
private void probeNetwork(HttpClient httpClient, UniFiSession session, String baseUrl, ThingUID bridgeUID) {
|
||||
ContentResponse resp = sendAuthenticated(httpClient, session, baseUrl + NETWORK_SITES_PATH, "Network");
|
||||
if (resp == null || resp.getStatus() != HttpStatus.OK_200) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
JsonObject root = JsonParser.parseString(resp.getContentAsString()).getAsJsonObject();
|
||||
JsonArray data = root.getAsJsonArray("data");
|
||||
if (data == null) {
|
||||
return;
|
||||
}
|
||||
for (JsonElement el : data) {
|
||||
if (!el.isJsonObject()) {
|
||||
continue;
|
||||
}
|
||||
JsonObject site = el.getAsJsonObject();
|
||||
String name = stringOrNull(site, "name");
|
||||
String desc = stringOrNull(site, "desc");
|
||||
if (name == null) {
|
||||
continue;
|
||||
}
|
||||
ThingUID uid = new ThingUID(NETWORK_SITE, bridgeUID, name);
|
||||
DiscoveryResult result = DiscoveryResultBuilder.create(uid).withBridge(bridgeUID)
|
||||
.withThingType(NETWORK_SITE).withProperties(Map.of("sid", name))
|
||||
.withRepresentationProperty("sid")
|
||||
.withLabel("UniFi Site: " + (desc != null && !desc.isBlank() ? desc : name)).build();
|
||||
thingDiscovered(result);
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
logger.debug("Failed to parse Network sites response: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private int probe(HttpClient httpClient, UniFiSession session, String url, String app) {
|
||||
ContentResponse resp = sendAuthenticated(httpClient, session, url, app);
|
||||
return resp == null ? -1 : resp.getStatus();
|
||||
}
|
||||
|
||||
private @Nullable ContentResponse sendAuthenticated(HttpClient httpClient, UniFiSession session, String url,
|
||||
String app) {
|
||||
try {
|
||||
Request request = httpClient.newRequest(url).method(HttpMethod.GET).timeout(PROBE_TIMEOUT_MS,
|
||||
TimeUnit.MILLISECONDS);
|
||||
session.addAuthHeaders(request);
|
||||
ContentResponse resp = request.send();
|
||||
logger.debug("Probe for {} returned {}", app, resp.getStatus());
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
logger.debug("Probe for {} failed: {}", app, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static @Nullable String stringOrNull(JsonObject obj, String key) {
|
||||
JsonElement el = obj.get(key);
|
||||
if (el == null || el.isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
return el.getAsString();
|
||||
}
|
||||
}
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.handler;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.eclipse.jetty.client.WWWAuthenticationProtocolHandler;
|
||||
import org.eclipse.jetty.util.ssl.SslContextFactory;
|
||||
import org.openhab.binding.unifi.internal.UniFiBindingConstants;
|
||||
import org.openhab.binding.unifi.internal.UniFiControllerConfiguration;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiApiKeyManager;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiApiKeyManagerImpl;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiAuthenticator;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiException;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiException.AuthState;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiRequestThrottler;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiSession;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiSessionImpl;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiSessionRegistry;
|
||||
import org.openhab.binding.unifi.internal.discovery.UniFiAppDiscoveryService;
|
||||
import org.openhab.core.io.net.http.HttpClientFactory;
|
||||
import org.openhab.core.thing.Bridge;
|
||||
import org.openhab.core.thing.ChannelUID;
|
||||
import org.openhab.core.thing.ThingStatus;
|
||||
import org.openhab.core.thing.ThingStatusDetail;
|
||||
import org.openhab.core.thing.binding.BaseBridgeHandler;
|
||||
import org.openhab.core.thing.binding.ThingHandlerService;
|
||||
import org.openhab.core.types.Command;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Handler for the shared {@code unifi:controller} bridge. Owns a per-bridge Jetty {@link HttpClient} (configured
|
||||
* to accept self-signed console certificates), logs in via {@link UniFiAuthenticator}, and publishes a shared
|
||||
* {@link UniFiSession} that the Network, Protect, and Access child bindings consume by casting
|
||||
* {@code getBridge().getHandler()} to this type and calling {@link #getSessionAsync()}.
|
||||
*
|
||||
* @author Dan Cunningham - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class UniFiControllerBridgeHandler extends BaseBridgeHandler {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(UniFiControllerBridgeHandler.class);
|
||||
|
||||
private static final int MAX_RECONNECT_DELAY_SECONDS = 300;
|
||||
private static final int THROTTLED_INITIAL_DELAY_SECONDS = 60;
|
||||
private static final int THROTTLED_MAX_DELAY_SECONDS = 1800;
|
||||
|
||||
private final HttpClientFactory httpClientFactory;
|
||||
|
||||
private volatile @Nullable HttpClient httpClient;
|
||||
private volatile @Nullable UniFiSession session;
|
||||
private volatile @Nullable UniFiSessionImpl sessionImpl;
|
||||
private volatile @Nullable UniFiApiKeyManager apiKeyManager;
|
||||
private volatile @Nullable UniFiAuthenticator authenticator;
|
||||
private CompletableFuture<UniFiSession> sessionFuture = new CompletableFuture<>();
|
||||
private UniFiControllerConfiguration config = new UniFiControllerConfiguration();
|
||||
private @Nullable ScheduledFuture<?> reconnectTask;
|
||||
private volatile boolean shuttingDown = false;
|
||||
private int reconnectAttempt = 0;
|
||||
private int throttledReconnectAttempt = 0;
|
||||
|
||||
public UniFiControllerBridgeHandler(Bridge bridge, HttpClientFactory httpClientFactory) {
|
||||
super(bridge);
|
||||
this.httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initialize() {
|
||||
config = getConfigAs(UniFiControllerConfiguration.class);
|
||||
logger.debug("Initializing UniFi controller bridge for host {}", config.host);
|
||||
|
||||
shuttingDown = false;
|
||||
reconnectAttempt = 0;
|
||||
throttledReconnectAttempt = 0;
|
||||
|
||||
if (config.host.isBlank() || config.username.isBlank() || config.password.isBlank()) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||
"Host, username, and password are required");
|
||||
sessionFuture.completeExceptionally(new UniFiException("Missing configuration"));
|
||||
return;
|
||||
}
|
||||
|
||||
HttpClient client = httpClientFactory.createHttpClient(UniFiBindingConstants.BINDING_ID,
|
||||
new SslContextFactory.Client(true));
|
||||
try {
|
||||
client.start();
|
||||
} catch (Exception e) {
|
||||
logger.debug("Failed to start HTTP client for {}: {}", thing.getUID(), e.getMessage(), e);
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||
"Failed to start HTTP client: " + e.getMessage());
|
||||
sessionFuture.completeExceptionally(new UniFiException("Failed to start HTTP client", e));
|
||||
return;
|
||||
}
|
||||
// The console answers some requests with a 401 that carries no WWW-Authenticate header (seen on
|
||||
// recent UniFi OS / Protect firmware) which Jetty intercepts and throws an exception before we can process
|
||||
// Must be after start(): doStart() registers the default protocol handlers.
|
||||
client.getProtocolHandlers().remove(WWWAuthenticationProtocolHandler.NAME);
|
||||
httpClient = client;
|
||||
|
||||
String baseUrl = "https://" + config.host + ":" + config.port;
|
||||
UniFiAuthenticator auth = new UniFiAuthenticator(client, scheduler, baseUrl, config.username, config.password,
|
||||
config.unifios, true);
|
||||
authenticator = auth;
|
||||
UniFiRequestThrottler throttler = new UniFiRequestThrottler();
|
||||
sessionImpl = new UniFiSessionImpl(baseUrl, auth, throttler);
|
||||
|
||||
updateStatus(ThingStatus.UNKNOWN);
|
||||
attemptConnect();
|
||||
}
|
||||
|
||||
private void attemptConnect() {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
UniFiAuthenticator auth = authenticator;
|
||||
UniFiSessionImpl impl = sessionImpl;
|
||||
HttpClient client = httpClient;
|
||||
if (auth == null || impl == null || client == null) {
|
||||
return;
|
||||
}
|
||||
auth.authenticate().whenComplete((result, error) -> {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
if (error == null) {
|
||||
reconnectAttempt = 0;
|
||||
throttledReconnectAttempt = 0;
|
||||
session = impl;
|
||||
apiKeyManager = new UniFiApiKeyManagerImpl(client, impl);
|
||||
UniFiSessionRegistry.getInstance().register(config.host, config.username, impl);
|
||||
if (!sessionFuture.isDone()) {
|
||||
sessionFuture.complete(impl);
|
||||
}
|
||||
updateStatus(ThingStatus.ONLINE);
|
||||
} else {
|
||||
logger.debug("Authentication to {} failed: {}", config.host, error.getMessage());
|
||||
handleAuthFailure(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handleAuthFailure(Throwable error) {
|
||||
Throwable cause = error;
|
||||
while (cause instanceof CompletionException) {
|
||||
Throwable inner = cause.getCause();
|
||||
if (inner == null) {
|
||||
break;
|
||||
}
|
||||
cause = inner;
|
||||
}
|
||||
AuthState authState = cause instanceof UniFiException ue ? ue.getAuthState() : AuthState.OK;
|
||||
switch (authState) {
|
||||
case REJECTED:
|
||||
if (!sessionFuture.isDone()) {
|
||||
sessionFuture.completeExceptionally(cause);
|
||||
}
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||
"@text/offline.conf-error-auth-rejected");
|
||||
return;
|
||||
case THROTTLED:
|
||||
scheduleReconnect(true);
|
||||
return;
|
||||
default:
|
||||
scheduleReconnect(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void scheduleReconnect(boolean throttled) {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
ScheduledFuture<?> existing = reconnectTask;
|
||||
if (existing != null && !existing.isDone()) {
|
||||
// Throttled supersedes a pending fast reconnect; otherwise let the existing one run.
|
||||
if (throttled) {
|
||||
existing.cancel(false);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
int delay;
|
||||
if (throttled) {
|
||||
delay = Math.min((int) Math.pow(2, throttledReconnectAttempt) * THROTTLED_INITIAL_DELAY_SECONDS,
|
||||
THROTTLED_MAX_DELAY_SECONDS);
|
||||
throttledReconnectAttempt++;
|
||||
logger.debug("Scheduling reconnect in {} seconds (throttled, attempt {})", delay,
|
||||
throttledReconnectAttempt);
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/offline.login-throttled");
|
||||
} else {
|
||||
delay = Math.min((int) Math.pow(2, reconnectAttempt) * 5, MAX_RECONNECT_DELAY_SECONDS);
|
||||
reconnectAttempt++;
|
||||
logger.debug("Scheduling reconnect in {} seconds (attempt {})", delay, reconnectAttempt);
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||
"@text/offline.comm-error-retrying");
|
||||
}
|
||||
reconnectTask = scheduler.schedule(this::attemptConnect, delay, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
logger.debug("Disposing UniFi controller bridge for host {}", config.host);
|
||||
shuttingDown = true;
|
||||
ScheduledFuture<?> task = reconnectTask;
|
||||
if (task != null) {
|
||||
task.cancel(false);
|
||||
reconnectTask = null;
|
||||
}
|
||||
if (!config.host.isBlank() && !config.username.isBlank()) {
|
||||
UniFiSessionRegistry.getInstance().unregister(config.host, config.username);
|
||||
}
|
||||
UniFiAuthenticator auth = authenticator;
|
||||
if (auth != null) {
|
||||
auth.clearAuth();
|
||||
authenticator = null;
|
||||
}
|
||||
sessionImpl = null;
|
||||
apiKeyManager = null;
|
||||
HttpClient client = httpClient;
|
||||
if (client != null) {
|
||||
try {
|
||||
client.stop();
|
||||
} catch (Exception e) {
|
||||
logger.debug("Error stopping HTTP client for {}: {}", thing.getUID(), e.getMessage());
|
||||
}
|
||||
httpClient = null;
|
||||
}
|
||||
session = null;
|
||||
if (!sessionFuture.isDone()) {
|
||||
sessionFuture.completeExceptionally(new IllegalStateException("Bridge disposed"));
|
||||
}
|
||||
sessionFuture = new CompletableFuture<>();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleCommand(ChannelUID channelUID, Command command) {
|
||||
// The controller bridge has no channels; nothing to handle.
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Class<? extends ThingHandlerService>> getServices() {
|
||||
return List.of(UniFiAppDiscoveryService.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return a future that completes with the authenticated {@link UniFiSession} once the bridge has finished
|
||||
* logging in. Child handlers should prefer this over {@link #getSession()} because it lets them wait
|
||||
* cleanly for the initial login to complete.
|
||||
*/
|
||||
public CompletableFuture<UniFiSession> getSessionAsync() {
|
||||
return sessionFuture;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the current authenticated session, or {@code null} if the bridge has not yet finished initializing
|
||||
* or is offline. Prefer {@link #getSessionAsync()} unless you already know the bridge is online.
|
||||
*/
|
||||
public @Nullable UniFiSession getSession() {
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the Jetty {@link HttpClient} configured by the bridge (including the trust-all SSL context for
|
||||
* self-signed console certificates). Child bindings should use this client for all REST and WebSocket
|
||||
* requests against the console, not create their own.
|
||||
*/
|
||||
public HttpClient getHttpClient() {
|
||||
HttpClient client = httpClient;
|
||||
if (client == null) {
|
||||
throw new IllegalStateException("HTTP client requested before bridge initialization");
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return a scheduled executor that child bindings can use for their own polling loops, WebSocket keepalives,
|
||||
* etc. Backed by the bridge handler's scheduler.
|
||||
*/
|
||||
public ScheduledExecutorService getScheduler() {
|
||||
return scheduler;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the hostname or IP address of the UniFi console this bridge is connected to.
|
||||
*/
|
||||
public String getHost() {
|
||||
return config.host;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the TCP port of the UniFi console this bridge is connected to.
|
||||
*/
|
||||
public int getPort() {
|
||||
return config.port;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the {@link UniFiApiKeyManager} for managing console-wide API keys, or {@code null} if the bridge
|
||||
* is not yet authenticated.
|
||||
*/
|
||||
public @Nullable UniFiApiKeyManager getApiKeyManager() {
|
||||
return apiKeyManager;
|
||||
}
|
||||
}
|
||||
-208
@@ -1,208 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.handler;
|
||||
|
||||
import static org.openhab.core.thing.ThingStatus.OFFLINE;
|
||||
import static org.openhab.core.thing.ThingStatus.ONLINE;
|
||||
import static org.openhab.core.thing.ThingStatus.UNKNOWN;
|
||||
import static org.openhab.core.thing.ThingStatusDetail.COMMUNICATION_ERROR;
|
||||
import static org.openhab.core.thing.ThingStatusDetail.CONFIGURATION_ERROR;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.openhab.binding.unifi.internal.UniFiControllerThingConfig;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiCommunicationException;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiController;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiException;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiInvalidCredentialsException;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiInvalidHostException;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiSSLException;
|
||||
import org.openhab.core.thing.Bridge;
|
||||
import org.openhab.core.thing.ChannelUID;
|
||||
import org.openhab.core.thing.ThingStatus;
|
||||
import org.openhab.core.thing.ThingStatusDetail;
|
||||
import org.openhab.core.thing.ThingStatusInfo;
|
||||
import org.openhab.core.thing.binding.BaseBridgeHandler;
|
||||
import org.openhab.core.thing.binding.ThingHandler;
|
||||
import org.openhab.core.thing.binding.ThingHandlerService;
|
||||
import org.openhab.core.thing.binding.builder.ThingStatusInfoBuilder;
|
||||
import org.openhab.core.types.Command;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link UniFiControllerThingHandler} is responsible for handling commands and status
|
||||
* updates for the UniFi Controller.
|
||||
*
|
||||
* @author Matthew Bowman - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class UniFiControllerThingHandler extends BaseBridgeHandler {
|
||||
|
||||
private static final String STATUS_DESCRIPTION_COMMUNICATION_ERROR = "@text/error.bridge.offline.communication_error";
|
||||
private static final String STATUS_DESCRIPTION_SSL_ERROR = "@text/error.bridge.offline.ssl_error";
|
||||
private static final String STATUS_DESCRIPTION_INVALID_CREDENTIALS = "@text/error.bridge.offline.invalid_credentials";
|
||||
private static final String STATUS_DESCRIPTION_INVALID_HOSTNAME = "@text/error.bridge.offline.invalid_hostname";
|
||||
private static final String I18N_STATUS_WITH_ARGUMENTS = "%s [\"%s\"]";
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(UniFiControllerThingHandler.class);
|
||||
|
||||
private UniFiControllerThingConfig config = new UniFiControllerThingConfig();
|
||||
|
||||
private @Nullable volatile UniFiController controller; /* mgb: volatile because accessed from multiple threads */
|
||||
|
||||
private @Nullable ScheduledFuture<?> refreshJob;
|
||||
|
||||
private final HttpClient httpClient;
|
||||
|
||||
public UniFiControllerThingHandler(final Bridge bridge, final HttpClient httpClient) {
|
||||
super(bridge);
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
// Public API
|
||||
|
||||
@Override
|
||||
public Collection<Class<? extends ThingHandlerService>> getServices() {
|
||||
return List.of(UniFiThingDiscoveryService.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initialize() {
|
||||
config = getConfigAs(UniFiControllerThingConfig.class);
|
||||
logger.debug("Initializing the UniFi Controller Handler with config = {}", config);
|
||||
final UniFiController uc = new UniFiController(httpClient, config.getHost(), config.getPort(),
|
||||
config.getUsername(), config.getPassword(), config.isUniFiOS(), config.getTimeoutSeconds());
|
||||
|
||||
controller = uc;
|
||||
updateStatus(UNKNOWN);
|
||||
scheduler.schedule(() -> start(uc), 10, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateStatus(final ThingStatus status, final ThingStatusDetail statusDetail,
|
||||
@Nullable final String description) {
|
||||
// mgb: update the status only if it's changed
|
||||
final ThingStatusInfo statusInfo = ThingStatusInfoBuilder.create(status, statusDetail)
|
||||
.withDescription(description).build();
|
||||
if (!statusInfo.equals(getThing().getStatusInfo())) {
|
||||
super.updateStatus(status, statusDetail, description);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
cancelRefreshJob();
|
||||
final UniFiController controller = this.controller;
|
||||
|
||||
if (controller != null) {
|
||||
try {
|
||||
controller.stop();
|
||||
} catch (final UniFiException e) {
|
||||
// mgb: nop as we're in dispose
|
||||
}
|
||||
this.controller = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleCommand(final ChannelUID channelUID, final Command command) {
|
||||
// nop - read-only binding
|
||||
logger.warn("Ignoring command = {} for channel = {} - the UniFi binding is read-only!", command, channelUID);
|
||||
}
|
||||
|
||||
public @Nullable UniFiController getController() {
|
||||
return controller;
|
||||
}
|
||||
|
||||
// Private API
|
||||
|
||||
private void start(final UniFiController uc) {
|
||||
boolean startRefresh = false;
|
||||
try {
|
||||
uc.start();
|
||||
startRefresh = true;
|
||||
} catch (final UniFiCommunicationException e) {
|
||||
updateStatusOffline(COMMUNICATION_ERROR, STATUS_DESCRIPTION_COMMUNICATION_ERROR, e.getMessage());
|
||||
startRefresh = true;
|
||||
} catch (final UniFiInvalidHostException e) {
|
||||
updateStatusOffline(CONFIGURATION_ERROR, STATUS_DESCRIPTION_INVALID_HOSTNAME, e.getMessage());
|
||||
} catch (final UniFiSSLException e) {
|
||||
updateStatusOffline(CONFIGURATION_ERROR, STATUS_DESCRIPTION_SSL_ERROR, e.getMessage());
|
||||
} catch (final UniFiInvalidCredentialsException e) {
|
||||
updateStatusOffline(CONFIGURATION_ERROR, STATUS_DESCRIPTION_INVALID_CREDENTIALS, e.getMessage());
|
||||
} catch (final UniFiException e) {
|
||||
logger.debug("Unknown error while configuring the UniFi Controller", e);
|
||||
updateStatus(OFFLINE, CONFIGURATION_ERROR, e.getMessage());
|
||||
}
|
||||
if (startRefresh) {
|
||||
logger.debug("Scheduling refresh job every {}s", config.getRefresh());
|
||||
refreshJob = scheduler.scheduleWithFixedDelay(this::run, 0, config.getRefresh(), TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
private void cancelRefreshJob() {
|
||||
synchronized (this) {
|
||||
final ScheduledFuture<?> rj = refreshJob;
|
||||
|
||||
if (rj != null) {
|
||||
logger.debug("Cancelling refresh job");
|
||||
rj.cancel(true);
|
||||
refreshJob = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void run() {
|
||||
try {
|
||||
logger.trace("Executing refresh job");
|
||||
refresh();
|
||||
updateStatus(ONLINE);
|
||||
} catch (final UniFiCommunicationException e) {
|
||||
updateStatusOffline(COMMUNICATION_ERROR, STATUS_DESCRIPTION_COMMUNICATION_ERROR, e.getMessage());
|
||||
} catch (final UniFiInvalidCredentialsException e) {
|
||||
updateStatusOffline(CONFIGURATION_ERROR, STATUS_DESCRIPTION_INVALID_CREDENTIALS, e.getMessage());
|
||||
} catch (final RuntimeException | UniFiException e) {
|
||||
logger.debug("Unhandled exception while refreshing the UniFi Controller {}", getThing().getUID(), e);
|
||||
updateStatus(OFFLINE, COMMUNICATION_ERROR, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void updateStatusOffline(final ThingStatusDetail thingStatusDetail, final String i18nKey,
|
||||
final @Nullable String argument) {
|
||||
updateStatus(OFFLINE, thingStatusDetail, String.format(I18N_STATUS_WITH_ARGUMENTS, i18nKey, argument));
|
||||
}
|
||||
|
||||
private void refresh() throws UniFiException {
|
||||
final UniFiController uc = controller;
|
||||
|
||||
if (uc != null) {
|
||||
logger.debug("Refreshing the UniFi Controller {}", getThing().getUID());
|
||||
uc.refresh();
|
||||
// mgb: then refresh all the client things
|
||||
getThing().getThings().forEach((thing) -> {
|
||||
final ThingHandler handler = thing.getHandler();
|
||||
|
||||
if (handler instanceof UniFiBaseThingHandler baseThingHandler) {
|
||||
baseThingHandler.refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -10,13 +10,13 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal;
|
||||
package org.openhab.binding.unifi.internal.network;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link UniFiAccessPointThingConfig} encapsulates all the configuration options for an instance of the
|
||||
* {@link org.openhab.binding.unifi.internal.handler.UniFiAccessPointThingHandler}.
|
||||
* {@link org.openhab.binding.unifi.internal.network.handler.UniFiAccessPointThingHandler}.
|
||||
*
|
||||
* @author Thomas Lauterbach - Initial contribution
|
||||
*/
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.network;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
|
||||
/**
|
||||
* The {@link UniFiBindingConstants} class defines common constants, which are
|
||||
* used across the UniFi binding.
|
||||
*
|
||||
* @author Matthew Bowman - Initial contribution
|
||||
* @author Patrik Wimnell - Blocking / Unblocking client support
|
||||
* @author Hilbrand Bouwkamp - Added poePort
|
||||
* @author Mark Herwege - Added guest vouchers
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public final class UniFiBindingConstants {
|
||||
|
||||
public static final String BINDING_ID = "unifi";
|
||||
|
||||
// List of all Thing Types owned by this child binding. The unifi:controller bridge itself is declared and
|
||||
// owned by the shared parent binding (org.openhab.binding.unifi), so it does not appear here.
|
||||
public static final ThingTypeUID THING_TYPE_SITE = new ThingTypeUID(BINDING_ID, "site");
|
||||
public static final ThingTypeUID THING_TYPE_NETWORK = new ThingTypeUID(BINDING_ID, "network");
|
||||
public static final ThingTypeUID THING_TYPE_WLAN = new ThingTypeUID(BINDING_ID, "wlan");
|
||||
public static final ThingTypeUID THING_TYPE_WIRED_CLIENT = new ThingTypeUID(BINDING_ID, "wiredClient");
|
||||
public static final ThingTypeUID THING_TYPE_WIRELESS_CLIENT = new ThingTypeUID(BINDING_ID, "wirelessClient");
|
||||
public static final ThingTypeUID THING_TYPE_POE_PORT = new ThingTypeUID(BINDING_ID, "poePort");
|
||||
public static final ThingTypeUID THING_TYPE_ACCESS_POINT = new ThingTypeUID(BINDING_ID, "accessPoint");
|
||||
public static final Set<ThingTypeUID> THING_TYPE_SUPPORTED = Set.of(THING_TYPE_SITE, THING_TYPE_NETWORK,
|
||||
THING_TYPE_WLAN, THING_TYPE_WIRED_CLIENT, THING_TYPE_WIRELESS_CLIENT, THING_TYPE_POE_PORT,
|
||||
THING_TYPE_ACCESS_POINT);
|
||||
|
||||
// List of site channels
|
||||
public static final String CHANNEL_TOTAL_CLIENTS = "totalClients";
|
||||
public static final String CHANNEL_WIRELESS_CLIENTS = "wirelessClients";
|
||||
public static final String CHANNEL_WIRED_CLIENTS = "wiredClients";
|
||||
public static final String CHANNEL_GUEST_CLIENTS = "guestClients";
|
||||
public static final String CHANNEL_GUEST_VOUCHER = "guestVoucher";
|
||||
public static final String CHANNEL_GUEST_VOUCHERS_GENERATE = "guestVouchersGenerate";
|
||||
|
||||
// List of network channels
|
||||
public static final String CHANNEL_PURPOSE = "purpose";
|
||||
|
||||
// List of wlan channels
|
||||
public static final String CHANNEL_SECURITY = "security";
|
||||
public static final String CHANNEL_WLANBAND = "wlanBand";
|
||||
public static final String CHANNEL_WPAENC = "wpaEnc";
|
||||
public static final String CHANNEL_WPAMODE = "wpaMode";
|
||||
public static final String CHANNEL_PASSPHRASE = "passphrase";
|
||||
public static final String CHANNEL_QRCODE_ENCODING = "qrcodeEncoding";
|
||||
|
||||
// List of common wired + wireless client + device channels
|
||||
public static final String CHANNEL_ONLINE = "online";
|
||||
public static final String CHANNEL_NAME = "name";
|
||||
public static final String CHANNEL_SITE = "site";
|
||||
public static final String CHANNEL_IP_ADDRESS = "ipAddress";
|
||||
public static final String CHANNEL_UPTIME = "uptime";
|
||||
public static final String CHANNEL_LAST_SEEN = "lastSeen";
|
||||
public static final String CHANNEL_EXPERIENCE = "experience";
|
||||
|
||||
// List of common wired + wireless client channels
|
||||
public static final String CHANNEL_HOSTNAME = "hostname";
|
||||
public static final String CHANNEL_MAC_ADDRESS = "macAddress";
|
||||
public static final String CHANNEL_GUEST = "guest";
|
||||
public static final String CHANNEL_BLOCKED = "blocked";
|
||||
public static final String CHANNEL_RECONNECT = "reconnect";
|
||||
public static final String CHANNEL_CMD = "cmd";
|
||||
public static final String CHANNEL_CMD_RECONNECT = "reconnect";
|
||||
|
||||
// List of additional wireless client channels
|
||||
public static final String CHANNEL_AP = "ap";
|
||||
public static final String CHANNEL_ESSID = "essid";
|
||||
public static final String CHANNEL_RSSI = "rssi";
|
||||
|
||||
// List of switch port channels
|
||||
public static final String CHANNEL_ENABLE = "enable";
|
||||
public static final String CHANNEL_ENABLE_PARAMETER_MODE = "mode";
|
||||
public static final String CHANNEL_ENABLE_PARAMETER_MODE_OFF = "off";
|
||||
public static final String CHANNEL_ENABLE_PARAMETER_MODE_AUTO = "auto";
|
||||
public static final String CHANNEL_PORT_POE_MODE = "mode";
|
||||
public static final String CHANNEL_PORT_POE_CMD = "cmd";
|
||||
public static final String CHANNEL_PORT_POE_CMD_POWER_CYCLE = "powercycle";
|
||||
public static final String CHANNEL_PORT_POE_ENABLE = "enable";
|
||||
public static final String CHANNEL_PORT_POE_POWER = "power";
|
||||
public static final String CHANNEL_PORT_POE_VOLTAGE = "voltage";
|
||||
public static final String CHANNEL_PORT_POE_CURRENT = "current";
|
||||
|
||||
// List of access point device channels
|
||||
public static final String CHANNEL_AP_ENABLE = "enable";
|
||||
public static final String CHANNEL_AP_STATE = "state";
|
||||
public static final String CHANNEL_AP_LED = "led";
|
||||
|
||||
// List of all Parameters
|
||||
public static final String PARAMETER_HOST = "host";
|
||||
public static final String PARAMETER_PORT = "port";
|
||||
public static final String PARAMETER_USERNAME = "username";
|
||||
public static final String PARAMETER_PASSWORD = "password";
|
||||
public static final String PARAMETER_UNIFIOS = "unifios";
|
||||
public static final String PARAMETER_SITE = "site";
|
||||
public static final String PARAMETER_CID = "cid";
|
||||
public static final String PARAMETER_SID = "sid";
|
||||
public static final String PARAMETER_WID = "wid";
|
||||
public static final String PARAMETER_NID = "nid";
|
||||
public static final String PARAMETER_VOUCHER_COUNT = "voucherCount";
|
||||
public static final String PARAMETER_VOUCHER_EXPIRATION = "voucherExpiration";
|
||||
public static final String PARAMETER_VOUCHER_USERS = "voucherUsers";
|
||||
public static final String PARAMETER_VOUCHER_UP_LIMIT = "voucherUpLimit";
|
||||
public static final String PARAMETER_VOUCHER_DOWN_LIMIT = "voucherDownLimit";
|
||||
public static final String PARAMETER_VOUCHER_DATA_QUOTA = "voucherDataQuota";
|
||||
public static final String PARAMETER_PORT_NUMBER = "portNumber";
|
||||
public static final String PARAMETER_MAC_ADDRESS = "macAddress";
|
||||
|
||||
// UniFi device types
|
||||
public static final String DEVICE_TYPE_UAP = "uap";
|
||||
|
||||
private UniFiBindingConstants() {
|
||||
// Constants class
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -10,10 +10,10 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal;
|
||||
package org.openhab.binding.unifi.internal.network;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.unifi.internal.handler.UniFiClientThingHandler;
|
||||
import org.openhab.binding.unifi.internal.network.handler.UniFiClientThingHandler;
|
||||
|
||||
/**
|
||||
* The {@link UniFiClientThingConfig} encapsulates all the configuration options for an instance of the
|
||||
+2
-2
@@ -10,10 +10,10 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal;
|
||||
package org.openhab.binding.unifi.internal.network;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.unifi.internal.handler.UniFiNetworkThingHandler;
|
||||
import org.openhab.binding.unifi.internal.network.handler.UniFiNetworkThingHandler;
|
||||
|
||||
/**
|
||||
* The {@link UniFiNetworkThingConfig} encapsulates all the configuration options for an instance of the
|
||||
+2
-2
@@ -10,13 +10,13 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal;
|
||||
package org.openhab.binding.unifi.internal.network;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link UniFiPoePortThingConfig} encapsulates all the configuration options for an instance of the
|
||||
* {@link org.openhab.binding.unifi.internal.handler.UniFiPoePortThingHandler}.
|
||||
* {@link org.openhab.binding.unifi.internal.network.handler.UniFiPoePortThingHandler}.
|
||||
*
|
||||
* @author Hilbrand Bouwkamp - Initial contribution
|
||||
*/
|
||||
+2
-2
@@ -10,10 +10,10 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal;
|
||||
package org.openhab.binding.unifi.internal.network;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.unifi.internal.handler.UniFiSiteThingHandler;
|
||||
import org.openhab.binding.unifi.internal.network.handler.UniFiSiteThingHandler;
|
||||
|
||||
/**
|
||||
* The {@link UniFiSiteThingConfig} encapsulates all the configuration options for an instance of the
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.network;
|
||||
|
||||
import static org.openhab.binding.unifi.internal.network.UniFiBindingConstants.*;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.unifi.internal.network.handler.UniFiAccessPointThingHandler;
|
||||
import org.openhab.binding.unifi.internal.network.handler.UniFiClientThingHandler;
|
||||
import org.openhab.binding.unifi.internal.network.handler.UniFiNetworkThingHandler;
|
||||
import org.openhab.binding.unifi.internal.network.handler.UniFiPoePortThingHandler;
|
||||
import org.openhab.binding.unifi.internal.network.handler.UniFiSiteThingHandler;
|
||||
import org.openhab.binding.unifi.internal.network.handler.UniFiWlanThingHandler;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
|
||||
import org.openhab.core.thing.binding.ThingHandler;
|
||||
import org.openhab.core.thing.binding.ThingHandlerFactory;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
|
||||
/**
|
||||
* The {@link UniFiThingHandlerFactory} creates handlers for UniFi Network child things. The controller bridge
|
||||
* itself is owned by the shared UniFi parent binding ({@code org.openhab.binding.unifi}), so this factory does
|
||||
* not handle {@code unifi:controller}; Network child things attach to the parent bridge and receive a shared
|
||||
* authenticated session.
|
||||
*
|
||||
* @author Matthew Bowman - Initial contribution
|
||||
* @author Dan Cunningham - Refactored onto shared parent bridge session
|
||||
*/
|
||||
@Component(service = ThingHandlerFactory.class, configurationPid = "binding.unifi.network")
|
||||
@NonNullByDefault
|
||||
public class UniFiThingHandlerFactory extends BaseThingHandlerFactory {
|
||||
|
||||
@Override
|
||||
public boolean supportsThingType(final ThingTypeUID thingTypeUID) {
|
||||
return THING_TYPE_SUPPORTED.contains(thingTypeUID);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @Nullable ThingHandler createHandler(final Thing thing) {
|
||||
final ThingTypeUID thingTypeUID = thing.getThingTypeUID();
|
||||
if (THING_TYPE_SITE.equals(thingTypeUID)) {
|
||||
return new UniFiSiteThingHandler(thing);
|
||||
} else if (THING_TYPE_NETWORK.equals(thingTypeUID)) {
|
||||
return new UniFiNetworkThingHandler(thing);
|
||||
} else if (THING_TYPE_WLAN.equals(thingTypeUID)) {
|
||||
return new UniFiWlanThingHandler(thing);
|
||||
} else if (THING_TYPE_WIRELESS_CLIENT.equals(thingTypeUID) || THING_TYPE_WIRED_CLIENT.equals(thingTypeUID)) {
|
||||
return new UniFiClientThingHandler(thing);
|
||||
} else if (THING_TYPE_POE_PORT.equals(thingTypeUID)) {
|
||||
return new UniFiPoePortThingHandler(thing);
|
||||
} else if (THING_TYPE_ACCESS_POINT.equals(thingTypeUID)) {
|
||||
return new UniFiAccessPointThingHandler(thing);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal;
|
||||
package org.openhab.binding.unifi.internal.network;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
+2
-2
@@ -10,10 +10,10 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal;
|
||||
package org.openhab.binding.unifi.internal.network;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.unifi.internal.handler.UniFiWlanThingHandler;
|
||||
import org.openhab.binding.unifi.internal.network.handler.UniFiWlanThingHandler;
|
||||
|
||||
/**
|
||||
* The {@link UniFiWlanThingConfig} encapsulates all the configuration options for an instance of the
|
||||
+6
-6
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.action;
|
||||
package org.openhab.binding.unifi.internal.network.action;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
@@ -19,11 +19,11 @@ import java.util.stream.Stream;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiController;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiException;
|
||||
import org.openhab.binding.unifi.internal.api.dto.UniFiSite;
|
||||
import org.openhab.binding.unifi.internal.api.dto.UniFiVoucher;
|
||||
import org.openhab.binding.unifi.internal.handler.UniFiSiteThingHandler;
|
||||
import org.openhab.binding.unifi.internal.network.api.UniFiController;
|
||||
import org.openhab.binding.unifi.internal.network.api.UniFiException;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.UniFiSite;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.UniFiVoucher;
|
||||
import org.openhab.binding.unifi.internal.network.handler.UniFiSiteThingHandler;
|
||||
import org.openhab.core.automation.annotation.ActionInput;
|
||||
import org.openhab.core.automation.annotation.ActionOutput;
|
||||
import org.openhab.core.automation.annotation.RuleAction;
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api;
|
||||
package org.openhab.binding.unifi.internal.network.api;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
+44
-98
@@ -10,36 +10,38 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api;
|
||||
package org.openhab.binding.unifi.internal.network.api;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletionException;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.eclipse.jetty.http.HttpMethod;
|
||||
import org.openhab.binding.unifi.internal.api.cache.UniFiControllerCache;
|
||||
import org.openhab.binding.unifi.internal.api.dto.UnfiPortOverrideJsonObject;
|
||||
import org.openhab.binding.unifi.internal.api.dto.UniFiClient;
|
||||
import org.openhab.binding.unifi.internal.api.dto.UniFiDevice;
|
||||
import org.openhab.binding.unifi.internal.api.dto.UniFiNetwork;
|
||||
import org.openhab.binding.unifi.internal.api.dto.UniFiSite;
|
||||
import org.openhab.binding.unifi.internal.api.dto.UniFiSwitchPorts;
|
||||
import org.openhab.binding.unifi.internal.api.dto.UniFiUnknownClient;
|
||||
import org.openhab.binding.unifi.internal.api.dto.UniFiVoucher;
|
||||
import org.openhab.binding.unifi.internal.api.dto.UniFiWiredClient;
|
||||
import org.openhab.binding.unifi.internal.api.dto.UniFiWirelessClient;
|
||||
import org.openhab.binding.unifi.internal.api.dto.UniFiWlan;
|
||||
import org.openhab.binding.unifi.internal.api.util.UnfiPortOverrideJsonElementDeserializer;
|
||||
import org.openhab.binding.unifi.internal.api.util.UniFiClientDeserializer;
|
||||
import org.openhab.binding.unifi.internal.api.util.UniFiClientInstanceCreator;
|
||||
import org.openhab.binding.unifi.internal.api.util.UniFiDeviceInstanceCreator;
|
||||
import org.openhab.binding.unifi.internal.api.util.UniFiNetworkInstanceCreator;
|
||||
import org.openhab.binding.unifi.internal.api.util.UniFiSiteInstanceCreator;
|
||||
import org.openhab.binding.unifi.internal.api.util.UniFiVoucherInstanceCreator;
|
||||
import org.openhab.binding.unifi.internal.api.util.UniFiWlanInstanceCreator;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiSession;
|
||||
import org.openhab.binding.unifi.internal.network.api.cache.UniFiControllerCache;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.UnfiPortOverrideJsonObject;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.UniFiClient;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.UniFiDevice;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.UniFiNetwork;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.UniFiSite;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.UniFiSwitchPorts;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.UniFiUnknownClient;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.UniFiVoucher;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.UniFiWiredClient;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.UniFiWirelessClient;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.UniFiWlan;
|
||||
import org.openhab.binding.unifi.internal.network.api.util.UnfiPortOverrideJsonElementDeserializer;
|
||||
import org.openhab.binding.unifi.internal.network.api.util.UniFiClientDeserializer;
|
||||
import org.openhab.binding.unifi.internal.network.api.util.UniFiClientInstanceCreator;
|
||||
import org.openhab.binding.unifi.internal.network.api.util.UniFiDeviceInstanceCreator;
|
||||
import org.openhab.binding.unifi.internal.network.api.util.UniFiNetworkInstanceCreator;
|
||||
import org.openhab.binding.unifi.internal.network.api.util.UniFiSiteInstanceCreator;
|
||||
import org.openhab.binding.unifi.internal.network.api.util.UniFiVoucherInstanceCreator;
|
||||
import org.openhab.binding.unifi.internal.network.api.util.UniFiWlanInstanceCreator;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -49,14 +51,17 @@ import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
/**
|
||||
* The {@link UniFiController} is the main communication point with an external instance of the Ubiquiti Networks
|
||||
* Controller Software.
|
||||
* The {@link UniFiController} is the main communication point with a UniFi Network controller. It consumes a
|
||||
* {@link UniFiSession} published by the shared UniFi parent binding's controller bridge, so login, CSRF
|
||||
* rotation, session persistence, and rate limiting are delegated to the parent and shared across the UniFi
|
||||
* Network, Protect, and Access family bindings.
|
||||
*
|
||||
* @author Matthew Bowman - Initial contribution
|
||||
* @author Patrik Wimnell - Blocking / Unblocking client support
|
||||
* @author Jacob Laursen - Fix online/blocked channels (broken by UniFi Controller 5.12.35)
|
||||
* @author Hilbrand Bouwkamp - Added POEPort support, moved generic cache related code to cache object
|
||||
* @author Mark Herwege - Added guest vouchers
|
||||
* @author Dan Cunningham - Refactored onto shared parent bridge session
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class UniFiController {
|
||||
@@ -66,29 +71,19 @@ public class UniFiController {
|
||||
private final Logger logger = LoggerFactory.getLogger(UniFiController.class);
|
||||
|
||||
private final HttpClient httpClient;
|
||||
private final UniFiControllerCache cache = new UniFiControllerCache();
|
||||
|
||||
private final String host;
|
||||
private final int port;
|
||||
private final String username;
|
||||
private final String password;
|
||||
private final UniFiSession session;
|
||||
private final boolean unifios;
|
||||
private final int timeoutSeconds;
|
||||
private final UniFiControllerCache cache = new UniFiControllerCache();
|
||||
private final Gson gson;
|
||||
private final Gson poeGson;
|
||||
|
||||
private String csrfToken;
|
||||
|
||||
public UniFiController(final HttpClient httpClient, final String host, final int port, final String username,
|
||||
final String password, final boolean unifios, int timeoutSeconds) {
|
||||
public UniFiController(final HttpClient httpClient, final UniFiSession session, final boolean unifios,
|
||||
final int timeoutSeconds) {
|
||||
this.httpClient = httpClient;
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.session = session;
|
||||
this.unifios = unifios;
|
||||
this.timeoutSeconds = timeoutSeconds;
|
||||
this.csrfToken = "";
|
||||
final UniFiSiteInstanceCreator siteInstanceCreator = new UniFiSiteInstanceCreator(cache);
|
||||
final UniFiNetworkInstanceCreator networkInstanceCreator = new UniFiNetworkInstanceCreator(cache);
|
||||
final UniFiWlanInstanceCreator wlanInstanceCreator = new UniFiWlanInstanceCreator(cache);
|
||||
@@ -112,44 +107,6 @@ public class UniFiController {
|
||||
|
||||
// Public API
|
||||
|
||||
public void start() throws UniFiException {
|
||||
if (unifios) {
|
||||
obtainCsrfToken();
|
||||
}
|
||||
|
||||
login();
|
||||
}
|
||||
|
||||
public void stop() throws UniFiException {
|
||||
logout();
|
||||
}
|
||||
|
||||
public void obtainCsrfToken() throws UniFiException {
|
||||
csrfToken = "";
|
||||
|
||||
final UniFiControllerRequest<Void> req = newRequest(Void.class, HttpMethod.GET, gson);
|
||||
req.setPath("/");
|
||||
executeRequest(req);
|
||||
}
|
||||
|
||||
public void login() throws UniFiException {
|
||||
final UniFiControllerRequest<Void> req = newRequest(Void.class, HttpMethod.POST, gson);
|
||||
req.setPath(unifios ? "/api/auth/login" : "/api/login");
|
||||
req.setBodyParameter("username", username);
|
||||
req.setBodyParameter("password", password);
|
||||
// scurb: Changed strict = false to make blocking feature work
|
||||
req.setBodyParameter("strict", false);
|
||||
req.setBodyParameter("remember", false);
|
||||
executeRequest(req, true);
|
||||
}
|
||||
|
||||
public void logout() throws UniFiException {
|
||||
csrfToken = "";
|
||||
final UniFiControllerRequest<Void> req = newRequest(Void.class, HttpMethod.POST, gson);
|
||||
req.setPath(unifios ? "/api/auth/logout" : "/logout");
|
||||
executeRequest(req);
|
||||
}
|
||||
|
||||
public void refresh() throws UniFiException {
|
||||
synchronized (this) {
|
||||
cache.clear();
|
||||
@@ -192,7 +149,7 @@ public class UniFiController {
|
||||
public boolean poeMode(final UniFiDevice device, final List<JsonObject> data) throws UniFiException {
|
||||
// Safety check to make sure no empty data is send to avoid corrupting override data on the device.
|
||||
if (data.isEmpty() || data.stream().anyMatch(p -> p.entrySet().isEmpty())) {
|
||||
logger.info("Not overriding port for '{}', because port data contains empty JSON: {}", device.getName(),
|
||||
logger.debug("Not overriding port for '{}', because port data contains empty JSON: {}", device.getName(),
|
||||
poeGson.toJson(data));
|
||||
return false;
|
||||
} else {
|
||||
@@ -288,35 +245,24 @@ public class UniFiController {
|
||||
|
||||
private <T> UniFiControllerRequest<T> newRequest(final Class<T> responseType, final HttpMethod method,
|
||||
final Gson gson) {
|
||||
return new UniFiControllerRequest<>(responseType, gson, httpClient, method, host, port, csrfToken, unifios,
|
||||
timeoutSeconds);
|
||||
return new UniFiControllerRequest<>(responseType, gson, httpClient, session, method, unifios, timeoutSeconds);
|
||||
}
|
||||
|
||||
private <T> @Nullable T executeRequest(final UniFiControllerRequest<T> request) throws UniFiException {
|
||||
return executeRequest(request, false);
|
||||
}
|
||||
|
||||
private <T> @Nullable T executeRequest(final UniFiControllerRequest<T> request, final boolean fromLogin)
|
||||
throws UniFiException {
|
||||
T result;
|
||||
try {
|
||||
result = (T) request.execute();
|
||||
csrfToken = request.getCsrfToken();
|
||||
return (T) request.execute();
|
||||
} catch (final UniFiExpiredSessionException e) {
|
||||
if (fromLogin) {
|
||||
// if this exception is thrown from a login attempt something is wrong, because the login should init
|
||||
// the session.
|
||||
throw new UniFiCommunicationException(e);
|
||||
} else {
|
||||
login();
|
||||
request.setCsrfToken(csrfToken);
|
||||
result = (T) executeRequest(request);
|
||||
logger.debug("Session expired; triggering re-authentication");
|
||||
try {
|
||||
session.reauthenticate().join();
|
||||
} catch (CompletionException ce) {
|
||||
throw new UniFiCommunicationException(ce);
|
||||
}
|
||||
return (T) request.execute();
|
||||
} catch (final UniFiNotAuthorizedException e) {
|
||||
logger.warn("Not Authorized! Please make sure your controller credentials have administrator rights");
|
||||
result = (T) null;
|
||||
logger.debug("Not Authorized. Please make sure your controller credentials have administrator rights");
|
||||
return null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<UniFiSite> refreshSites() throws UniFiException {
|
||||
+15
-35
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api;
|
||||
package org.openhab.binding.unifi.internal.network.api;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
@@ -37,10 +37,9 @@ import org.eclipse.jetty.client.api.Response;
|
||||
import org.eclipse.jetty.client.util.InputStreamResponseListener;
|
||||
import org.eclipse.jetty.client.util.StringContentProvider;
|
||||
import org.eclipse.jetty.http.HttpMethod;
|
||||
import org.eclipse.jetty.http.HttpScheme;
|
||||
import org.eclipse.jetty.http.HttpStatus;
|
||||
import org.eclipse.jetty.http.HttpURI;
|
||||
import org.eclipse.jetty.http.MimeTypes;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiSession;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -51,7 +50,9 @@ import com.google.gson.JsonParseException;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
/**
|
||||
* The {@link UniFiControllerRequest} encapsulates a request sent by the {@link UniFiController}.
|
||||
* The {@link UniFiControllerRequest} encapsulates a request sent by the {@link UniFiController}. Authentication
|
||||
* headers and the rate limiter are applied via a shared {@link UniFiSession} provided by the parent UniFi
|
||||
* binding's controller bridge.
|
||||
*
|
||||
* @author Matthew Bowman - Initial contribution
|
||||
*
|
||||
@@ -69,22 +70,14 @@ class UniFiControllerRequest<T> {
|
||||
private final Logger logger = LoggerFactory.getLogger(UniFiControllerRequest.class);
|
||||
|
||||
private final Gson gson;
|
||||
|
||||
private final HttpClient httpClient;
|
||||
|
||||
private final String host;
|
||||
|
||||
private final int port;
|
||||
|
||||
private final UniFiSession session;
|
||||
private final boolean unifios;
|
||||
private final int timeoutSeconds;
|
||||
|
||||
private final HttpMethod method;
|
||||
|
||||
private String path = "/";
|
||||
|
||||
private String csrfToken;
|
||||
|
||||
private final Map<String, String> queryParameters = new HashMap<>();
|
||||
|
||||
private final Map<String, Object> bodyParameters = new HashMap<>();
|
||||
@@ -94,15 +87,12 @@ class UniFiControllerRequest<T> {
|
||||
// Public API
|
||||
|
||||
public UniFiControllerRequest(final Class<T> resultType, final Gson gson, final HttpClient httpClient,
|
||||
final HttpMethod method, final String host, final int port, final String csrfToken, final boolean unifios,
|
||||
int timeoutSeconds) {
|
||||
final UniFiSession session, final HttpMethod method, final boolean unifios, int timeoutSeconds) {
|
||||
this.resultType = resultType;
|
||||
this.gson = gson;
|
||||
this.httpClient = httpClient;
|
||||
this.session = session;
|
||||
this.method = method;
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
this.csrfToken = csrfToken;
|
||||
this.unifios = unifios;
|
||||
this.timeoutSeconds = timeoutSeconds;
|
||||
}
|
||||
@@ -167,18 +157,19 @@ class UniFiControllerRequest<T> {
|
||||
|
||||
final String csrfToken = response.getHeaders().get("X-CSRF-Token");
|
||||
if (csrfToken != null && !csrfToken.isEmpty()) {
|
||||
this.csrfToken = csrfToken;
|
||||
session.updateCsrfToken(csrfToken);
|
||||
}
|
||||
break;
|
||||
case HttpStatus.BAD_REQUEST_400:
|
||||
logger.info("UniFi returned a status 400: {}", prettyPrintJson(responseToString(listener)));
|
||||
logger.debug("UniFi returned a status 400: {}", prettyPrintJson(responseToString(listener)));
|
||||
throw new UniFiInvalidCredentialsException("Invalid Credentials");
|
||||
case HttpStatus.UNAUTHORIZED_401:
|
||||
throw new UniFiExpiredSessionException("Expired Credentials");
|
||||
case HttpStatus.FORBIDDEN_403:
|
||||
throw new UniFiNotAuthorizedException("Unauthorized Access");
|
||||
default:
|
||||
logger.info("UniFi returned a status code {}: {}", status, prettyPrintJson(responseToString(listener)));
|
||||
logger.debug("UniFi returned a status code {}: {}", status,
|
||||
prettyPrintJson(responseToString(listener)));
|
||||
throw new UniFiException("Unknown HTTP status code " + status + " returned by the controller");
|
||||
}
|
||||
return content;
|
||||
@@ -232,18 +223,9 @@ class UniFiControllerRequest<T> {
|
||||
return new String(responseContent.toByteArray(), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public String getCsrfToken() {
|
||||
return csrfToken;
|
||||
}
|
||||
|
||||
public void setCsrfToken(final String csrfToken) {
|
||||
this.csrfToken = csrfToken;
|
||||
}
|
||||
|
||||
private Request newRequest() {
|
||||
final HttpURI uri = new HttpURI(HttpScheme.HTTPS.asString(), host, port, path);
|
||||
final Request request = httpClient.newRequest(uri.toString()).timeout(timeoutSeconds, TimeUnit.SECONDS)
|
||||
.method(method);
|
||||
final String url = session.getBaseUrl() + path;
|
||||
final Request request = httpClient.newRequest(url).timeout(timeoutSeconds, TimeUnit.SECONDS).method(method);
|
||||
for (final Entry<String, String> entry : queryParameters.entrySet()) {
|
||||
request.param(entry.getKey(), entry.getValue());
|
||||
}
|
||||
@@ -255,9 +237,7 @@ class UniFiControllerRequest<T> {
|
||||
new StringContentProvider(CONTENT_TYPE_APPLICATION_JSON_UTF_8, jsonBody, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
if (!csrfToken.isEmpty()) {
|
||||
request.header("x-csrf-token", this.csrfToken);
|
||||
}
|
||||
session.addAuthHeaders(request);
|
||||
|
||||
return request;
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.network.api;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* The {@link UniFiException} represents a binding specific {@link Exception}.
|
||||
*
|
||||
* @author Matthew Bowman - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class UniFiException extends Exception {
|
||||
|
||||
private static final long serialVersionUID = -7422254981644510570L;
|
||||
|
||||
public UniFiException(final String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public UniFiException(final String message, final Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public UniFiException(final @Nullable Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api;
|
||||
package org.openhab.binding.unifi.internal.network.api;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api;
|
||||
package org.openhab.binding.unifi.internal.network.api;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api;
|
||||
package org.openhab.binding.unifi.internal.network.api;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api;
|
||||
package org.openhab.binding.unifi.internal.network.api;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api;
|
||||
package org.openhab.binding.unifi.internal.network.api;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api.cache;
|
||||
package org.openhab.binding.unifi.internal.network.api.cache;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
@@ -22,7 +22,7 @@ import java.util.stream.Stream;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.unifi.internal.api.dto.HasId;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.HasId;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
+10
-8
@@ -10,17 +10,19 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api.cache;
|
||||
package org.openhab.binding.unifi.internal.network.api.cache;
|
||||
|
||||
import static org.openhab.binding.unifi.internal.api.cache.UniFiCache.Prefix.HOSTNAME;
|
||||
import static org.openhab.binding.unifi.internal.api.cache.UniFiCache.Prefix.ID;
|
||||
import static org.openhab.binding.unifi.internal.api.cache.UniFiCache.Prefix.IP;
|
||||
import static org.openhab.binding.unifi.internal.api.cache.UniFiCache.Prefix.MAC;
|
||||
import static org.openhab.binding.unifi.internal.api.cache.UniFiCache.Prefix.NAME;
|
||||
import static org.openhab.binding.unifi.internal.network.api.cache.UniFiCache.Prefix.HOSTNAME;
|
||||
import static org.openhab.binding.unifi.internal.network.api.cache.UniFiCache.Prefix.ID;
|
||||
import static org.openhab.binding.unifi.internal.network.api.cache.UniFiCache.Prefix.IP;
|
||||
import static org.openhab.binding.unifi.internal.network.api.cache.UniFiCache.Prefix.MAC;
|
||||
import static org.openhab.binding.unifi.internal.network.api.cache.UniFiCache.Prefix.NAME;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.unifi.internal.api.dto.UniFiClient;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.UniFiClient;
|
||||
|
||||
/**
|
||||
* The {@link UniFiClientCache} is a specific implementation of {@link UniFiCache} for the purpose of caching
|
||||
@@ -57,6 +59,6 @@ class UniFiClientCache extends UniFiCache<UniFiClient> {
|
||||
}
|
||||
|
||||
private static @Nullable String safeTidy(final @Nullable String value) {
|
||||
return value == null ? null : value.trim().toLowerCase();
|
||||
return value == null ? null : value.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
+9
-9
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api.cache;
|
||||
package org.openhab.binding.unifi.internal.network.api.cache;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
@@ -23,14 +23,14 @@ import java.util.stream.Stream;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.unifi.internal.api.dto.UniFiClient;
|
||||
import org.openhab.binding.unifi.internal.api.dto.UniFiDevice;
|
||||
import org.openhab.binding.unifi.internal.api.dto.UniFiNetwork;
|
||||
import org.openhab.binding.unifi.internal.api.dto.UniFiPortTuple;
|
||||
import org.openhab.binding.unifi.internal.api.dto.UniFiSite;
|
||||
import org.openhab.binding.unifi.internal.api.dto.UniFiSwitchPorts;
|
||||
import org.openhab.binding.unifi.internal.api.dto.UniFiVoucher;
|
||||
import org.openhab.binding.unifi.internal.api.dto.UniFiWlan;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.UniFiClient;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.UniFiDevice;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.UniFiNetwork;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.UniFiPortTuple;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.UniFiSite;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.UniFiSwitchPorts;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.UniFiVoucher;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.UniFiWlan;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
+3
-3
@@ -10,13 +10,13 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api.cache;
|
||||
package org.openhab.binding.unifi.internal.network.api.cache;
|
||||
|
||||
import static org.openhab.binding.unifi.internal.api.cache.UniFiCache.Prefix.MAC;
|
||||
import static org.openhab.binding.unifi.internal.network.api.cache.UniFiCache.Prefix.MAC;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.unifi.internal.api.dto.UniFiDevice;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.UniFiDevice;
|
||||
|
||||
/**
|
||||
* The {@link UniFiDeviceCache} is a specific implementation of {@link UniFiCache} for the purpose of caching
|
||||
+4
-4
@@ -10,14 +10,14 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api.cache;
|
||||
package org.openhab.binding.unifi.internal.network.api.cache;
|
||||
|
||||
import static org.openhab.binding.unifi.internal.api.cache.UniFiCache.Prefix.ID;
|
||||
import static org.openhab.binding.unifi.internal.api.cache.UniFiCache.Prefix.NAME;
|
||||
import static org.openhab.binding.unifi.internal.network.api.cache.UniFiCache.Prefix.ID;
|
||||
import static org.openhab.binding.unifi.internal.network.api.cache.UniFiCache.Prefix.NAME;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.unifi.internal.api.dto.UniFiNetwork;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.UniFiNetwork;
|
||||
|
||||
/**
|
||||
* The {@link UniFiNetworkCache} is a specific implementation of {@link UniFiCache} for the purpose of caching
|
||||
+5
-5
@@ -10,15 +10,15 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api.cache;
|
||||
package org.openhab.binding.unifi.internal.network.api.cache;
|
||||
|
||||
import static org.openhab.binding.unifi.internal.api.cache.UniFiCache.Prefix.DESC;
|
||||
import static org.openhab.binding.unifi.internal.api.cache.UniFiCache.Prefix.ID;
|
||||
import static org.openhab.binding.unifi.internal.api.cache.UniFiCache.Prefix.NAME;
|
||||
import static org.openhab.binding.unifi.internal.network.api.cache.UniFiCache.Prefix.DESC;
|
||||
import static org.openhab.binding.unifi.internal.network.api.cache.UniFiCache.Prefix.ID;
|
||||
import static org.openhab.binding.unifi.internal.network.api.cache.UniFiCache.Prefix.NAME;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.unifi.internal.api.dto.UniFiSite;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.UniFiSite;
|
||||
|
||||
/**
|
||||
* The {@link UniFiSiteCache} is a specific implementation of {@link UniFiCache} for the purpose of caching
|
||||
+3
-3
@@ -10,13 +10,13 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api.cache;
|
||||
package org.openhab.binding.unifi.internal.network.api.cache;
|
||||
|
||||
import static org.openhab.binding.unifi.internal.api.cache.UniFiCache.Prefix.ID;
|
||||
import static org.openhab.binding.unifi.internal.network.api.cache.UniFiCache.Prefix.ID;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.unifi.internal.api.dto.UniFiVoucher;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.UniFiVoucher;
|
||||
|
||||
/**
|
||||
* The {@link UniFiVoucherCache} is a specific implementation of {@link UniFiCache} for the purpose of caching
|
||||
+4
-4
@@ -10,14 +10,14 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api.cache;
|
||||
package org.openhab.binding.unifi.internal.network.api.cache;
|
||||
|
||||
import static org.openhab.binding.unifi.internal.api.cache.UniFiCache.Prefix.ID;
|
||||
import static org.openhab.binding.unifi.internal.api.cache.UniFiCache.Prefix.NAME;
|
||||
import static org.openhab.binding.unifi.internal.network.api.cache.UniFiCache.Prefix.ID;
|
||||
import static org.openhab.binding.unifi.internal.network.api.cache.UniFiCache.Prefix.NAME;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.unifi.internal.api.dto.UniFiWlan;
|
||||
import org.openhab.binding.unifi.internal.network.api.dto.UniFiWlan;
|
||||
|
||||
/**
|
||||
* The {@link UniFiWlanCache} is a specific implementation of {@link UniFiCache} for the purpose of caching
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api.dto;
|
||||
package org.openhab.binding.unifi.internal.network.api.dto;
|
||||
|
||||
/**
|
||||
* Data classes that have an id as identifier.
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api.dto;
|
||||
package org.openhab.binding.unifi.internal.network.api.dto;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
+4
-4
@@ -10,13 +10,13 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api.dto;
|
||||
package org.openhab.binding.unifi.internal.network.api.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import org.openhab.binding.unifi.internal.api.cache.UniFiControllerCache;
|
||||
import org.openhab.binding.unifi.internal.api.util.UniFiTidyLowerCaseStringDeserializer;
|
||||
import org.openhab.binding.unifi.internal.api.util.UniFiTimestampDeserializer;
|
||||
import org.openhab.binding.unifi.internal.network.api.cache.UniFiControllerCache;
|
||||
import org.openhab.binding.unifi.internal.network.api.util.UniFiTidyLowerCaseStringDeserializer;
|
||||
import org.openhab.binding.unifi.internal.network.api.util.UniFiTimestampDeserializer;
|
||||
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
+4
-4
@@ -10,13 +10,13 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api.dto;
|
||||
package org.openhab.binding.unifi.internal.network.api.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import org.openhab.binding.unifi.internal.api.cache.UniFiControllerCache;
|
||||
import org.openhab.binding.unifi.internal.api.util.UniFiTidyLowerCaseStringDeserializer;
|
||||
import org.openhab.binding.unifi.internal.api.util.UniFiTimestampDeserializer;
|
||||
import org.openhab.binding.unifi.internal.network.api.cache.UniFiControllerCache;
|
||||
import org.openhab.binding.unifi.internal.network.api.util.UniFiTidyLowerCaseStringDeserializer;
|
||||
import org.openhab.binding.unifi.internal.network.api.util.UniFiTimestampDeserializer;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
+2
-2
@@ -10,9 +10,9 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api.dto;
|
||||
package org.openhab.binding.unifi.internal.network.api.dto;
|
||||
|
||||
import org.openhab.binding.unifi.internal.api.cache.UniFiControllerCache;
|
||||
import org.openhab.binding.unifi.internal.network.api.cache.UniFiControllerCache;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api.dto;
|
||||
package org.openhab.binding.unifi.internal.network.api.dto;
|
||||
|
||||
import com.google.gson.annotations.Expose;
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api.dto;
|
||||
package org.openhab.binding.unifi.internal.network.api.dto;
|
||||
|
||||
/**
|
||||
* Tuple to store both the {@link UniFiPortTable}, which contains the all information related to the port,
|
||||
+2
-2
@@ -10,9 +10,9 @@
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.unifi.internal.api.dto;
|
||||
package org.openhab.binding.unifi.internal.network.api.dto;
|
||||
|
||||
import org.openhab.binding.unifi.internal.api.cache.UniFiControllerCache;
|
||||
import org.openhab.binding.unifi.internal.network.api.cache.UniFiControllerCache;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user