Ajout initial

Fichiers :
- EditOffsetCLI.ps1 — script PowerShell principal (modification BLE + GFx)
- README.md — documentation utilisateur en français
- .gitignore — configuration git
This commit is contained in:
2026-06-25 09:50:42 +02:00
commit 4848dbbb59
3 changed files with 994 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
*.log
*.csv
/.idea
/doc
/plan
/.claude
+752
View File
@@ -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,
'<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
+236
View File
@@ -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.