commit 4848dbbb59d6f459d160b0c37cfb71f25e531ea6 Author: Charles Date: Thu Jun 25 09:50:42 2026 +0200 Ajout initial Fichiers : - EditOffsetCLI.ps1 — script PowerShell principal (modification BLE + GFx) - README.md — documentation utilisateur en français - .gitignore — configuration git diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7ab90c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.log +*.csv +/.idea +/doc +/plan +/.claude diff --git a/EditOffsetCLI.ps1 b/EditOffsetCLI.ps1 new file mode 100644 index 0000000..5215f20 --- /dev/null +++ b/EditOffsetCLI.ps1 @@ -0,0 +1,752 @@ +<# +.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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..702c632 --- /dev/null +++ b/README.md @@ -0,0 +1,236 @@ +# EditOffsetCLI — Modification de la consigne relative des BLE Device (Multi-capteur/Unitouch) en masse + +> **IMPORTANT : Ce projet est un développement personnel indépendant.** +> +> Ce projet a été développé par Charles-Arthur DAVID à titre personnel. +> Distech Controls n'est pas responsable de ce projet et ne le supporte pas. +> Aucune demande de support ne sera prise en charge par Distech Controls. +> Distech Controls ne fournit aucune garantie ni assistance technique pour ce projet. + +--- + +Outil de configuration automatique de la plage de réglage de consigne relative des BLE Device (Multi-capteur/Unitouch) sur automates Distech Controls Eclypse. + +--- + +## Prérequis + +- Windows 10 ou Windows 11 +- Automates **Distech Controls ECY Facilities (V2)** +- Être connecté au même réseau que les automates (réseau local ou VPN) +- Avoir les identifiants de connexion aux automates (nom d'utilisateur et mot de passe) + +Aucune installation de logiciel supplémentaire n'est nécessaire. + +--- + +## Contenu du dossier + +``` +EditOffsetCLI.ps1 ← script principal (fichier unique à exécuter) +votre_liste.csv ← export XNU ou fichier CSV de votre choix (voir ci-dessous) +``` + +--- + +## Étape 1 — Débloquer le script (première utilisation uniquement) + +Windows bloque par sécurité les fichiers téléchargés depuis internet. Cette étape n'est à faire **qu'une seule fois**. + +1. Faites un **clic droit** sur le fichier `EditOffsetCLI.ps1` +2. Cliquez sur **Propriétés** +3. En bas de la fenêtre, cochez la case **Débloquer** +4. Cliquez sur **OK** + +![Débloquer le fichier via les propriétés Windows](doc/unblock.png) + +> Si vous ne voyez pas la case "Débloquer", le fichier est déjà autorisé, passez à l'étape suivante. + +--- + +## Étape 2 — Préparer la liste des automates (fichier CSV) + +### Option A — Utiliser un export XpressNetwork Utility (recommandé) + +Si vous disposez de **XpressNetwork Utility**, exportez directement votre liste d'automates en CSV : le fichier est utilisable tel quel sans modification. + +### Option B — Créer le fichier manuellement + +Créez un fichier texte nommé `automates.csv` dans le même dossier que le script. + +Ouvrez-le avec **le Bloc-notes** (pas Excel) et collez ce contenu en adaptant vos données : + +``` +Hostname;Current Ip +Automate-Bureau-101;192.168.1.10 +Automate-Bureau-102;192.168.1.11 +Automate-Salle-Conf;192.168.1.12 +``` + +### Détail des colonnes + +| Colonne | Obligatoire | Description | Exemple | +|---------|:-----------:|-------------|---------| +| `Current Ip` | ✅ | Adresse IP de l'automate | `192.168.1.10` | +| `Hostname` | ❌ | Nom libre pour identifier l'automate dans les logs | `Automate-Bureau-101` | +| `Username` | ❌ | Identifiant si différent du paramètre `-Username` | `technicien` | +| `Password` | ❌ | Mot de passe si différent du paramètre `-Password` | `motdepasse` | + +> Les automates ECY Facilities V2 communiquent toujours en HTTPS — le script le gère automatiquement. + +### Identifiants différents par automate (optionnel) + +Si certains automates ont des identifiants différents, ajoutez les colonnes `Username` et `Password` : + +``` +Hostname;Current Ip;Username;Password +Automate-Bureau-101;192.168.1.10;admin;motdepasse1 +Automate-Bureau-102;192.168.1.11;technicien;motdepasse2 +``` + +--- + +## Étape 3 — Ouvrir PowerShell dans le bon dossier + +1. Ouvrez l'**Explorateur de fichiers** et naviguez jusqu'au dossier contenant `EditOffsetCLI.ps1` +2. Cliquez dans la **barre d'adresse** en haut (là où est affiché le chemin du dossier) +3. Tapez `powershell` puis appuyez sur **Entrée** + +Une fenêtre PowerShell s'ouvre directement dans le bon dossier. + +--- + +## Étape 4 — Lancer le script + +Dans la fenêtre PowerShell, tapez la commande suivante en remplaçant `3` par la valeur d'offset souhaitée : + +```powershell +.\EditOffsetCLI.ps1 -CsvInput ".\automates.csv" -Offset 3 +``` + +### Avec identifiants (si tous les automates ont le même mot de passe) + +```powershell +.\EditOffsetCLI.ps1 -CsvInput ".\automates.csv" -Offset 3 -Username "admin" -Password "votre_mot_de_passe" +``` + +### Paramètres disponibles + +| Paramètre | Obligatoire | Description | +|-----------|:-----------:|-------------| +| `-CsvInput` | ✅ | Chemin vers votre fichier CSV | +| `-Offset` | ✅ | Valeur de l'offset (ex: `3` → plage de -3 à +3°C) | +| `-Username` | ❌ | Identifiant API (défaut : `admin`) | +| `-Password` | ❌ | Mot de passe API (défaut : vide) | +| `-UpdateGfx` | ❌ | Modifier aussi le fichier GFx de l'automate (interface graphique) | + +> **Que fait l'offset ?** Si vous entrez `-Offset 3`, le script configure : +> - Limite haute : **+3°C** +> - Limite basse : **-3°C** +> - Consigne relative : **3°C** + +### Avec mise à jour du fichier GFx (interface graphique) + +Pour synchroniser aussi les limites dans l'interface graphique de l'automate, ajoutez `-UpdateGfx` : + +```powershell +.\EditOffsetCLI.ps1 -CsvInput ".\automates.csv" -Offset 3 -UpdateGfx +``` + +> Sans `-UpdateGfx`, seule la configuration BLE est modifiée. Recommandé de tester sans `-UpdateGfx` d'abord. + +--- + +## Étape 5 — Suivre l'exécution + +Pendant l'exécution, le script affiche les opérations en temps réel avec des couleurs : + +| Couleur | Signification | +|---------|---------------| +| 🔵 Cyan | Information (opération en cours) | +| 🟢 Vert | Succès | +| 🟡 Jaune | Avertissement (automate ignoré, port invalide…) | +| 🔴 Rouge | Erreur | + +À la fin, un résumé s'affiche : + +``` +[RESUME] +Automates traites : 5 +Automates en erreur : 0 +Local-values mises a jour : 10 +Configurations mises a jour : 5 +GFx mis a jour : 5 +Duree totale : 00:00:12.34 +``` + +--- + +## Fichier journal (log) + +Un fichier de log est créé automatiquement dans le même dossier, nommé : + +``` +editoffset_2026-06-24_14h30.log +``` + +Ce fichier contient l'historique complet de toutes les opérations effectuées. En cas de problème, transmettez ce fichier à votre référent technique. + +--- + +## Problèmes courants + +### ❌ "L'exécution de scripts est désactivée sur ce système" + +Windows bloque l'exécution des scripts par politique de sécurité. Lancez cette commande dans PowerShell **en tant qu'administrateur** : + +```powershell +Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser +``` + +Puis relancez le script normalement. + +--- + +### ❌ "Fichier CSV introuvable" + +Vérifiez que : +- Le fichier `automates.csv` est bien dans le **même dossier** que `EditOffsetCLI.ps1` +- Le nom du fichier est exactement `automates.csv` (pas `automates.csv.txt`) + +> Dans l'Explorateur de fichiers, activez **Affichage → Extensions de noms de fichiers** pour voir l'extension réelle. + +--- + +### ❌ Tous les automates sont en erreur + +Vérifiez que : +- Votre PC est bien connecté au réseau des automates +- Les adresses IP dans le CSV sont correctes +- Le port renseigné correspond au protocole actif sur l'automate (HTTP ou HTTPS) +- Les identifiants sont corrects + +--- + +### ❌ "Erreur SSL/TLS" ou "certificat" + +Le script gère automatiquement les certificats auto-signés des automates Distech. Si l'erreur persiste, vérifiez que `curl.exe` est disponible sur votre système (inclus dans Windows 10 v1803+ et Windows 11). + +--- + +## Exemple complet + +Fichier `automates.csv` : +``` +Hostname;Current Ip +Salle-A101;10.0.1.50 +Salle-A102;10.0.1.51 +Salle-B201;10.0.1.52 +``` + +Commande : +```powershell +.\EditOffsetCLI.ps1 -CsvInput ".\automates.csv" -Offset 4 -Username "admin" -Password "Distech2024" +``` + +Résultat attendu : les 3 automates sont configurés avec une plage de -4°C à +4°C.