From 59d5cbf05aafa13ad24e783243c851c9e3965aa9 Mon Sep 17 00:00:00 2001 From: Gilbert Sanchez Date: Sun, 22 Mar 2026 19:05:16 -0700 Subject: [PATCH] =?UTF-8?q?feat(WLED):=20=E2=9C=A8=20Add=20WLED=20device?= =?UTF-8?q?=20support=20with=20connection,=20disconnection,=20and=20state?= =?UTF-8?q?=20management=20functions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Introduced `Connect-WLED`, `Disconnect-WLED`, `Get-WLED`, `Send-WLED`, and `Set-WLED` functions for managing WLED devices. * Added `WLED` type and formatting support in `LightScript.format.ps1xml`. * Updated `LightScript.psd1` to include `WLED` in the tags. * Regenerated type and format XML files using EZOut 2.0.6. * Enhanced documentation for new functions and their usage. --- CLAUDE.md | 74 +++++++ Formatting/WLED.format.ps1 | 1 + Functions/WLED/Connect-WLED.ps1 | 96 +++++++++ Functions/WLED/Disconnect-WLED.ps1 | 41 ++++ Functions/WLED/Get-WLED.ps1 | 133 ++++++++++++ Functions/WLED/Send-WLED.ps1 | 97 +++++++++ Functions/WLED/Set-WLED.ps1 | 312 +++++++++++++++++++++++++++++ LightScript.format.ps1xml | 261 ++++++++++++++++++------ LightScript.psd1 | 2 +- LightScript.types.ps1xml | 2 +- 10 files changed, 958 insertions(+), 61 deletions(-) create mode 100644 CLAUDE.md create mode 100644 Formatting/WLED.format.ps1 create mode 100644 Functions/WLED/Connect-WLED.ps1 create mode 100644 Functions/WLED/Disconnect-WLED.ps1 create mode 100644 Functions/WLED/Get-WLED.ps1 create mode 100644 Functions/WLED/Send-WLED.ps1 create mode 100644 Functions/WLED/Set-WLED.ps1 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bd8e2b4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,74 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +LightScript is a PowerShell module for controlling smart lights (Philips Hue, NanoLeaf, Twinkly, Divoom Pixoo64, Elgato KeyLight, LaMetric Time, WLED). Published to the PowerShell Gallery as `LightScript`. + +## Build & Test Commands + +```powershell +# Import the module locally for development +Import-Module .\LightScript.psd1 -Force -PassThru + +# Run tests (Pester v4, NOT v5) +Install-Module Pester -MaximumVersion 4.99.99 -Scope CurrentUser -SkipPublisherCheck +Import-Module Pester -MaximumVersion 4.99.99 +Invoke-Pester -PassThru + +# Static analysis +Invoke-ScriptAnalyzer -Path .\ -Recurse + +# Regenerate formatting and type definitions (requires EZOut module) +.\LightScript.ezformat.ps1 + +# Regenerate GitHub workflow YAML (requires PSDevOps module) +.\LightScript.GitHubWorkflow.psdevops.ps1 +``` + +## Architecture + +### Module Loading +- `LightScript.psd1` — manifest (version, metadata, exports) +- `LightScript.psm1` — root module; recursively dot-sources all `*-*.ps1` files, then auto-creates aliases mapping light/room names to `Set-HueLight` +- `LightScript.ps1.psm1` — PipeScript template alternative that uses `[include("*-*.ps1")]` + +### Function Organization +One function per file under `Functions/`, grouped by device: +- `Functions/Hue/` — Philips Hue (largest: ~32 functions). `Send-HueBridge` is the central HTTP dispatch. +- `Functions/NanoLeaf/` — `Send-NanoLeaf` is the HTTP dispatch. +- `Functions/Twinkly/` +- `Functions/Pixoo/` +- `Functions/KeyLight/` +- `Functions/LaMetric/` +- `Functions/WLED/` — WLED open-source LED controllers. `Send-WLED` is the HTTP dispatch. No auth required. + +Each device follows the pattern: `Find-*`, `Connect-*`, `Disconnect-*`, `Get-*`, `Set-*` with a shared `Send-*` function for REST communication. + +### Type System +- `Types/` — custom PowerShell type extensions (method `.ps1` files) for types like `Hue.Sensor`, `LightScript.Color`, `LaMetric.Icon`, `LaMetric.Time.Notification` +- `LightScript.types.ps1xml` — generated by EZOut from `Types/` directory +- `LightScript.format.ps1xml` — generated by EZOut from `Formatting/` directory +- Functions assign `PSTypeName` (e.g., `Hue.Light`, `Hue.Group`) to enable custom formatting + +### Code Generation Pipeline +The build uses several Start-Automating modules: +- **PipeScript** — generates functions from `.ps1.ps1` templates (e.g., `Add-HueLight.ps1.ps1` → `Add-HueLight.ps1`) +- **EZOut** — `LightScript.ezformat.ps1` generates `.format.ps1xml` and `.types.ps1xml` from `Formatting/` and `Types/` +- **HelpOut** — generates help documentation from comment-based help +- **PSDevOps** — `LightScript.GitHubWorkflow.psdevops.ps1` generates `.github/workflows/` YAML + +### CI/CD (GitHub Actions — `TestAndPublish.yml`) +1. **PowerShellStaticAnalysis** — ScriptCop + PSScriptAnalyzer +2. **TestPowerShellOnLinux** — Pester (pinned to 4.99.99), with code coverage +3. **TagReleaseAndPublish** — tags version, creates GitHub release, publishes to PSGallery (only on merged PRs) +4. **BuildModule** — PipeScript + EZOut + HelpOut + +## Conventions + +- Functions use PowerShell `Verb-Noun` naming with device prefix: `Get-HueLight`, `Set-NanoLeaf`, `Connect-Pixoo` +- Heavy use of `[ComponentModel.DefaultBindingProperty]` and `[ComponentModel.AmbientValue]` attributes for pipeline binding +- Connection info (IP addresses, auth tokens) stored in `$home/LightScript/` directory +- All `Set-*` commands support `ShouldProcess` (WhatIf/Confirm) +- `LightScript.Color` type provides RGB/HSL/color-temperature conversions with daisy-chaining support diff --git a/Formatting/WLED.format.ps1 b/Formatting/WLED.format.ps1 new file mode 100644 index 0000000..b936527 --- /dev/null +++ b/Formatting/WLED.format.ps1 @@ -0,0 +1 @@ +Write-FormatView -TypeName WLED -Property Name, IPAddress, On, Brightness, CurrentEffect, CurrentPalette, LEDCount, Version diff --git a/Functions/WLED/Connect-WLED.ps1 b/Functions/WLED/Connect-WLED.ps1 new file mode 100644 index 0000000..b04add3 --- /dev/null +++ b/Functions/WLED/Connect-WLED.ps1 @@ -0,0 +1,96 @@ +function Connect-WLED { + <# + .Synopsis + Connects to a WLED device + .Description + Connects to a WLED device over WiFi and saves connection information. + WLED is open-source firmware for ESP8266/ESP32 LED controllers with a JSON API. + .Example + Connect-WLED 192.168.1.100 -PassThru + .Link + Get-WLED + .Link + Disconnect-WLED + #> + [OutputType([Nullable], [PSObject])] + param( + # The IP Address for the WLED device. + [Parameter(Mandatory, Position = 0, ValueFromPipelineByPropertyName)] + [Alias('WLEDIPAddress')] + [IPAddress] + $IPAddress, + + # If set, will output the connection information. + [switch] + $PassThru + ) + + begin { + if (-not $script:WLEDCache) { + $script:WLEDCache = @{} + } + if ($home) { + $lightScriptRoot = Join-Path $home -ChildPath LightScript + } + } + + process { + #region Attempt to Contact the Device + $wledData = Invoke-RestMethod -Uri "http://$IPAddress/json" + #endregion Attempt to Contact the Device + + if ($wledData) { + $macAddress = + if ($wledData.info.mac) { + $wledData.info.mac + } + elseif ($PSVersionTable.Platform -like 'Win*' -or -not $PSVersionTable.Platform) { + Get-NetNeighbor | Where-Object IPAddress -eq $IPAddress | Select-Object -ExpandProperty LinkLayerAddress + } + elseif ($ExecutionContext.SessionState.InvokeCommand.GetCommand('nmap', 'Application')) { + nmap -Pn "$IPAddress" | + Where-Object { $_ -like 'MAC Address:*' } | + ForEach-Object { @($_ -split ' ')[2] } + } + + if (-not $macAddress) { + Write-Error "Unable to resolve MAC address for $IPAddress, will not save connection" + return + } + + #region Save Device Information + if ($home) { + if (-not (Test-Path $lightScriptRoot)) { + $createLightScriptDir = New-Item -ItemType Directory -Path $lightScriptRoot + if (-not $createLightScriptDir) { return } + } + + $mainSeg = $wledData.state.seg | Select-Object -First 1 + $wledData.pstypenames.clear() + $wledData.pstypenames.add('WLED') + $wledData | + Add-Member NoteProperty IPAddress $IPAddress -Force -PassThru | + Add-Member NoteProperty MACAddress $macAddress -Force -PassThru | + Add-Member NoteProperty Name $wledData.info.name -Force -PassThru | + Add-Member NoteProperty Version $wledData.info.ver -Force -PassThru | + Add-Member NoteProperty LEDCount $wledData.info.leds.count -Force -PassThru | + Add-Member NoteProperty On $wledData.state.on -Force -PassThru | + Add-Member NoteProperty Brightness $wledData.state.bri -Force -PassThru | + Add-Member NoteProperty CurrentEffect $( + if ($wledData.effects -and $mainSeg) { $wledData.effects[$mainSeg.fx] } + ) -Force -PassThru | + Add-Member NoteProperty CurrentPalette $( + if ($wledData.palettes -and $mainSeg) { $wledData.palettes[$mainSeg.pal] } + ) -Force -PassThru | + Export-Clixml -Path (Join-Path $lightScriptRoot ".$macAddress.wled.clixml") + + $script:WLEDCache["$IPAddress"] = $wledData + } + #endregion Save Device Information + + if ($PassThru) { + $wledData + } + } + } +} diff --git a/Functions/WLED/Disconnect-WLED.ps1 b/Functions/WLED/Disconnect-WLED.ps1 new file mode 100644 index 0000000..daa83f2 --- /dev/null +++ b/Functions/WLED/Disconnect-WLED.ps1 @@ -0,0 +1,41 @@ +function Disconnect-WLED { + <# + .Synopsis + Disconnects a WLED device + .Description + Disconnects a WLED device, removing stored device info + .Example + Disconnect-WLED 192.168.1.100 + .Link + Connect-WLED + #> + [OutputType([Nullable], [PSObject])] + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] + param( + # The IP Address for the WLED device. + [Parameter(Mandatory, Position = 0, ValueFromPipelineByPropertyName)] + [Alias('WLEDIPAddress')] + [IPAddress] + $IPAddress + ) + + begin { + if ($home) { + $lightScriptRoot = Join-Path $home -ChildPath LightScript + } + } + + process { + @(Get-ChildItem -Filter *.wled.clixml -Path $lightScriptRoot -ErrorAction SilentlyContinue) | + ForEach-Object { + $file = $_ + $fileInfo = Import-Clixml -LiteralPath $file.FullName + if ("$($fileInfo.IPAddress)" -eq "$IPAddress" -and $PSCmdlet.ShouldProcess("Remove WLED device '$($fileInfo.Name)' ($($fileInfo.IPAddress))")) { + Remove-Item -LiteralPath $file.FullName -Force + if ($script:WLEDCache) { + $script:WLEDCache.Remove("$IPAddress") + } + } + } + } +} diff --git a/Functions/WLED/Get-WLED.ps1 b/Functions/WLED/Get-WLED.ps1 new file mode 100644 index 0000000..da759d0 --- /dev/null +++ b/Functions/WLED/Get-WLED.ps1 @@ -0,0 +1,133 @@ +function Get-WLED { + <# + .Synopsis + Gets WLED devices + .Description + Gets saved WLED devices and their current state. + Can also list available effects and palettes. + .Example + Get-WLED + .Example + Get-WLED -ListEffect + .Example + Get-WLED -ListPalette + .Example + Get-WLED -Force + .Link + Connect-WLED + .Link + Set-WLED + #> + [CmdletBinding(DefaultParameterSetName = 'ListDevices')] + [OutputType([PSObject])] + param( + # The IP Address for the WLED device. + [Parameter(ValueFromPipelineByPropertyName)] + [Alias('WLEDIPAddress')] + [IPAddress[]] + $IPAddress, + + # If set, will list available effects. + [Parameter(ParameterSetName = 'ListEffect')] + [Alias('Effect', 'Effects')] + [switch] + $ListEffect, + + # If set, will list available palettes. + [Parameter(ParameterSetName = 'ListPalette')] + [Alias('Palette', 'Palettes')] + [switch] + $ListPalette, + + # If set, will refresh device state from the API. + [switch] + $Force + ) + + begin { + if (-not $script:WLEDCache) { + $script:WLEDCache = @{} + } + if ($home) { + $lightScriptRoot = Join-Path $home -ChildPath LightScript + } + } + + process { + #region Default to All Devices + if (-not $IPAddress) { + if ($home -and (-not $script:WLEDCache.Count -or $Force)) { + Get-ChildItem -Path $lightScriptRoot -ErrorAction SilentlyContinue -Filter *.wled.clixml -Force | + Import-Clixml | + ForEach-Object { + if (-not $_) { return } + $script:WLEDCache["$($_.IPAddress)"] = $_ + } + } + $IPAddress = $script:WLEDCache.Keys + if (-not $IPAddress) { return } + } + #endregion Default to All Devices + + foreach ($ip in $IPAddress) { + #region Refresh from Device + if ($Force) { + $wledData = $null + $wledData = Invoke-RestMethod -Uri "http://$ip/json" -ErrorAction SilentlyContinue + if ($wledData) { + $mainSeg = $wledData.state.seg | Select-Object -First 1 + $wledData.pstypenames.clear() + $wledData.pstypenames.add('WLED') + $script:WLEDCache["$ip"] = $wledData | + Add-Member NoteProperty IPAddress ([IPAddress]"$ip") -Force -PassThru | + Add-Member NoteProperty MACAddress $wledData.info.mac -Force -PassThru | + Add-Member NoteProperty Name $wledData.info.name -Force -PassThru | + Add-Member NoteProperty Version $wledData.info.ver -Force -PassThru | + Add-Member NoteProperty LEDCount $wledData.info.leds.count -Force -PassThru | + Add-Member NoteProperty On $wledData.state.on -Force -PassThru | + Add-Member NoteProperty Brightness $wledData.state.bri -Force -PassThru | + Add-Member NoteProperty CurrentEffect $( + if ($wledData.effects -and $mainSeg) { $wledData.effects[$mainSeg.fx] } + ) -Force -PassThru | + Add-Member NoteProperty CurrentPalette $( + if ($wledData.palettes -and $mainSeg) { $wledData.palettes[$mainSeg.pal] } + ) -Force -PassThru + } else { + Write-Warning "WLED device at $ip is not reachable" + } + } + #endregion Refresh from Device + + $device = $script:WLEDCache["$ip"] + if (-not $device) { continue } + + if ($ListEffect) { + if ($device.effects) { + for ($i = 0; $i -lt $device.effects.Count; $i++) { + [PSCustomObject]@{ + PSTypeName = 'WLED.Effect' + Id = $i + Name = $device.effects[$i] + IPAddress = $device.IPAddress + } + } + } + } + elseif ($ListPalette) { + if ($device.palettes) { + for ($i = 0; $i -lt $device.palettes.Count; $i++) { + [PSCustomObject]@{ + PSTypeName = 'WLED.Palette' + Id = $i + Name = $device.palettes[$i] + IPAddress = $device.IPAddress + } + } + } + } + else { + $device + } + } + } +} diff --git a/Functions/WLED/Send-WLED.ps1 b/Functions/WLED/Send-WLED.ps1 new file mode 100644 index 0000000..e8d59aa --- /dev/null +++ b/Functions/WLED/Send-WLED.ps1 @@ -0,0 +1,97 @@ +function Send-WLED { + <# + .Synopsis + Sends messages to a WLED device + .Description + Sends HTTP messages to a WLED device's JSON API + .Example + Send-WLED -IPAddress 192.168.1.100 -Command "state" -Method POST -Data @{on=$true} + .Example + Send-WLED -IPAddress 192.168.1.100 -Command "info" + .Link + Get-WLED + .Link + Set-WLED + #> + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low')] + [OutputType([PSObject])] + param( + # The IP Address of the WLED device. + [Parameter(Mandatory, ValueFromPipelineByPropertyName)] + [Alias('WLEDIPAddress')] + [IPAddress] + $IPAddress, + + # The URI fragment to send to the WLED device (e.g., "state", "info", "eff", "pal"). + [string] + $Command, + + # The HTTP method to send. + [Parameter(ValueFromPipelineByPropertyName)] + [string] + $Method = 'GET', + + # The data to send. This will be converted into JSON if it is not a string. + [Parameter(ValueFromPipelineByPropertyName)] + [PSObject] + $Data, + + # The typename of the results. + [Parameter(ValueFromPipelineByPropertyName)] + [string[]] + $PSTypeName + ) + + process { + #region Handle Broadcasting Recursively + if ($IPAddress -in [IPAddress]::Any, [IPAddress]::Broadcast) { + $splat = @{} + $PSBoundParameters + $splat.Remove('IPAddress') + foreach ($val in $script:WLEDCache.Values) { + $splat['IPAddress'] = $val.IPAddress + Send-WLED @splat + } + return + } + #endregion Handle Broadcasting Recursively + + $uri = "http://$IPAddress/json" + if ($Command) { + $uri = "http://$IPAddress/json/$Command" + } + + $splat = @{ + Uri = $uri + Method = $Method + } + + if ($Data) { + if ($Data -is [string]) { + $splat.Body = $Data + } else { + $splat.Body = ConvertTo-Json -Compress -Depth 10 -InputObject $Data + } + $splat.ContentType = 'application/json' + } + + if ($WhatIfPreference) { + return $splat + } + + if (-not $PSCmdlet.ShouldProcess("$($splat.Method) $($splat.Uri)")) { return } + + Invoke-RestMethod @splat 2>&1 | + ForEach-Object { + $in = $_ + if (-not $in -or $in -eq 'null') { return } + if ($PSTypeName -and $in -isnot [Management.Automation.ErrorRecord]) { + $in.PSTypeNames.Clear() + foreach ($t in $PSTypeName) { + $in.PSTypeNames.Add($t) + } + } + $in | + Add-Member NoteProperty IPAddress $IPAddress -Force -PassThru + } + } +} diff --git a/Functions/WLED/Set-WLED.ps1 b/Functions/WLED/Set-WLED.ps1 new file mode 100644 index 0000000..1ab8662 --- /dev/null +++ b/Functions/WLED/Set-WLED.ps1 @@ -0,0 +1,312 @@ +function Set-WLED { + <# + .Synopsis + Sets WLED device state + .Description + Changes state on one or more WLED devices. + .Example + Set-WLED -On + .Example + Set-WLED -Brightness .5 + .Example + Set-WLED -RGBColor "#FF0000" + .Example + Set-WLED -EffectName "Rainbow" -EffectSpeed 128 + .Example + Set-WLED -PaletteName "Ocean" -EffectName "Colorwaves" + .Example + Set-WLED -SegmentId 1 -RGBColor "#00FF00" -Brightness .8 + .Example + Set-WLED -Preset 1 + .Link + Get-WLED + .Link + Send-WLED + #> + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSShouldProcess", "", Justification = "Handled in process block")] + param( + # One or more IP Addresses of WLED devices. + [Parameter(ValueFromPipelineByPropertyName)] + [Alias('WLEDIPAddress')] + [IPAddress[]] + $IPAddress, + + # If set, will turn the light on. + [Parameter(ValueFromPipelineByPropertyName)] + [switch] + $On, + + # If set, will turn the light off. + [Parameter(ValueFromPipelineByPropertyName)] + [switch] + $Off, + + # The brightness of the light (0 to 1). + [Parameter(ValueFromPipelineByPropertyName)] + [ValidateRange(0, 1)] + [Alias('Luminance', 'B')] + [double] + $Brightness, + + # The RGB color as a hex string (e.g., "#FF0000" for red). + [Parameter(ValueFromPipelineByPropertyName)] + [ValidatePattern('^#[\da-fA-F]{6}$')] + [Alias('Color')] + [string] + $RGBColor, + + # The color temperature in Kelvin (1900-10091). + [Parameter(ValueFromPipelineByPropertyName)] + [ValidateRange(1900, 10091)] + [Alias('CT', 'CCT', 'TemperatureKelvin')] + [int] + $ColorTemperature, + + # The transition time for this change. + [Parameter(ValueFromPipelineByPropertyName)] + [Timespan] + $Transition, + + # The name or ID of the effect. + [ArgumentCompleter({ + param ($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + $effects = @( + foreach ($device in $script:WLEDCache.Values) { + if ($device.effects) { $device.effects } + } + ) | Select-Object -Unique + if ($wordToComplete) { + $toComplete = $wordToComplete -replace "^'" -replace "'$" + @($effects -like "$toComplete*" -replace '^', "'" -replace '$', "'") + } else { + @($effects -replace '^', "'" -replace '$', "'") + } + })] + [Parameter(ValueFromPipelineByPropertyName)] + [Alias('Effect', 'FX')] + [string] + $EffectName, + + # The effect speed (0 to 255). + [Parameter(ValueFromPipelineByPropertyName)] + [ValidateRange(0, 255)] + [Alias('Speed', 'SX')] + [int] + $EffectSpeed, + + # The effect intensity (0 to 255). + [Parameter(ValueFromPipelineByPropertyName)] + [ValidateRange(0, 255)] + [Alias('Intensity', 'IX')] + [int] + $EffectIntensity, + + # The name or ID of the color palette. + [ArgumentCompleter({ + param ($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + $palettes = @( + foreach ($device in $script:WLEDCache.Values) { + if ($device.palettes) { $device.palettes } + } + ) | Select-Object -Unique + if ($wordToComplete) { + $toComplete = $wordToComplete -replace "^'" -replace "'$" + @($palettes -like "$toComplete*" -replace '^', "'" -replace '$', "'") + } else { + @($palettes -replace '^', "'" -replace '$', "'") + } + })] + [Parameter(ValueFromPipelineByPropertyName)] + [Alias('Palette')] + [string] + $PaletteName, + + # The preset ID to load (-1 to 250). + [Parameter(ValueFromPipelineByPropertyName)] + [ValidateRange(-1, 250)] + [int] + $Preset, + + # The segment ID to target. Defaults to the main segment (0). + [Parameter(ValueFromPipelineByPropertyName)] + [Alias('Segment')] + [int] + $SegmentId = 0 + ) + + begin { + if (-not $script:WLEDCache) { + $script:WLEDCache = @{} + } + if ($home) { + $lightScriptRoot = Join-Path $home -ChildPath LightScript + } + } + + process { + $paramCopy = @{} + $PSBoundParameters + + #region Default to All Devices + if (-not $IPAddress) { + if ($home -and -not $script:WLEDCache.Count) { + Get-ChildItem -Path $lightScriptRoot -ErrorAction SilentlyContinue -Filter *.wled.clixml -Force | + Import-Clixml | + ForEach-Object { + if (-not $_) { return } + $script:WLEDCache["$($_.IPAddress)"] = $_ + } + $IPAddress = $script:WLEDCache.Keys + } + elseif ($script:WLEDCache.Count) { + $IPAddress = $script:WLEDCache.Keys + } + if (-not $IPAddress) { return } + } + #endregion Default to All Devices + + foreach ($ip in $IPAddress) { + $stateData = [Ordered]@{} + $segData = [Ordered]@{ id = $SegmentId } + $hasSegData = $false + + #region Power + if ($On -and -not $Off) { + $stateData['on'] = $true + } + if ($Off) { + $stateData['on'] = $false + } + #endregion Power + + #region Brightness + if ($paramCopy.ContainsKey('Brightness')) { + $stateData['bri'] = [int][Math]::Round($Brightness * 255) + } + #endregion Brightness + + #region Transition + if ($paramCopy.ContainsKey('Transition')) { + $stateData['tt'] = [int][Math]::Round($Transition.TotalMilliseconds / 100) + } + #endregion Transition + + #region Preset + if ($paramCopy.ContainsKey('Preset')) { + $stateData['ps'] = $Preset + } + #endregion Preset + + #region RGB Color + if ($RGBColor) { + $hexColor = $RGBColor -replace '^#' + $r = [Convert]::ToInt32($hexColor.Substring(0, 2), 16) + $g = [Convert]::ToInt32($hexColor.Substring(2, 2), 16) + $b = [Convert]::ToInt32($hexColor.Substring(4, 2), 16) + $segData['col'] = @(, @($r, $g, $b)) + $hasSegData = $true + } + #endregion RGB Color + + #region Color Temperature + if ($paramCopy.ContainsKey('ColorTemperature')) { + $segData['cct'] = $ColorTemperature + $hasSegData = $true + } + #endregion Color Temperature + + #region Effect + if ($EffectName) { + if ($EffectName -match '^\d+$') { + $segData['fx'] = [int]$EffectName + } + elseif ($EffectName -in '~', '~-', 'r') { + $segData['fx'] = $EffectName + } + else { + $cachedDevice = $script:WLEDCache["$ip"] + if ($cachedDevice -and $cachedDevice.effects) { + $effectId = $null + for ($i = 0; $i -lt $cachedDevice.effects.Count; $i++) { + if ($cachedDevice.effects[$i] -eq $EffectName) { + $effectId = $i + break + } + } + if ($null -eq $effectId) { + Write-Error "Effect '$EffectName' not found on WLED device $ip" + continue + } + $segData['fx'] = $effectId + } else { + Write-Error "No cached effect list for WLED device $ip. Run Get-WLED first." + continue + } + } + $hasSegData = $true + } + #endregion Effect + + #region Effect Speed and Intensity + if ($paramCopy.ContainsKey('EffectSpeed')) { + $segData['sx'] = $EffectSpeed + $hasSegData = $true + } + if ($paramCopy.ContainsKey('EffectIntensity')) { + $segData['ix'] = $EffectIntensity + $hasSegData = $true + } + #endregion Effect Speed and Intensity + + #region Palette + if ($PaletteName) { + if ($PaletteName -match '^\d+$') { + $segData['pal'] = [int]$PaletteName + } + elseif ($PaletteName -in '~', '~-', 'r') { + $segData['pal'] = $PaletteName + } + else { + $cachedDevice = $script:WLEDCache["$ip"] + if ($cachedDevice -and $cachedDevice.palettes) { + $paletteId = $null + for ($i = 0; $i -lt $cachedDevice.palettes.Count; $i++) { + if ($cachedDevice.palettes[$i] -eq $PaletteName) { + $paletteId = $i + break + } + } + if ($null -eq $paletteId) { + Write-Error "Palette '$PaletteName' not found on WLED device $ip" + continue + } + $segData['pal'] = $paletteId + } else { + Write-Error "No cached palette list for WLED device $ip. Run Get-WLED first." + continue + } + } + $hasSegData = $true + } + #endregion Palette + + if ($hasSegData) { + $stateData['seg'] = @($segData) + } + + if ($stateData.Count) { + $body = ConvertTo-Json $stateData -Compress -Depth 10 + if ($WhatIfPreference) { + [PSCustomObject]@{ + Uri = "http://$ip/json/state" + Method = 'POST' + Body = $body + } + } + elseif ($PSCmdlet.ShouldProcess("WLED $ip", "$body")) { + $null = Invoke-RestMethod -Uri "http://$ip/json/state" -Method POST -Body $body -ContentType 'application/json' + } + } + } + } +} diff --git a/LightScript.format.ps1xml b/LightScript.format.ps1xml index 7e26b66..4f8f9a8 100644 --- a/LightScript.format.ps1xml +++ b/LightScript.format.ps1xml @@ -1,5 +1,5 @@ - + @@ -289,24 +289,24 @@ $(@(foreach ($_ in $actionLines) { } while ($false) - - $__ = $_ - $ci = . { + + $CellColorValue = $($Script:_LastCellStyle = $($__ = $_;. { if ($_.Status -eq 'Enabled') { '#00ff00' } else { '#ff0000' } -} - $_ = $__ - if ($ci -is [string]) { - $ci = & ${LightScript_Format-RichText} -NoClear -ForegroundColor $ci - } else { - $ci = & ${LightScript_Format-RichText} -NoClear @ci - } - $output = . {$_.'Status'} - @($ci; $output; & ${LightScript_Format-RichText}) -join "" - +};$_ = $__);$Script:_LastCellStyle) + + if ($CellColorValue -and $CellColorValue -is [string]) { + $CellColorValue = & ${LightScript_Format-RichText} -NoClear -ForegroundColor $CellColorValue + } elseif (`$CellColorValue -is [Collections.IDictionary]) { + $CellColorValue = & ${LightScript_Format-RichText} -NoClear @CellColorValue + } + + $output = . {$_.'Status'} + @($CellColorValue; $output; & ${LightScript_Format-RichText}) -join '' + Time @@ -830,6 +830,62 @@ New-Object PSObject -Property ([Ordered]@{ + + WLED + + WLED + + + + + + + + + + + + + + + + + + + + + + + + + Name + + + IPAddress + + + On + + + Brightness + + + CurrentEffect + + + CurrentPalette + + + LEDCount + + + Version + + + + + + @@ -853,7 +909,7 @@ New-Object PSObject -Property ([Ordered]@{ .Notes Stylized Output works in two contexts at present: * Rich consoles (Windows Terminal, PowerShell.exe, Pwsh.exe) (when $host.UI.SupportsVirtualTerminal) - * Web pages (Based off the presence of a $Request variable, or when $host.UI.SupportsHTML (you must add this property to $host.UI)) + * Web pages (Based off the presence of a $Request variable, or when $host.UI.SupportsHTML (you must add this property to $host.UI)) #> [Management.Automation.Cmdlet("Format","Object")] [ValidateScript({ @@ -862,12 +918,13 @@ New-Object PSObject -Property ([Ordered]@{ if (-not ($canUseANSI -or $canUseHTML)) { return $false} return $true })] + [OutputType([string])] param( # The input object [Parameter(ValueFromPipeline)] [PSObject] $InputObject, - + # The foreground color [string]$ForegroundColor, @@ -902,8 +959,23 @@ New-Object PSObject -Property ([Ordered]@{ # If set, will invert text [switch]$Invert, + + # If provided, will create a hyperlink to a given uri + [Alias('Hyperlink', 'Href')] + [uri] + $Link, + # If set, will not clear formatting - [switch]$NoClear + [switch]$NoClear, + + # The alignment. Defaulting to Left. + # Setting an alignment will pad the remaining space on each line. + [ValidateSet('Left','Right','Center')] + [string] + $Alignment, + + # The length of a line. By default, the buffer width + [int]$LineLength = $($host.UI.RawUI.BufferSize.Width) ) begin { @@ -913,12 +985,27 @@ New-Object PSObject -Property ([Ordered]@{ Output='';Error='BrightRed';Warning='BrightYellow'; Verbose='BrightCyan';Debug='Yellow';Progress='Cyan'; Success='BrightGreen';Failure='Red';Default=''} + + $ansiCode = [Regex]::new(@' + (?<ANSI_Code> + (?-i)\e # An Escape + \[ # Followed by a bracket + (?<ParameterBytes>[\d\:\;\<\=\>\?]{0,}) # Followed by zero or more parameter + bytes + (?<IntermediateBytes>[\s\!\"\#\$\%\&\'\(\)\*\+\,\-\.\/]{0,}) # Followed by zero or more + intermediate bytes + (?<FinalByte>[\@ABCDEFGHIJKLMNOPQRSTUVWXYZ\[\\\]\^_\`abcdefghijklmnopqrstuvwxyz\{\|\}\~]) # Followed by a final byte + + ) +'@) $esc = [char]0x1b $standardColors = 'Black', 'Red', 'Green', 'Yellow', 'Blue','Magenta', 'Cyan', 'White' $brightColors = 'BrightBlack', 'BrightRed', 'BrightGreen', 'BrightYellow', 'BrightBlue','BrightMagenta', 'BrightCyan', 'BrightWhite' + $allOutput = @() + $n =0 - $cssClasses = @() + $cssClasses = @() $colorAttributes = @(:nextColor foreach ($hc in $ForegroundColor,$BackgroundColor) { $n++ @@ -1095,6 +1182,21 @@ New-Object PSObject -Property ([Ordered]@{ if ($canUseHTML) { "border-bottom: 3px double;"} elseif ($canUseANSI) {'' +$esc + "[21m" } } + + if ($Alignment -and $canUseHTML) { + "display:block;text-align:$($Alignment.ToLower())" + } + + if ($Link) { + if ($canUseHTML) { + # Hyperlinks need to be a nested element + # so we will not add it to style attributes for HTML + } + elseif ($canUseANSI) { + # For ANSI, + '' + $esc + ']8;;' + $Link + $esc + '\' + } + } ) @@ -1104,61 +1206,102 @@ New-Object PSObject -Property ([Ordered]@{ if ($styleAttributes) { " style='$($styleAttributes -join ';')'"} )$( if ($cssClasses) { " class='$($cssClasses -join ' ')'"} - )>" + )>" + $( + if ($Link) { + "<a href='$link'>" + } + ) } elseif ($canUseANSI) { $styleAttributes -join '' } } process { - if ($header) { - "$header" + "$(if ($inputObject) { $inputObject | Out-String})".Trim() - } - elseif ($inputObject) { - ($inputObject | Out-String).Trim() - } + $inputObjectAsString = + "$(if ($inputObject) { $inputObject | Out-String})".Trim() + + $inputObjectAsString = + if ($Alignment -and -not $canUseHTML) { + (@(foreach ($inputObjectLine in ($inputObjectAsString -split '(?>\r\n|\n)')) { + $inputObjectLength = $ansiCode.Replace($inputObjectLine, '').Length + if ($inputObjectLength -lt $LineLength) { + if ($Alignment -eq 'Left') { + $inputObjectLine + } elseif ($Alignment -eq 'Right') { + (' ' * ($LineLength - $inputObjectLength)) + $inputObjectLine + } else { + $half = ($LineLength - $inputObjectLength)/2 + (' ' * [Math]::Floor($half)) + $inputObjectLine + + (' ' * [Math]::Ceiling($half)) + } + } + else { + $inputObjectLine + } + }) -join [Environment]::NewLine) + [Environment]::newline + } else { + $inputObjectAsString + } + + $allOutput += + if ($header) { + "$header" + $inputObjectAsString + } + elseif ($inputObject) { + $inputObjectAsString + } } end { if (-not $NoClear) { - if ($canUseHTML) { - "</span>" - } - elseif ($canUseANSI) { - if ($Bold -or $Faint -or $colorAttributes -match '\[1;') { - "$esc[22m" - } - if ($Italic) { - "$esc[23m" - } - if ($Underline -or $doubleUnderline) { - "$esc[24m" - } - if ($Blink) { - "$esc[25m" - } - if ($Invert) { - "$esc[27m" - } - if ($hide) { - "$esc[28m" - } - if ($Strikethru) { - "$esc[29m" - } - if ($ForegroundColor) { - "$esc[39m" - } - if ($BackgroundColor) { - "$esc[49m" + $allOutput += + if ($canUseHTML) { + if ($Link) { + "</a>" + } + "</span>" } - - if (-not ($Underline -or $Bold -or $Invert -or $ForegroundColor -or $BackgroundColor)) { - '' + $esc + '[0m' + elseif ($canUseANSI) { + if ($Bold -or $Faint -or $colorAttributes -match '\[1;') { + "$esc[22m" + } + if ($Italic) { + "$esc[23m" + } + if ($Underline -or $doubleUnderline) { + "$esc[24m" + } + if ($Blink) { + "$esc[25m" + } + if ($Invert) { + "$esc[27m" + } + if ($hide) { + "$esc[28m" + } + if ($Strikethru) { + "$esc[29m" + } + if ($ForegroundColor) { + "$esc[39m" + } + if ($BackgroundColor) { + "$esc[49m" + } + + if ($Link) { + "$esc]8;;$esc\" + } + + if (-not ($Underline -or $Bold -or $Invert -or $ForegroundColor -or $BackgroundColor)) { + '' + $esc + '[0m' + } } - } } + + $allOutput -join '' } diff --git a/LightScript.psd1 b/LightScript.psd1 index 2152b37..8193d20 100644 --- a/LightScript.psd1 +++ b/LightScript.psd1 @@ -9,7 +9,7 @@ Copyright = '2021 Start-Automating' PrivateData = @{ PSData = @{ - Tags = 'IoT','Hue', 'Twinkly', 'NanoLeaf', 'Pixoo', 'Divoom','KeyLight','LaMetricTime' + Tags = 'IoT','Hue', 'Twinkly', 'NanoLeaf', 'Pixoo', 'Divoom','KeyLight','LaMetricTime','WLED' ProjectURI = 'https://github.com/StartAutomating/LightScript' LicenseURI = 'https://github.com/StartAutomating/LightScript/blob/main/LICENSE' IconURI = 'https://github.com/StartAutomating/LightScript/blob/main/Assets/LightScript.png' diff --git a/LightScript.types.ps1xml b/LightScript.types.ps1xml index d456b87..d459de2 100644 --- a/LightScript.types.ps1xml +++ b/LightScript.types.ps1xml @@ -1,5 +1,5 @@ - + Hue.Sensor