<# .SYNOPSIS Light4dCLI - CLI pour la gestion des configurations de sorties d'eclairage 0-10V (ECxLight4D) sur automates Distech Controls Eclypse. .DESCRIPTION Light4dCLI permet de gerer les configurations des sorties d'eclairage pilotees en 0-10V sur des automates Distech Controls Eclypse en utilisant leur API REST v2. Action READ : Lit la configuration des modules ECxLight4D de chaque automate et genere un fichier CSV avec une ligne par sortie (output). Action WRITE : Modifie les proprietes des sorties 0-10V a partir du CSV. Toutes les proprietes de chaque ligne sont reecrites. .PARAMETER Action Action a effectuer : Read - Lire la configuration et generer un CSV de sortie Write - Ecrire les proprietes depuis le CSV vers les automates .PARAMETER CsvInput Chemin vers le fichier CSV d'entree (separateur point-virgule). Pour Read : colonnes minimales Hostname, Current Ip, HttpPort, HttpsPort. Pour Write : CSV enrichi genere par l'action Read. .PARAMETER Username Nom d'utilisateur pour l'authentification API (defaut: admin). Peut etre surcharge par la colonne Username du CSV. .PARAMETER Password Mot de passe pour l'authentification API (defaut: vide). Peut etre surcharge par la colonne Password du CSV. .EXAMPLE .\Light4dCLI.ps1 -Action Read -CsvInput ".\automates.csv" Lit la configuration 0-10V de tous les automates listes dans le CSV. Genere un fichier light4d_YYYY-MM-DD_HHhMM.csv dans le repertoire courant. .EXAMPLE .\Light4dCLI.ps1 -Action Write -CsvInput ".\light4d_2026-08-11_18h00.csv" -Password "MonMotDePasse" Ecrit les proprietes du CSV vers les automates. .EXAMPLE Get-Help .\Light4dCLI.ps1 -Detailed Affiche cette aide detaillee. .NOTES Prerequis : PowerShell 5.1+ (inclus dans Windows 10/11) API : Distech Controls Eclypse REST API v2 Securite : TLS 1.2, certificats auto-signes acceptes, fallback curl.exe Fichier unique pour faciliter le transfert (un seul Unblock-File necessaire). Si Windows bloque le fichier apres telechargement : Unblock-File .\Light4dCLI.ps1 #> param( [Parameter(Mandatory)] [ValidateSet("Read", "Write")] [string]$Action, [Parameter(Mandatory)] [string]$CsvInput, [string]$Username = "admin", [string]$Password = "" ) # Performance : desactiver la barre de progression Invoke-WebRequest $ProgressPreference = 'SilentlyContinue' # ============================================================ #region LOGGER # ============================================================ $script:LogFile = $null $script:Stopwatch = $null $script:Stats = @{ AutomatesTotal = 0 AutomatesError = 0 OutputsProcessed = 0 } function Initialize-Logger { [CmdletBinding()] param() $timestamp = Get-Date -Format "yyyy-MM-dd_HH\hmm" $script:LogFile = Join-Path (Get-Location) "light4d_$timestamp.log" New-Item -ItemType File -Path $script:LogFile -Force | Out-Null $script:Stopwatch = [System.Diagnostics.Stopwatch]::StartNew() $script:Stats = @{ AutomatesTotal = 0 AutomatesError = 0 OutputsProcessed = 0 } Write-Log -Message "Logger initialise - fichier: $($script:LogFile)" -Level INFO } function Write-Log { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Message, [ValidateSet("INFO", "WARN", "ERROR", "SUCCESS")] [string]$Level = "INFO" ) $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $line = "[$timestamp] [$Level] $Message" switch ($Level) { "INFO" { Write-Host $line -ForegroundColor Cyan } "WARN" { Write-Host $line -ForegroundColor Yellow } "ERROR" { Write-Host $line -ForegroundColor Red } "SUCCESS" { Write-Host $line -ForegroundColor Green } } if ($script:LogFile) { Add-Content -Path $script:LogFile -Value $line -Encoding UTF8 } } function Update-Stats { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateSet("AutomatesTotal", "AutomatesError", "OutputsProcessed")] [string]$Counter, [int]$Increment = 1 ) $script:Stats[$Counter] += $Increment } function Write-Summary { [CmdletBinding()] param() $script:Stopwatch.Stop() $duration = $script:Stopwatch.Elapsed Write-Log -Message "========== RESUME ==========" -Level INFO Write-Log -Message "Automates traites : $($script:Stats.AutomatesTotal)" -Level INFO Write-Log -Message "Automates en erreur : $($script:Stats.AutomatesError)" -Level $(if ($script:Stats.AutomatesError -gt 0) { "WARN" } else { "INFO" }) Write-Log -Message "Outputs traites : $($script:Stats.OutputsProcessed)" -Level INFO Write-Log -Message "Duree totale : $($duration.ToString('hh\:mm\:ss\.ff'))" -Level INFO Write-Log -Message "============================" -Level INFO } #endregion # ============================================================ #region CSVHANDLER # ============================================================ function Read-AutomateCsv { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$CsvPath ) if (-not (Test-Path $CsvPath)) { throw "Fichier CSV introuvable : $CsvPath" } $rows = Import-Csv -Path $CsvPath -Delimiter ";" Write-Log -Message "CSV charge : $($rows.Count) lignes depuis $CsvPath" -Level INFO return $rows } function Get-BaseUrl { [CmdletBinding()] param( [Parameter(Mandatory)] [PSCustomObject]$Automate ) $ip = $Automate."Current Ip" $httpsPort = $Automate.HttpsPort $httpPort = $Automate.HttpPort # HTTPS si port > 0 et != -1 if ($httpsPort -and $httpsPort -ne "" -and [int]$httpsPort -gt 0 -and [int]$httpsPort -ne -1) { if ([int]$httpsPort -eq 443) { return "https://$ip" } return "https://${ip}:$httpsPort" } # HTTP si port > 0 if ($httpPort -and $httpPort -ne "" -and [int]$httpPort -gt 0 -and [int]$httpPort -ne -1) { if ([int]$httpPort -eq 80) { return "http://$ip" } return "http://${ip}:$httpPort" } return $null } function Get-Credentials { [CmdletBinding()] param( [Parameter(Mandatory)] [PSCustomObject]$Automate, [string]$DefaultUsername = "admin", [string]$DefaultPassword = "" ) $username = $DefaultUsername $password = $DefaultPassword # Override depuis le CSV si colonnes presentes et renseignees $props = $Automate.PSObject.Properties.Name if ("Username" -in $props -and $Automate.Username -and $Automate.Username -ne "") { $username = $Automate.Username } if ("Password" -in $props -and $Automate.Password -and $Automate.Password -ne "") { $password = $Automate.Password } return @{ Username = $username Password = $password } } function Write-OutputCsv { [CmdletBinding()] param( [Parameter(Mandatory)] [array]$OutputRows, [Parameter(Mandatory)] [string]$OutputPath ) # Creer le repertoire de sortie si necessaire $outputDir = Split-Path $OutputPath -Parent if ($outputDir -and -not (Test-Path $outputDir)) { New-Item -ItemType Directory -Path $outputDir -Force | Out-Null } $OutputRows | Export-Csv -Path $OutputPath -Delimiter ";" -NoTypeInformation -Encoding UTF8 Write-Log -Message "CSV de sortie ecrit : $OutputPath ($($OutputRows.Count) lignes)" -Level SUCCESS } #endregion # ============================================================ #region APICLIENT # ============================================================ $script:CurlAvailable = $false function Initialize-ApiClient { [CmdletBinding()] param() # Forcer TLS 1.2 + 1.1 + 1.0 [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls11 -bor [Net.SecurityProtocolType]::Tls # Desactiver la verification SSL (certificats auto-signes) if (-not ([System.Management.Automation.PSTypeName]'TrustAllCertsPolicy').Type) { Add-Type @" using System.Net; using System.Security.Cryptography.X509Certificates; public class TrustAllCertsPolicy : ICertificatePolicy { public bool CheckValidationResult( ServicePoint srvPoint, X509Certificate certificate, WebRequest request, int certificateProblem) { return true; } } "@ } [System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCertsPolicy # Verifier si curl.exe est disponible (fallback SSL) $script:CurlAvailable = [bool](Get-Command curl.exe -ErrorAction SilentlyContinue) if ($script:CurlAvailable) { Write-Log -Message "ApiClient initialise (TLS multi, SSL bypass, curl.exe fallback)" -Level INFO } else { Write-Log -Message "ApiClient initialise (TLS multi, SSL bypass)" -Level INFO } } function Get-AuthHeader { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Username, [Parameter(Mandatory)] [AllowEmptyString()] [string]$Password ) $pair = "${Username}:${Password}" $bytes = [System.Text.Encoding]::ASCII.GetBytes($pair) $base64 = [System.Convert]::ToBase64String($bytes) return @{ "Authorization" = "Basic $base64" "Accept" = "application/json" "User-Agent" = "Light4dCLI/1.0 (PowerShell; Win32NT)" } } function Test-SslError { [CmdletBinding()] param( [Parameter(Mandatory)] [System.Exception]$Exception ) $msg = $Exception.Message return ($msg -match "SSL" -or $msg -match "TLS" -or $msg -match "confiance" -or $msg -match "trust" -or $msg -match "certificate") } function Invoke-CurlGet { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Url, [Parameter(Mandatory)] [string]$Username, [Parameter(Mandatory)] [AllowEmptyString()] [string]$Password ) Write-Log -Message "GET $Url (curl -k fallback)" -Level WARN $output = & curl.exe -s -k -u "${Username}:${Password}" -H "Accept: application/json" $Url 2>&1 if ($LASTEXITCODE -ne 0) { throw "curl GET erreur (exit code $LASTEXITCODE): $output" } Write-Log -Message "GET OK (curl)" -Level SUCCESS return ($output | Out-String) } function Invoke-CurlPost { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Url, [Parameter(Mandatory)] [string]$JsonBody, [Parameter(Mandatory)] [string]$Username, [Parameter(Mandatory)] [AllowEmptyString()] [string]$Password ) Write-Log -Message "POST $Url (curl -k fallback)" -Level WARN $tempFile = [System.IO.Path]::GetTempFileName() try { [System.IO.File]::WriteAllText($tempFile, $JsonBody, [System.Text.Encoding]::UTF8) $output = & curl.exe -s -k -u "${Username}:${Password}" -H "Content-Type: application/json; charset=utf-8" -d "@$tempFile" $Url 2>&1 if ($LASTEXITCODE -ne 0) { throw "curl POST erreur (exit code $LASTEXITCODE): $output" } Write-Log -Message "POST OK (curl)" -Level SUCCESS } finally { Remove-Item $tempFile -Force -ErrorAction SilentlyContinue } } function Invoke-ApiGet { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Url, [Parameter(Mandatory)] [string]$Username, [Parameter(Mandatory)] [AllowEmptyString()] [string]$Password ) $headers = Get-AuthHeader -Username $Username -Password $Password Write-Log -Message "GET $Url" -Level INFO try { $response = Invoke-WebRequest -Uri $Url -Method GET -Headers $headers -UseBasicParsing -TimeoutSec 30 if ($response.Content -is [byte[]]) { return [System.Text.Encoding]::UTF8.GetString($response.Content).TrimStart([char]0xFEFF) } return $response.Content } catch { if ((Test-SslError -Exception $_.Exception) -and $script:CurlAvailable) { return Invoke-CurlGet -Url $Url -Username $Username -Password $Password } throw } } function Invoke-ApiPost { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Url, [Parameter(Mandatory)] [string]$JsonBody, [Parameter(Mandatory)] [string]$Username, [Parameter(Mandatory)] [AllowEmptyString()] [string]$Password ) $headers = Get-AuthHeader -Username $Username -Password $Password Write-Log -Message "POST $Url" -Level INFO try { $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($JsonBody) $response = Invoke-WebRequest -Uri $Url -Method POST -Headers $headers -Body $bodyBytes -ContentType "application/json; charset=utf-8" -UseBasicParsing -TimeoutSec 30 $statusCode = $response.StatusCode Write-Log -Message "POST reponse : $statusCode" -Level SUCCESS return $statusCode } catch { if ((Test-SslError -Exception $_.Exception) -and $script:CurlAvailable) { Invoke-CurlPost -Url $Url -JsonBody $JsonBody -Username $Username -Password $Password return } Write-Log -Message "POST erreur : $($_.Exception.Message)" -Level ERROR throw } } #endregion # ============================================================ #region PARSER # ============================================================ function Get-Light4dModules { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$JsonContent ) $data = $JsonContent | ConvertFrom-Json $results = @() foreach ($prop in $data.PSObject.Properties) { $moduleKey = $prop.Name $module = $prop.Value # Filtrer uniquement les modules ECxLight4D (sorties 0-10V) if ($module."configured-module-type" -ne "ECxLight4D") { continue } $moduleName = $module.name foreach ($outProp in $module.outputs.PSObject.Properties) { $outputKey = $outProp.Name $output = $outProp.Value $results += [PSCustomObject][ordered]@{ ModuleKey = $moduleKey ModuleName = $moduleName OutputKey = $outputKey OutputName = $output.name DefaultValue = $output."default-value" DimmingTime = $output."dimming-time" DimmingManagement = $output."dimming-management" DisableDimming = $output."disable-dimming" } } } return $results } function Build-ModuleWriteBody { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ModuleKey, [Parameter(Mandatory)] [array]$OutputRows ) $outputs = @{} foreach ($row in $OutputRows) { $outputKey = $row.OutputKey $outputs[$outputKey] = [ordered]@{ "default-value" = [double]$row.DefaultValue "dimming-time" = [double]$row.DimmingTime "dimming-management" = [string]$row.DimmingManagement "disable-dimming" = [System.Convert]::ToBoolean($row.DisableDimming) } } $body = @{ $ModuleKey = @{ "outputs" = $outputs } } return ($body | ConvertTo-Json -Depth 10) } #endregion # ============================================================ #region MAIN # ============================================================ Initialize-Logger Initialize-ApiClient Write-Log -Message "=== Light4dCLI demarre - Action: $Action ===" -Level INFO # Chemin de l'API modules (sorties 0-10V ECxLight4D) $light4dApiPath = "/api/rest/v2/services/subnet/devices/light-sunblind/modules" # Lecture du CSV d'entree $csvRows = Read-AutomateCsv -CsvPath $CsvInput # ============================================================ # ACTION READ : Lire la config 0-10V de chaque automate # ============================================================ if ($Action -eq "Read") { $timestamp = Get-Date -Format "yyyy-MM-dd_HH\hmm" $CsvOutput = Join-Path (Get-Location) "light4d_$timestamp.csv" Write-Log -Message "CSV de sortie : $CsvOutput" -Level INFO $allOutputRows = @() # Deduplication des automates par IP (le CSV d'entree a une ligne par automate) $uniqueAutomates = $csvRows | Sort-Object -Property "Current Ip" -Unique foreach ($automate in $uniqueAutomates) { $hostname = $automate.Hostname $ip = $automate."Current Ip" Update-Stats -Counter AutomatesTotal try { $baseUrl = Get-BaseUrl -Automate $automate if (-not $baseUrl) { Write-Log -Message "[$hostname] Aucun port HTTP/HTTPS valide - ignore" -Level WARN Update-Stats -Counter AutomatesError continue } $creds = Get-Credentials -Automate $automate -DefaultUsername $Username -DefaultPassword $Password $url = "$baseUrl$light4dApiPath/" Write-Log -Message "[$hostname] $url (user: $($creds.Username))" -Level INFO # GET modules 0-10V $jsonContent = Invoke-ApiGet -Url $url -Username $creds.Username -Password $creds.Password # Parser et filtrer les modules ECxLight4D $light4dOutputs = Get-Light4dModules -JsonContent $jsonContent if ($light4dOutputs.Count -eq 0) { Write-Log -Message "[$hostname] Aucun module ECxLight4D trouve" -Level WARN continue } Write-Log -Message "[$hostname] $($light4dOutputs.Count) sortie(s) ECxLight4D trouvee(s)" -Level INFO # Creer une ligne CSV par output foreach ($output in $light4dOutputs) { # Copier toutes les colonnes source de l'automate $row = [ordered]@{} foreach ($prop in $automate.PSObject.Properties) { $row[$prop.Name] = $prop.Value } # Ajouter les colonnes 0-10V $row["ModuleKey"] = $output.ModuleKey $row["ModuleName"] = $output.ModuleName $row["OutputKey"] = $output.OutputKey $row["OutputName"] = $output.OutputName $row["DefaultValue"] = $output.DefaultValue $row["DimmingTime"] = $output.DimmingTime $row["DimmingManagement"] = $output.DimmingManagement $row["DisableDimming"] = $output.DisableDimming $allOutputRows += [PSCustomObject]$row Update-Stats -Counter OutputsProcessed Write-Log -Message "[$hostname] M$($output.ModuleKey)_O$($output.OutputKey) ($($output.OutputName)) - DefaultValue: $($output.DefaultValue)" -Level SUCCESS } } catch { Write-Log -Message "[$hostname] ERREUR : $($_.Exception.Message)" -Level ERROR Update-Stats -Counter AutomatesError } } # Ecriture du CSV de sortie if ($allOutputRows.Count -gt 0) { Write-OutputCsv -OutputRows $allOutputRows -OutputPath $CsvOutput } else { Write-Log -Message "Aucune sortie trouvee - CSV non genere" -Level WARN } } # ============================================================ # ACTION WRITE : Ecrire la config 0-10V sur chaque automate # ============================================================ elseif ($Action -eq "Write") { # Grouper les lignes par automate (Current Ip) $automateGroups = $csvRows | Group-Object -Property "Current Ip" foreach ($automateGroup in $automateGroups) { $ip = $automateGroup.Name $rows = $automateGroup.Group $hostname = $rows[0].Hostname Update-Stats -Counter AutomatesTotal try { $baseUrl = Get-BaseUrl -Automate $rows[0] if (-not $baseUrl) { Write-Log -Message "[$hostname] Aucun port HTTP/HTTPS valide - ignore" -Level WARN Update-Stats -Counter AutomatesError continue } $creds = Get-Credentials -Automate $rows[0] -DefaultUsername $Username -DefaultPassword $Password $url = "$baseUrl$light4dApiPath" Write-Log -Message "[$hostname] $url (user: $($creds.Username))" -Level INFO # Grouper par ModuleKey pour envoyer un POST par module $moduleGroups = $rows | Group-Object -Property ModuleKey foreach ($moduleGroup in $moduleGroups) { $moduleKey = $moduleGroup.Name $outputRows = $moduleGroup.Group Write-Log -Message "[$hostname] Module $moduleKey : $($outputRows.Count) sortie(s) a ecrire" -Level INFO # Construire le body JSON $jsonBody = Build-ModuleWriteBody -ModuleKey $moduleKey -OutputRows $outputRows Write-Log -Message "[$hostname] Module $moduleKey body: $jsonBody" -Level INFO # POST vers l'API Invoke-ApiPost -Url $url -JsonBody $jsonBody -Username $creds.Username -Password $creds.Password Update-Stats -Counter OutputsProcessed -Increment $outputRows.Count Write-Log -Message "[$hostname] Module $moduleKey : configuration ecrite avec succes" -Level SUCCESS } } catch { Write-Log -Message "[$hostname] ERREUR : $($_.Exception.Message)" -Level ERROR Update-Stats -Counter AutomatesError } } } # Resume final Write-Summary #endregion