<# .SYNOPSIS EditOffsetCLI - Modification en masse de la consigne relative UNITOUCH sur automates Distech Eclypse. .DESCRIPTION Modifie les high-limit et low-limit des local-values de consigne, ainsi que la relative-value de toutes les configurations BLE, sur un parc d'automates Distech Controls Eclypse via leur API REST v2. Optionnellement, modifie aussi le fichier GFx de l'automate pour synchroniser les limites de consigne dans l'interface graphique (parametre -UpdateGfx). Pour chaque automate du CSV : 1. GET /ble-room-device/ pour identifier les local-values de consigne et les configurations 2. Pour chaque local-value : GET -> modifier high-limit/low-limit -> POST 3. Pour chaque configuration : GET -> modifier relative-value -> POST 4. Si -UpdateGfx : telecharger le GFx -> modifier Main.xml -> uploader .PARAMETER CsvInput Chemin vers le fichier CSV d'entree (separateur point-virgule). Colonnes obligatoires : Hostname, Current Ip, HttpPort, HttpsPort Colonnes optionnelles : Username, Password (surchargent les parametres CLI) .PARAMETER Offset Valeur de l'offset de consigne. Resultat : high-limit = +Offset, low-limit = -Offset, relative-value = Offset .PARAMETER Username Nom d'utilisateur pour l'authentification API (defaut: admin). .PARAMETER Password Mot de passe pour l'authentification API (defaut: vide). .PARAMETER UpdateGfx Switch optionnel. Si present, modifie aussi le fichier GFx (project/Project.gfx) de chaque automate pour synchroniser les limites dans l'interface graphique. Sans ce parametre, seule la configuration BLE est modifiee. .EXAMPLE .\EditOffsetCLI.ps1 -CsvInput ".\automates.csv" -Offset 3 Modifie uniquement la configuration BLE (local-values + configurations). .EXAMPLE .\EditOffsetCLI.ps1 -CsvInput ".\automates.csv" -Offset 3 -UpdateGfx Modifie la configuration BLE ET le fichier GFx de chaque automate. .EXAMPLE .\EditOffsetCLI.ps1 -CsvInput ".\automates.csv" -Offset 3 -Username "admin" -Password "secret" -UpdateGfx .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 .\EditOffsetCLI.ps1 #> param( [Parameter(Mandatory)] [string]$CsvInput, [Parameter(Mandatory)] [double]$Offset, [string]$Username = "admin", [string]$Password = "", [switch]$UpdateGfx ) $ProgressPreference = 'SilentlyContinue' # ============================================================ #region LOGGER # ============================================================ $script:LogFile = $null $script:Stopwatch = $null $script:Stats = @{} function Initialize-Logger { $timestamp = Get-Date -Format "yyyy-MM-dd_HH\hmm" $script:LogFile = Join-Path (Get-Location) "editoffset_$timestamp.log" New-Item -ItemType File -Path $script:LogFile -Force | Out-Null $script:Stopwatch = [System.Diagnostics.Stopwatch]::StartNew() $script:Stats = @{ AutomatesTotal = 0 AutomatesError = 0 LocalValuesUpdated = 0 ConfigsUpdated = 0 GfxUpdated = 0 } Write-Log -Message "Logger initialise - fichier: $($script:LogFile)" -Level INFO } function Write-Log { 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 { param( [Parameter(Mandatory)] [ValidateSet("AutomatesTotal", "AutomatesError", "LocalValuesUpdated", "ConfigsUpdated", "GfxUpdated")] [string]$Counter, [int]$Increment = 1 ) $script:Stats[$Counter] += $Increment } function Write-Summary { $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 "Local-values mises a jour : $($script:Stats.LocalValuesUpdated)" -Level INFO Write-Log -Message "Configurations mises a jour : $($script:Stats.ConfigsUpdated)" -Level INFO Write-Log -Message "GFx mis a jour : $($script:Stats.GfxUpdated)" -Level INFO Write-Log -Message "Duree totale : $($duration.ToString('hh\:mm\:ss\.ff'))" -Level INFO Write-Log -Message "============================" -Level INFO } #endregion # ============================================================ #region API CLIENT # ============================================================ $script:CurlAvailable = $false function Initialize-ApiClient { [Net.ServicePointManager]::SecurityProtocol = ` [Net.SecurityProtocolType]::Tls12 -bor ` [Net.SecurityProtocolType]::Tls11 -bor ` [Net.SecurityProtocolType]::Tls 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 $script:CurlAvailable = [bool](Get-Command curl.exe -ErrorAction SilentlyContinue) $extra = if ($script:CurlAvailable) { ", curl.exe fallback" } else { "" } Write-Log -Message "ApiClient initialise (TLS multi, SSL bypass$extra)" -Level INFO } function Get-AuthHeader { 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" = "EditOffsetCLI/1.0 (PowerShell; Win32NT)" } } function Test-SslError { param([Parameter(Mandatory)][System.Exception]$Exception) return ($Exception.Message -match "SSL|TLS|confiance|trust|certificate") } function Invoke-ApiGet { 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) { 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 $LASTEXITCODE): $output" } Write-Log -Message "GET OK (curl)" -Level SUCCESS return ($output | Out-String) } throw } } function Invoke-ApiPost { 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 Write-Log -Message "POST reponse : $($response.StatusCode)" -Level SUCCESS return $response.StatusCode } catch { if ((Test-SslError -Exception $_.Exception) -and $script:CurlAvailable) { 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 $LASTEXITCODE): $output" } Write-Log -Message "POST OK (curl)" -Level SUCCESS } finally { Remove-Item $tempFile -Force -ErrorAction SilentlyContinue } return } Write-Log -Message "POST erreur : $($_.Exception.Message)" -Level ERROR throw } } function Invoke-ApiGetBinary { param( [Parameter(Mandatory)][string]$Url, [Parameter(Mandatory)][string]$Username, [Parameter(Mandatory)][AllowEmptyString()][string]$Password ) $headers = Get-AuthHeader -Username $Username -Password $Password $headers["Accept"] = "*/*" Write-Log -Message "GET (binary) $Url" -Level INFO try { $response = Invoke-WebRequest -Uri $Url -Method GET -Headers $headers -UseBasicParsing -TimeoutSec 60 if ($response.Content -is [byte[]]) { return $response.Content } # Fallback : contenu retourne comme string — encoder en latin-1 pour preserver les octets return [System.Text.Encoding]::GetEncoding('iso-8859-1').GetBytes($response.Content) } catch { if ((Test-SslError -Exception $_.Exception) -and $script:CurlAvailable) { Write-Log -Message "GET (binary) $Url (curl -k fallback)" -Level WARN $tempOut = [System.IO.Path]::GetTempFileName() try { & curl.exe -s -k -u "${Username}:${Password}" -o $tempOut $Url 2>&1 if ($LASTEXITCODE -ne 0) { throw "curl GET binary erreur (exit $LASTEXITCODE)" } Write-Log -Message "GET binary OK (curl)" -Level SUCCESS return [System.IO.File]::ReadAllBytes($tempOut) } finally { Remove-Item $tempOut -Force -ErrorAction SilentlyContinue } } throw } } function Invoke-ApiPostBinary { param( [Parameter(Mandatory)][string]$Url, [Parameter(Mandatory)][byte[]]$Bytes, [Parameter(Mandatory)][string]$Username, [Parameter(Mandatory)][AllowEmptyString()][string]$Password ) $headers = Get-AuthHeader -Username $Username -Password $Password $boundary = "----FormBoundary" + [System.Guid]::NewGuid().ToString("N") # Corps multipart : header de part + bytes du fichier + footer $partHeader = [System.Text.Encoding]::ASCII.GetBytes( "--$boundary`r`n" + "Content-Disposition: form-data; name=`"file`"; filename=`"Project.gfx`"`r`n" + "Content-Type: application/octet-stream`r`n`r`n") $partFooter = [System.Text.Encoding]::ASCII.GetBytes("`r`n--$boundary--`r`n") $body = New-Object byte[] ($partHeader.Length + $Bytes.Length + $partFooter.Length) [System.Array]::Copy($partHeader, 0, $body, 0, $partHeader.Length) [System.Array]::Copy($Bytes, 0, $body, $partHeader.Length, $Bytes.Length) [System.Array]::Copy($partFooter, 0, $body, $partHeader.Length + $Bytes.Length, $partFooter.Length) Write-Log -Message "POST (multipart) $Url" -Level INFO try { $response = Invoke-WebRequest -Uri $Url -Method POST -Headers $headers -Body $body ` -ContentType "multipart/form-data; boundary=$boundary" ` -UseBasicParsing -TimeoutSec 60 Write-Log -Message "POST multipart reponse : $($response.StatusCode)" -Level SUCCESS return $response.StatusCode } catch { if ((Test-SslError -Exception $_.Exception) -and $script:CurlAvailable) { Write-Log -Message "POST (multipart) $Url (curl -k fallback)" -Level WARN $tempFile = [System.IO.Path]::GetTempFileName() + ".gfx" try { [System.IO.File]::WriteAllBytes($tempFile, $Bytes) $output = & curl.exe -s -k -u "${Username}:${Password}" ` -F "file=@`"$tempFile`";filename=Project.gfx" $Url 2>&1 if ($LASTEXITCODE -ne 0) { throw "curl POST multipart erreur (exit $LASTEXITCODE): $output" } Write-Log -Message "POST multipart OK (curl)" -Level SUCCESS } finally { Remove-Item $tempFile -Force -ErrorAction SilentlyContinue } return } Write-Log -Message "POST multipart erreur : $($_.Exception.Message)" -Level ERROR throw } } #endregion # ============================================================ #region CSV HANDLER # ============================================================ function Read-AutomateCsv { 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) ligne(s) depuis $CsvPath" -Level INFO return $rows } function Get-BaseUrl { param([Parameter(Mandatory)][PSCustomObject]$Automate) $ip = $Automate."Current Ip" $props = $Automate.PSObject.Properties.Name # Utiliser HttpsPort si la colonne est presente et valide if ("HttpsPort" -in $props) { $httpsPort = $Automate.HttpsPort 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" } } # Utiliser HttpPort si la colonne est presente et valide if ("HttpPort" -in $props) { $httpPort = $Automate.HttpPort 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" } } # Defaut ECY Facilities V2 : toujours HTTPS port 443 return "https://$ip" } function Get-Credentials { param( [Parameter(Mandatory)][PSCustomObject]$Automate, [string]$DefaultUsername = "admin", [string]$DefaultPassword = "" ) $u = $DefaultUsername $p = $DefaultPassword $props = $Automate.PSObject.Properties.Name if ("Username" -in $props -and $Automate.Username -and $Automate.Username -ne "") { $u = $Automate.Username } if ("Password" -in $props -and $Automate.Password -and $Automate.Password -ne "") { $p = $Automate.Password } return @{ Username = $u; Password = $p } } #endregion # ============================================================ #region UNITOUCH PARSER # ============================================================ function Get-SetpointLocalValueKeys { # Extrait les cles de local-values a modifier depuis le JSON du device. # Les cles sont lues dans configurations/{id}/setpoint-temperature/command-value # et feedback-value pour ne pas supposer que ce sont toujours 101/102. param([Parameter(Mandatory)][string]$JsonContent) $device = $JsonContent | ConvertFrom-Json $keys = [System.Collections.Generic.List[string]]::new() foreach ($configProp in $device.configurations.PSObject.Properties) { $sp = $configProp.Value."setpoint-temperature" if (-not $sp) { continue } $cmdKey = [string]$sp."command-value" $fbKey = [string]$sp."feedback-value" if ($cmdKey -and -not $keys.Contains($cmdKey)) { $keys.Add($cmdKey) } if ($fbKey -and -not $keys.Contains($fbKey)) { $keys.Add($fbKey) } } return $keys } function Get-AllConfigIds { # Retourne toutes les cles de configurations presentes dans le device. param([Parameter(Mandatory)][string]$JsonContent) $device = $JsonContent | ConvertFrom-Json return $device.configurations.PSObject.Properties.Name } function Test-HasRelativeSetpoint { # Verifie qu'au moins une configuration expose "relative-value" dans setpoint-temperature. # Si absent, le thermostat est en mode absolu et le script ne doit rien modifier. param([Parameter(Mandatory)][string]$JsonContent) $device = $JsonContent | ConvertFrom-Json foreach ($configProp in $device.configurations.PSObject.Properties) { $sp = $configProp.Value."setpoint-temperature" if ($sp -and ($sp.PSObject.Properties.Name -contains "relative-value")) { return $true } } return $false } function Get-GfxSetpointIdxList { # Lit les ValueIndex de consigne depuis les configurations GFx (IP2SmartSenseSimpleConfigurationResource). # Retourne la liste dedupliquee des IDX des SmartSenseNumericValue a modifier. # Approche dynamique : ne suppose pas que les IDX sont toujours 108/109. param([Parameter(Mandatory)][string]$XmlContent) $idxList = [System.Collections.Generic.List[string]]::new() $opts = [System.Text.RegularExpressions.RegexOptions]::Singleline $spMatches = [regex]::Matches($XmlContent, ']*>.*?(\d+)', $opts) $fbMatches = [regex]::Matches($XmlContent, ']*>.*?(\d+)', $opts) foreach ($m in $spMatches) { $idx = $m.Groups[1].Value if (-not $idxList.Contains($idx)) { $idxList.Add($idx) } } foreach ($m in $fbMatches) { $idx = $m.Groups[1].Value if (-not $idxList.Contains($idx)) { $idxList.Add($idx) } } return $idxList } function Edit-GfxMainXml { # Modifie le contenu de Main.xml en 3 etapes : # 1. Collecter les IDX cibles depuis les configurations (Get-GfxSetpointIdxList) # 2. Modifier Minimum/Maximum dans les SmartSenseNumericValue cibles # 3. Modifier TemperatureSetpointLimitRelativeValue dans chaque config en mode Relatif (LimitMode=1) # Retourne le string XML modifie. param( [Parameter(Mandatory)][string]$XmlContent, [Parameter(Mandatory)][double]$Offset, [Parameter(Mandatory)][string]$Hostname ) $xml = $XmlContent $opts = [System.Text.RegularExpressions.RegexOptions]::Singleline # --- Etape 1 : collecter les IDX cibles --- $idxList = Get-GfxSetpointIdxList -XmlContent $xml if ($idxList.Count -eq 0) { Write-Log -Message "[$Hostname] GFx : aucun ValueIndex de consigne trouve dans les configurations" -Level WARN return $xml } Write-Log -Message "[$Hostname] GFx : IDX cibles = $($idxList -join ', ')" -Level INFO # --- Etape 2 : modifier Minimum/Maximum des SmartSenseNumericValue cibles --- $modCount = 0 foreach ($idx in $idxList) { $blockPattern = ']*>(?:(?!).)*' + $idx + '(?:(?!).)*' $m = [regex]::Match($xml, $blockPattern, $opts) if ($m.Success) { $original = $m.Value $modified = [regex]::Replace($original, '[^<]*', "$(-$Offset)") $modified = [regex]::Replace($modified, '[^<]*', "$Offset") $xml = $xml.Replace($original, $modified) $modCount++ Write-Log -Message "[$Hostname] GFx : IDX=$idx -> Minimum=$(-$Offset) Maximum=$Offset" -Level SUCCESS } else { Write-Log -Message "[$Hostname] GFx : SmartSenseNumericValue IDX=$idx introuvable dans Main.xml" -Level WARN } } if ($modCount -eq 0) { Write-Log -Message "[$Hostname] GFx : aucun bloc Minimum/Maximum modifie" -Level WARN } # --- Etape 3 : modifier TemperatureSetpointLimitRelativeValue par configuration --- $configPattern = ']*>.*?' $configMatches = [regex]::Matches($xml, $configPattern, $opts) if ($configMatches.Count -eq 0) { Write-Log -Message "[$Hostname] GFx : aucune configuration IP2SmartSense trouvee" -Level WARN return $xml } foreach ($cm in $configMatches) { $original = $cm.Value $nameMatch = [regex]::Match($original, '(.*?)') $configName = if ($nameMatch.Success) { $nameMatch.Groups[1].Value } else { "inconnue" } $modeMatch = [regex]::Match($original, '(\d+)') if ($modeMatch.Success -and $modeMatch.Groups[1].Value -eq '1') { $modified = [regex]::Replace($original, '[^<]*', "$Offset") $xml = $xml.Replace($original, $modified) Write-Log -Message "[$Hostname] GFx : config '$configName' -> TemperatureSetpointLimitRelativeValue=$Offset" -Level SUCCESS } else { Write-Log -Message "[$Hostname] GFx : config '$configName' mode absolu - TemperatureSetpointLimitRelativeValue non modifie" -Level WARN } } return $xml } function Invoke-EditGfxFile { # Ouvre le ZIP GFx, remplace Main.xml par la version modifiee, recree le ZIP. # Tous les autres fichiers de l'archive sont preserves a l'identique. param( [Parameter(Mandatory)][string]$InputPath, [Parameter(Mandatory)][string]$OutputPath, [Parameter(Mandatory)][double]$Offset, [Parameter(Mandatory)][string]$Hostname ) Add-Type -AssemblyName System.IO.Compression.FileSystem $zipIn = [System.IO.Compression.ZipFile]::OpenRead($InputPath) try { $mainEntry = $zipIn.GetEntry('Main.xml') if (-not $mainEntry) { throw "Main.xml introuvable dans l'archive GFx" } $reader = New-Object System.IO.StreamReader($mainEntry.Open(), [System.Text.Encoding]::UTF8) $mainXml = $reader.ReadToEnd() $reader.Dispose() $modifiedXml = Edit-GfxMainXml -XmlContent $mainXml -Offset $Offset -Hostname $Hostname if (Test-Path $OutputPath) { Remove-Item $OutputPath -Force } $zipOut = [System.IO.Compression.ZipFile]::Open($OutputPath, [System.IO.Compression.ZipArchiveMode]::Create) try { foreach ($entry in $zipIn.Entries) { $newEntry = $zipOut.CreateEntry($entry.FullName, [System.IO.Compression.CompressionLevel]::Optimal) $outStream = $newEntry.Open() if ($entry.FullName -eq 'Main.xml') { $xmlBytes = [System.Text.Encoding]::UTF8.GetBytes($modifiedXml) $outStream.Write($xmlBytes, 0, $xmlBytes.Length) } else { $inStream = $entry.Open() $inStream.CopyTo($outStream) $inStream.Dispose() } $outStream.Dispose() } } finally { $zipOut.Dispose() } } finally { $zipIn.Dispose() } } #endregion # ============================================================ #region MAIN # ============================================================ $bleDevicePath = "/api/rest/v2/services/subnet/devices/ble-room-device" $gfxFilePath = "/api/rest/v2/services/gfx/files/project/Project.gfx" Initialize-Logger Initialize-ApiClient $gfxMode = if ($UpdateGfx) { "BLE + GFx" } else { "BLE uniquement" } Write-Log -Message "=== EditOffsetCLI demarre - Offset: $Offset - Mode: $gfxMode ===" -Level INFO $csvRows = Read-AutomateCsv -CsvPath $CsvInput $uniqueAutomates = $csvRows | Sort-Object -Property "Current Ip" -Unique foreach ($automate in $uniqueAutomates) { $hostname = $automate.Hostname 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 # ---------------------------------------------------------- # 1. GET device complet — identifie local-values et configurations # ---------------------------------------------------------- $deviceJson = Invoke-ApiGet -Url "$baseUrl$bleDevicePath/" ` -Username $creds.Username -Password $creds.Password if (-not (Test-HasRelativeSetpoint -JsonContent $deviceJson)) { Write-Log -Message "[$hostname] Mode absolu detecte (pas de relative-value) - aucune modification effectuee" -Level WARN continue } $lvKeys = Get-SetpointLocalValueKeys -JsonContent $deviceJson $configIds = Get-AllConfigIds -JsonContent $deviceJson if ($lvKeys.Count -eq 0) { Write-Log -Message "[$hostname] Aucune local-value de consigne trouvee - ignore" -Level WARN continue } Write-Log -Message "[$hostname] Local-values a modifier : $($lvKeys -join ', ')" -Level INFO Write-Log -Message "[$hostname] Configurations a modifier : $($configIds -join ', ')" -Level INFO # ---------------------------------------------------------- # 2. Modifier high-limit et low-limit de chaque local-value # ---------------------------------------------------------- foreach ($key in $lvKeys) { try { $url = "$baseUrl$bleDevicePath/local-values/$key/" $lvJson = Invoke-ApiGet -Url $url -Username $creds.Username -Password $creds.Password $lv = $lvJson | ConvertFrom-Json $lv."high-limit" = $Offset $lv."low-limit" = -$Offset $body = $lv | ConvertTo-Json -Depth 20 -Compress Invoke-ApiPost -Url $url -JsonBody $body -Username $creds.Username -Password $creds.Password Update-Stats -Counter LocalValuesUpdated Write-Log -Message "[$hostname] local-value $key : high-limit=$Offset low-limit=$(-$Offset) OK" -Level SUCCESS } catch { Write-Log -Message "[$hostname] local-value $key ERREUR : $($_.Exception.Message)" -Level ERROR } } # ---------------------------------------------------------- # 3. Modifier relative-value de chaque configuration # ---------------------------------------------------------- foreach ($configId in $configIds) { try { $url = "$baseUrl$bleDevicePath/configurations/$configId" $cfgJson = Invoke-ApiGet -Url $url -Username $creds.Username -Password $creds.Password $cfg = $cfgJson | ConvertFrom-Json if (-not $cfg."setpoint-temperature") { Write-Log -Message "[$hostname] config $configId : pas de setpoint-temperature - ignore" -Level WARN continue } if ($cfg."setpoint-temperature".PSObject.Properties.Name -notcontains "relative-value") { Write-Log -Message "[$hostname] config $configId : mode absolu - ignore" -Level WARN continue } $cfg."setpoint-temperature"."relative-value" = $Offset $body = $cfg | ConvertTo-Json -Depth 20 -Compress Invoke-ApiPost -Url $url -JsonBody $body -Username $creds.Username -Password $creds.Password Update-Stats -Counter ConfigsUpdated Write-Log -Message "[$hostname] config $configId : relative-value=$Offset OK" -Level SUCCESS } catch { Write-Log -Message "[$hostname] config $configId ERREUR : $($_.Exception.Message)" -Level ERROR } } # ---------------------------------------------------------- # 4. Modifier le fichier GFx (uniquement si -UpdateGfx) # ---------------------------------------------------------- if ($UpdateGfx) { $tempGfxIn = [System.IO.Path]::GetTempFileName() + ".gfx" $tempGfxOut = [System.IO.Path]::GetTempFileName() + ".gfx" try { $gfxBytes = Invoke-ApiGetBinary -Url "$baseUrl${gfxFilePath}?content=media" ` -Username $creds.Username -Password $creds.Password [System.IO.File]::WriteAllBytes($tempGfxIn, $gfxBytes) Invoke-EditGfxFile -InputPath $tempGfxIn -OutputPath $tempGfxOut ` -Offset $Offset -Hostname $hostname $newBytes = [System.IO.File]::ReadAllBytes($tempGfxOut) Invoke-ApiPostBinary -Url "$baseUrl${gfxFilePath}?overwrite=true" -Bytes $newBytes ` -Username $creds.Username -Password $creds.Password Update-Stats -Counter GfxUpdated Write-Log -Message "[$hostname] GFx modifie et uploade avec succes" -Level SUCCESS } catch { if ($_.Exception.Message -match "404|Not Found") { Write-Log -Message "[$hostname] GFx introuvable sur cet automate - ignore" -Level WARN } else { Write-Log -Message "[$hostname] GFx ERREUR : $($_.Exception.Message)" -Level ERROR } } finally { Remove-Item $tempGfxIn -Force -ErrorAction SilentlyContinue Remove-Item $tempGfxOut -Force -ErrorAction SilentlyContinue } } } catch { Write-Log -Message "[$hostname] ERREUR FATALE : $($_.Exception.Message)" -Level ERROR Update-Stats -Counter AutomatesError } } Write-Summary #endregion