conbee-api-client.psm1
|
$script:ConbeeVaultName = "ConbeeVault-Client" $script:DefaultConbeeApiSecretName = "ConbeeApiToken" $script:SensorsToIgnoreXMLPath = "$($env:HOME)/SensorsToIgnore.clixml" $script:TriggerSensorsXMLPath = "$($env:HOME)/TriggerSensors.clixml" ## Secret vault fun # https://learn.microsoft.com/en-us/powershell/utility-modules/secretmanagement/get-started/using-secretstore?view=ps-modules #region SecretVaultFunctions Function Set-NonInteractiveConbeeVault { [CmdletBinding()] param ( [string]$vaultName = $script:ConbeeVaultName ) # You can't set some vaults to be non-interactive and some to be interactive. It's all or nothing. # If you want an interactive vault, simply run, set-conbeevault. # Even in this non-interactive mode, you'll need to set an initial password, once the config is complete you won't be # prompted for this again. Write-Warning "This will set ALL VAULTS to non-interactive, no authentication mode. Think about this wisely." Set-SecretStoreConfiguration -Interaction None -Authentication None -Scope CurrentUser Set-Conbeevault -vaultName $vaultName } Function Set-Conbeevault { [CmdletBinding()] param ( [string]$vaultName = $script:ConbeeVaultName ) Register-SecretVault -Name $vaultName -ModuleName Microsoft.PowerShell.SecretStore Get-SecretVault -Name $vaultName } Function Set-ApiTokenToVault { [CmdletBinding()] param ( [string]$secretName = $script:DefaultConbeeApiSecretName, [string]$vaultName = $script:ConbeeVaultName ) $apiToken = Read-Host -Prompt "Enter the API token for the Conbee API" -AsSecureString Set-Secret -Name $secretName -Secret $apiToken -Vault $vaultName } Function Get-ApiTokenFromVault { [CmdletBinding()] param ( [string]$secretName = $script:DefaultConbeeApiSecretName, [string]$vaultName = $script:ConbeeVaultName ) Get-Secret -Name $secretName -Vault $vaultName } #endregion #region Generic Functions Function ConvertTo-FlatObject { # I must admit, I hate approved verbs. # In short, this returns all the property values within the parent PsCustomObject. [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] $PsObj ) process { $PsObj | ForEach-Object { $r = $_.PSObject.Properties.Value; $r} } } Function Test-NullableParamWithinRange { [CmdletBinding()] param ( [Nullable[int]]$Value, [Parameter(Mandatory)] [int]$Min, [Parameter(Mandatory)] [int]$Max ) # Allows nullable params to be range validated. if ($null -ne $Value -and ($Value -lt $Min -or $Value -gt $Max)) { Write-Error "Value must be between $Min and $Max" } else { $Value } } #endregion #region ConbeeSession class ConbeeConfig { [string]$Hostname = "127.0.0.1" [securestring]$Token [bool]$Ssl = $false } Function New-ConbeeConfig { [ConbeeConfig]::new() } Function New-ConbeeSession { [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [ConbeeConfig]$ConbeeConfig ) $script:ConbeeHostName = $ConbeeConfig.Hostname $script:BaseUri = "$(if ($ConbeeConfig.Ssl) {'https'} else {'http'})://$($ConbeeConfig.Hostname)" $script:Token = if (-not $ConbeeConfig.Token) {Get-ApiTokenFromVault} else {$ConbeeConfig.Token} } Function New-ConbeeSessionUsingVault { [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$HostName ) $conf = New-ConbeeConfig $conf.Token = Get-ApiTokenFromVault $conf.Hostname = $HostName $conf | New-ConbeeSession $conf } #endregion #region CoreApiWrappers Function New-ConbeeApiCall { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Method, [Parameter(Mandatory)] [string]$Endpoint, [hashtable]$Data ) $params = @{ Uri = "$($script:BaseUri)/api/$($script:Token | ConvertFrom-SecureString -AsPlainText)/$endpoint/" Method = $method Headers = @{Accept = "application/json"} } if ($Data) { $params.Add("Body", ($Data | ConvertTo-Json -ErrorAction Stop -Depth 10)) $params.Headers.Add("Content-Type", "application/json") } Invoke-RestMethod @params } Function Add-ApiIdToSensor { [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [PSCustomObject]$Sensors ) end { foreach ($sensor in $Sensors.PSObject.Properties) { $sensor.Value | Add-Member -Type NoteProperty -Name ApiId -Value $sensor.Name -Force } $Sensors } } #endregion #region ZHASwitch Specific Config Function Add-Any_OnTargetValue { [CmdletBinding()] # Specific to ZHASwitches and a little odd to hold. This will allow you to set a ZHASwitch to either turn a group # on or off when a button is pressed. param ( [Parameter(Mandatory, ValueFromPipeline)] [PSCustomObject]$Sensors, [bool]$Any_OnTargetValue = $True # Default to true, i.e. set the Group to 'on' ) process { $Sensors | Add-Member -Type NoteProperty -Name Any_OnTargetValue -Value $Any_OnTargetValue -Force } end { $Sensors } } #endregion #region ConbeeConfig Function Get-ConbeeConfig { New-ConbeeApiCall -Method GET -Endpoint "config" } #endregion #region SensorManagement Function ConvertTo-FlatSensor { [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [PSCustomObject]$Sensor ) process { $_ | ForEach-Object { if ($_.PSObject.Properties.Name -contains "uniqueid") { # Already flattened, use as-is $_ } else { $_ | ConvertTo-FlatObject } } } } Function Export-SensorsToIgnore { [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [PSCustomObject]$Sensors ) $Sensors | Export-Clixml -Path $script:SensorsToIgnoreXMLPath -Force } Function Import-SensorsToIgnore { Import-Clixml -Path $script:SensorsToIgnoreXMLPath -ErrorAction SilentlyContinue } Function Import-TriggerSensors { Import-Clixml -Path $script:TriggerSensorsXMLPath -ErrorAction SilentlyContinue } Function Export-TriggerSensors { [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [PSCustomObject]$Sensors ) $Sensors | Export-Clixml -Path $script:TriggerSensorsXMLPath -Force } Function Get-SensorsFromProperties { [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [PSCustomObject]$Sensors ) process { $Sensors | Get-Member -MemberType NoteProperty } } Function New-SensorTriggerConfig { [pscustomobject]@{ TriggerGroup = $null IgnoreDaylight = $false } } Function Add-TriggerConfigToSensors { [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [PSCustomObject]$Sensors, [Parameter(Mandatory)] [pscustomobject]$TriggerConfig ) process { $Sensors | ForEach-Object { $sensor = $_ $TriggerConfig.PSObject.Properties | ForEach-Object { $sensor | Add-Member -Type NoteProperty -Name $_.Name -Value $_.Value -Force } $sensor } } } Function Add-SensorToClixml { [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline)] [PSCustomObject]$Sensors, [PSCustomObject]$SensorXml ) begin { if ($SensorXml -eq $null) { $SensorXml = [PSCustomObject]@{} } $nextVal = [int]($SensorXml | Get-SensorsFromProperties | Sort-Object { [int]$_.Name } -Descending | Select-Object -First 1 -ExpandProperty Name) + 1 } process { foreach ($Sensor in $Sensors) { if (-not [bool](Get-SensorsByUniqueID -Sensors $SensorXml -SensorToCheck $Sensor)) { $SensorXml | Add-Member -Type NoteProperty -Name $nextVal -Value $Sensor | out-null $nextVal += 1 } else { Write-Warning "Sensor with UniqueID $($Sensor.UniqueID) already exists, skipping add. Use Remove-SensorFromTriggers to remove it first if you want to re-add it." } } } end { $SensorXml } } Function Add-SensorToIgnore { [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [PSCustomObject]$Sensors ) process { $sensors | ConvertTo-FlatSensor | Add-SensorToClixml -SensorXml (Import-SensorsToIgnore) | Export-SensorsToIgnore } } Function Add-SensorToTriggers { [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [PSCustomObject]$Sensors ) process { $Sensors | ConvertTo-FlatSensor | ForEach-Object { if (-not $_.TriggerGroup) { Write-Warning "Add TriggerGroup to: $_ via Add-TriggerGroupToSensor"; return } $_ | Add-SensorToClixml -SensorXml (Import-TriggerSensors) | Export-TriggerSensors } } } Function Remove-SensorFromClixml { [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [PSCustomObject]$Sensors, [PSCustomObject]$SensorXml ) process { $Sensors | ConvertTo-FlatSensor | Foreach-Object {$SensorXml = Remove-SensorsByUniqueID $SensorXml $_} } end { $SensorXml } } Function Remove-SensorFromIgnore { [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [PSCustomObject]$Sensors ) process { $sensors | Remove-SensorFromClixml -SensorXml (Import-SensorsToIgnore) | Export-SensorsToIgnore } } Function Remove-SensorFromTriggers { [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [PSCustomObject]$Sensors ) process { $sensors | Remove-SensorFromClixml -SensorXml (Import-TriggerSensors) | Export-TriggerSensors } } #endregion #region Filters/Formatters Filter Get-SensorsByUniqueID { [CmdletBinding()] param ( [Parameter(Mandatory)] [PSCustomObject]$Sensors, [Parameter(Mandatory)] [PSCustomObject]$SensorToCheck ) process { $Sensors | Get-SensorsFromProperties | Where-Object { $Sensors.($_.Name).UniqueID -eq $SensorToCheck.UniqueID } } } Filter Remove-SensorsByUniqueID { [CmdletBinding()] param ( [Parameter(Mandatory)] [PSCustomObject]$Sensors, [Parameter(Mandatory)] [PSCustomObject]$SensorToFilter ) begin { $NewSensorObject = [PSCustomObject]@{} } process { $Sensors | Get-SensorsFromProperties | Where-Object { $Sensors.($_.Name).UniqueID -ne $SensorToFilter.UniqueID } | ForEach-Object { $NewSensorObject |Add-Member -Type NoteProperty -Name $_.Name -Value $sensors.($_.Name) } $NewSensorObject } } Function Format-ZBDevices { [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [PSCustomObject]$ZBDevices ) process { $ZBDevices | Get-SensorsFromProperties | ForEach-Object { $ZBDevices.($_.Name) } } } Filter Set-SensorFilter { [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [PSCustomObject]$Sensors ) begin { $SensorstoIgnore = Import-SensorsToIgnore $IdsToIgnore = $SensorstoIgnore | Get-SensorsFromProperties | ForEach-Object {$SensorstoIgnore.($_.Name).UniqueID} # Note, think about filter Get-SensorsByUniqueID } process { # NOTE(Another-Salad): There is still likely a better way of doing this but here we are. $Sensors | Format-ZBDevices | Where-object {$_.uniqueid -notin $IdsToIgnore} } } Function Test-AnySensorProperty { [CmdletBinding()] param ( [Parameter(Mandatory)] [PSCustomObject]$Sensors, [Parameter(Mandatory)] [ScriptBlock]$Predicate ) [bool]($Sensors | Where-Object $Predicate | Select-Object -First 1) } #endregion #region SensorFunctions $SensorTypes = [pscustomobject]@{ Humidity = "ZHAHumidity" Temperature = "ZHATemperature" Presence = "ZHAPresence" Power = "ZHAPower" Consumption = "ZHAConsumption" LightLevel = "ZHALightLevel" Daylight = "Daylight" Switch = "ZHASwitch" } # Get-AllSensorsRaw | Set-SensorFilter Function Get-AllSensorsRaw { # Ok, this isn't really _raw_ anymore. I'm adding the ID of the sensor to its returned data for an easy life. New-ConbeeApiCall -Method GET -Endpoint "sensors" | Add-ApiIdToSensor } Function Get-FitleredSensorData { [CmdletBinding()] param ( [Parameter(ValueFromPipeline, Mandatory)] [string]$SensorType ) Get-AllSensorsRaw | Set-SensorFilter | Where-Object { $_.type -eq $SensorType } } Function Update-ZHAStateValueToFloat { [CmdletBinding()] param( [Parameter(ValueFromPipeline, Mandatory)] [PSCustomObject]$Sensors ) process { $Sensors | ForEach-Object {$_.state.PSObject.Properties | ForEach-Object {if ($_.Name -in @("temperature", "humidity")) {$_.Value = [math]::round($_.Value / 100, 2)}}} $Sensors } } Function Get-TemperatureSensors { $SensorTypes.Temperature | Get-FitleredSensorData | Update-ZHAStateValueToFloat } Function Get-HumiditySensors { $SensorTypes.Humidity| Get-FitleredSensorData | Update-ZHAStateValueToFloat } Function Get-PresenceSensors { $SensorTypes.Presence | Get-FitleredSensorData } Function Get-PowerSensors { $SensorTypes.Power | Get-FitleredSensorData } Function Get-ConsumptionSensors { $SensorTypes.Consumption | Get-FitleredSensorData } Function Get-LightLevelSensors { $SensorTypes.LightLevel | Get-FitleredSensorData } Function Get-SwitchSensors { $SensorTypes.Switch | Get-FitleredSensorData } Function Get-DaylightSensors { # If you are like me, you have likely ignored the default daylight sensor in the DeConz API. # I found this to mostly be noise, until now ofc. [CmdletBinding()] param( [switch]$IgnoreFilter ) if ($IgnoreFilter) { Get-AllSensorsRaw | Format-ZBDevices | Where-Object { $_.type -eq $SensorTypes.Daylight } } else { $SensorTypes.Daylight | Get-FitleredSensorData } } Function Rename-Sensor { [CmdletBinding()] param( [Parameter(Mandatory)] [PSCustomObject]$Sensor, [Parameter(Mandatory)] [string]$NewName ) # name has to be lower case as the API is case sensitive, fantastic. New-ConbeeApiCall -Method PUT -Endpoint "sensors/$($Sensor.ApiId)" -Data @{name = $NewName} } # Get-LightLevelSensors | Update-SensorConfig -Config @{tholddark = 10000} # Default is 12000 Function Update-SensorConfig { [CmdletBinding()] param( [Parameter(Mandatory,ValueFromPipeline)] [PSCustomObject]$Sensor, [Parameter(Mandatory)] [hashtable]$Config ) process { New-ConbeeApiCall -Method PUT -Endpoint "sensors/$($Sensor.ApiId)/config" -Data $Config } } Function Show-CurrentTemperature { [CmdletBinding()] param() Get-TemperatureSensors | Select-Object name, @{Name='temperature';Expression={ '{0:N2}' -f $_.state.temperature }} | Format-Table -AutoSize } #endregion #region Groups # You'll see interactions with groups which can combine many lights, plugs, etc into single entities. # Plugs are a little interesting in the DeConz API as you cannot (at the time of writing March 2025) # control their state (on/off) directly. However, if they are put into a group (can be a group of one) # then you can. I foresee many interactions with single plugs abstracted via groups. # https://dresden-elektronik.github.io/deconz-rest-doc/endpoints/groups/#set-group-state # Example params: # { # "on": true, # "bri": 180, # "hue": 43680, # "sat": 255, # "transitiontime": 10 # } # I've seen some oddities with the deconz api I need to investigate futher. Setting the brightness to 0 has inconsistent behaviour. # I have seen it 'turn off', but I have also seen 0 set it to a very low brightness. # I've also seen some bulbs get stuck at a certain brightness, and only an on/off toggle will reset them. # OK, I either dreamt of a world where setting bri to 0 would turn off the light, or there has been a change in the API. # https://dresden-elektronik.github.io/deconz-rest-doc/endpoints/groups/#set-group-state # I swear this used to explain that supplying a bri value would supercede the on/off state. # I now see two requests being sent if I supply the api with both an On and a Bri value: # success # ------- # @{/groups/8/action/on=True} # @{/groups/8/action/bri=150} # To turn off the light, I can't send a bri and an on=false, I have to just send on=false. Good. # Function New-LightGroupState { [CmdletBinding()] Param( [Parameter(Mandatory, ValueFromPipeline)] [ValidateScript({$_.type -eq "LightGroup"})] # From deconz API [pscustomobject]$Group, [switch]$Off, # If these aren't explicitly defaulted to $null they'll be a 0, which is a valid value for the API. # $null means "don't set this value". [Nullable[int]]$Brightness = $null, [Nullable[int]]$Hue = $null, [Nullable[int]]$Saturation = $null, [Nullable[int]]$Transitiontime = $null ) Test-NullableParamWithinRange $Brightness -Min 0 -Max 255 | out-null Test-NullableParamWithinRange $Hue -Min 0 -Max 65535 | out-null Test-NullableParamWithinRange $Saturation -Min 0 -Max 254 | out-null Test-NullableParamWithinRange $Transitiontime -Min 1 -Max 10 | out-null [PSCustomObject]@{ PsTypeName = 'LightGroupState' Group = $Group Bri = $Brightness On = !$Off Hue = $Hue Sat = $Saturation Transitiontime = $Transitiontime } } Function Get-AllGroups { New-ConbeeApiCall -Method GET -Endpoint "groups" } Function Get-GroupByName { [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$Name ) Get-AllGroups | ConvertTo-FlatObject | Where-Object {$_.Name -match $Name} } Function Get-GroupAttributes { [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [pscustomobject]$Group ) process { New-ConbeeApiCall -Method GET -Endpoint "groups/$($Group.id)" } } # $conf = Get-GroupByName -Name "Living Room" | New-LightGroupState -Bri 200 # $conf | Set-LightGroupState Function Set-LightGroupState { [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [PsCustomobject][PsTypeName('LightGroupState')]$GroupState ) process { $data = @{} Foreach ($prop in $GroupState.PSObject.Properties) { if ($prop.Name -ne "Group" -and $null -ne $prop.Value) { $data.Add($prop.Name.ToLower(), $prop.Value) } } New-ConbeeApiCall -Method PUT -Endpoint "groups/$($GroupState.Group.id)/action" -Data $data if ($GroupState.Transitiontime) { # item (likely a light has a transistion time which is in 1/10 seconds), wait for it to complete before returning. Start-Sleep -Seconds ([float]$GroupState.Transitiontime / 10) } } } #endregion #region LightGroup Helpers Function Set-LightAcknowledge { # Useful if you want to acknowledge something like a button event when the lights are on. [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline)] [PsCustomobject][PsTypeName('LightGroupState')]$GroupState, [int]$FlickerCount = 1, [switch]$OnOffOnly ) process { if (-not $GroupState.Transitiontime) { Write-Warning "No transition time set, setting to 5 (0.5 seconds) for flicker effect unless you won't see anything." $GroupState.Transitiontime = 5 } $originalBri = $GroupState.Bri for ($i = 0; $i -lt $FlickerCount; $i++) { if ($OnOffOnly) { $GroupState.Bri = $null # Ensure brightness isn't set, as that will override on/off. $GroupState.On = $false $GroupState | Set-LightGroupState $GroupState.On = $true $GroupState | Set-LightGroupState } else { # Flicker by halving brightness, then restoring it. $GroupState.Bri = $GroupState.Bri / 2 $GroupState | Set-LightGroupState $GroupState.Bri = $GroupState.Bri * 2 $GroupState | Set-LightGroupState } } # These API calls are relatively fire and forget, I don't want to potentially spend ages changing state and # confirming the state is correct if these can be lossy, as I just want to room to be bright again. # The point of the above is to give the user some form of feedback that an action has been registered. # I'd prefer that to potentially look a little odd, rather than spend ages getting it perfect. # Lets make sure we are actually at the original brightness here though, as that will be annoying if not. if ($originalBri -ne ($GroupState | Get-GroupAttributes).action.bri) { $GroupState.Bri = $originalBri $GroupState | Set-LightGroupState } } } #endregion #region WebSocket helpers Function New-WsConnection { [CmdletBinding()] param( # Conbee API defaults to port 443 for ws connections, but can be configured to a different port. [int]$Port = 443 ) Add-Type -AssemblyName System.Net.WebSockets.Client $uri = "ws://$($script:ConbeeHostName):$Port" $ws = [System.Net.WebSockets.ClientWebSocket]::new() $ws.ConnectAsync([Uri]$uri, [Threading.CancellationToken]::None).Wait() $ws } Function Close-WsConnection { [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline)] [System.Net.WebSockets.ClientWebSocket]$ws ) process { $ws.CloseAsync([System.Net.WebSockets.WebSocketCloseStatus]::NormalClosure, "Closing", [Threading.CancellationToken]::None).Wait() $ws.Dispose() } } Function Receive-WsData { [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline)] [System.Net.WebSockets.ClientWebSocket]$ws ) begin { $Buffer = [byte[]]::new(1024) } process { $segment = [System.ArraySegment[byte]]::new($buffer) $result = $ws.ReceiveAsync($segment, [Threading.CancellationToken]::None).GetAwaiter().GetResult() [System.Text.Encoding]::UTF8.GetString($Buffer, 0, $result.Count) | ConvertFrom-Json } } #endregion |