4848dbbb59
Fichiers : - EditOffsetCLI.ps1 — script PowerShell principal (modification BLE + GFx) - README.md — documentation utilisateur en français - .gitignore — configuration git
753 lines
31 KiB
PowerShell
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
|