Files
EditOffsetCLI/EditOffsetCLI.ps1
T
cadjou 4848dbbb59 Ajout initial
Fichiers :
- EditOffsetCLI.ps1 — script PowerShell principal (modification BLE + GFx)
- README.md — documentation utilisateur en français
- .gitignore — configuration git
2026-06-25 09:50:42 +02:00

753 lines
31 KiB
PowerShell

<#
.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,
'<TemperatureSetpointValue\b[^>]*>.*?<ValueIndex>(\d+)</ValueIndex>', $opts)
$fbMatches = [regex]::Matches($XmlContent,
'<TemperatureSetpointFeedbackValue\b[^>]*>.*?<ValueIndex>(\d+)</ValueIndex>', $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 = '<SmartSenseNumericValue[^>]*>(?:(?!</SmartSenseNumericValue>).)*<IDX>' + $idx + '</IDX>(?:(?!</SmartSenseNumericValue>).)*</SmartSenseNumericValue>'
$m = [regex]::Match($xml, $blockPattern, $opts)
if ($m.Success) {
$original = $m.Value
$modified = [regex]::Replace($original, '<Minimum>[^<]*</Minimum>', "<Minimum>$(-$Offset)</Minimum>")
$modified = [regex]::Replace($modified, '<Maximum>[^<]*</Maximum>', "<Maximum>$Offset</Maximum>")
$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 = '<IP2SmartSenseSimpleConfigurationResource[^>]*>.*?</IP2SmartSenseSimpleConfigurationResource>'
$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, '<NAME>(.*?)</NAME>')
$configName = if ($nameMatch.Success) { $nameMatch.Groups[1].Value } else { "inconnue" }
$modeMatch = [regex]::Match($original, '<TemperatureSetpointLimitMode>(\d+)</TemperatureSetpointLimitMode>')
if ($modeMatch.Success -and $modeMatch.Groups[1].Value -eq '1') {
$modified = [regex]::Replace($original,
'<TemperatureSetpointLimitRelativeValue>[^<]*</TemperatureSetpointLimitRelativeValue>',
"<TemperatureSetpointLimitRelativeValue>$Offset</TemperatureSetpointLimitRelativeValue>")
$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