Back up what a part marker holds, without the vendor tool

Telesis pinstamp controllers have never been backed up. The vendor's own
utilities, Backup470 and Backup420, are GUIs with no command line, so nothing
could capture a marker on a schedule - and a controller swap meant retyping
patterns from memory.

TelesisMarker.psm1 speaks the protocol directly, reversed from a capture of
Backup470 against a TMC470 and verified against hardware: a pattern pulled by
this code is byte-identical to the one the vendor tool saves, and a pattern
written back survives a power cycle. A TMC420 was captured the same way and
differs in exactly one respect - it exposes no parameter files - so one
implementation covers both families, with content classes chosen from a Type I
status probe rather than a per-model dialect.

Backup-PartMarker.ps1 captures everything the controller holds into a
timestamped snapshot on the share and records it in ShopDB against the marker
asset. Snapshots rather than a mirrored directory, because a mirror forces a
choice between deleting the only copy of a retired pattern and claiming a
pattern that no longer exists; a snapshot has neither problem, and the manifest
hash means an unchanged controller writes nothing.

The marker's serial port is shared with eDNC and is exclusive-open, so captures
happen only at shift changes - 14:00 normally, when someone is on site to fix a
problem, with 06:00 and 22:00 engaging only once the backup is overdue. The
capture yields the port within a second if the marker application starts
mid-run, and cycles missed to a busy port are counted and carried into the next
snapshot, since posting a no-op to keep the health badge green would hide
exactly the failure the badge exists to report.

TYPE 'R' deletes a file on the controller, immediately and without
confirmation. It is documented in the module header and deliberately not
implemented.
This commit is contained in:
cproudlock
2026-08-25 13:02:06 -04:00
parent 12c6c771dc
commit e40a294193
3 changed files with 970 additions and 2 deletions

View File

@@ -47,6 +47,13 @@ $script:SHOPDBREG = 'HKLM:\SOFTWARE\GE\ShopDB'
$script:KEYFILE = 'C:\Enrollment\shopdb-key.txt'
$script:PCCONFIG = 'C:\Enrollment\pc-config.txt'
# The action ShopDB reported for the most recent post, per kind: 'created',
# 'noop', or '' when nothing has been posted. Send-ShopdbBackup returns only
# accepted/not-accepted, and changing that would break its existing caller - but
# a share kind has to know the difference, because it decides whether the
# snapshot directory it just wrote is a new revision or a duplicate to remove.
$script:LASTACTION = @{}
function Get-ShopdbLogPath {
param([Parameter(Mandatory)][string]$Kind)
@@ -294,6 +301,12 @@ function Send-ShopdbBackup {
[string]$ContentHash,
[string]$SharePath,
[string]$SourceFileName,
# Optional description of a share kind's snapshot - what is in it, not
# the bytes. ShopDB stores it so an asset panel can list the contents
# and diff them against the previous snapshot; without it the server
# would have to mount the share, which it refuses to do. Advisory:
# dedup still keys on ContentHash.
[hashtable]$Manifest,
[int]$TimeoutSec = 30)
$kind = $Context.Kind
@@ -327,6 +340,7 @@ function Send-ShopdbBackup {
}
if ($ContentHash) { $payload['contenthash'] = $ContentHash }
if ($SharePath) { $payload['sharepath'] = $SharePath }
if ($Manifest) { $payload['manifest'] = $Manifest }
$uri = '{0}/api/collector/backups' -f $Context.BaseUrl
$size = if ($Bytes) { '{0} bytes' -f $Bytes.Length } else { $SharePath }
@@ -336,11 +350,12 @@ function Send-ShopdbBackup {
try {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$resp = Invoke-RestMethod -Uri $uri -Method Post `
-Body ($payload | ConvertTo-Json -Compress) `
-Body ($payload | ConvertTo-Json -Compress -Depth 6) `
-ContentType 'application/json' `
-Headers @{ 'X-API-Key' = $Context.CollectorKey } `
-TimeoutSec $TimeoutSec -EA Stop
$script:LASTACTION[$kind] = "$($resp.data.action)"
switch ("$($resp.data.action)") {
'created' { Write-ShopdbBackupLog -Kind $kind -Message (
'New revision {0} recorded.' -f $resp.data.backuprevisionid) }
@@ -369,7 +384,99 @@ function Send-ShopdbBackup {
}
function Add-ShopdbBackupSkip {
<#
Record that a cycle was skipped, and why.
A skipped cycle posts NOTHING to ShopDB, deliberately: lastseenat means
"this config was confirmed still current", so posting a no-op to keep a
marker green would be a lie that hides a backup which has not run for a
month. The staleness badge is what surfaces that.
What ShopDB cannot know is WHY, so the count and the reason are kept
locally and carried into the next successful snapshot's manifest. A
marker that captures once a week because its port is busy every shift
change then says so on its own asset page.
#>
param([Parameter(Mandatory)][string]$Kind,
[Parameter(Mandatory)][string]$Reason)
$file = Join-Path $script:LOGDIR ('{0}-backup.skips' -f $Kind)
$count = 0
$first = (Get-Date).ToUniversalTime().ToString('o')
if (Test-Path $file) {
try {
$existing = Get-Content -LiteralPath $file -Raw | ConvertFrom-Json
$count = [int]$existing.count
if ($existing.first) { $first = [string]$existing.first }
} catch { }
}
if (-not (Test-Path $script:LOGDIR)) {
New-Item -ItemType Directory -Path $script:LOGDIR -Force -EA SilentlyContinue | Out-Null
}
[pscustomobject]@{
count = $count + 1
first = $first
last = (Get-Date).ToUniversalTime().ToString('o')
reason = $Reason
} | ConvertTo-Json -Compress | Set-Content -Path $file -EA SilentlyContinue
}
function Get-ShopdbBackupSkips {
<#
Skipped cycles since the last successful capture: @{count; first; last;
reason}, or $null when none. Read into the manifest, then cleared by
Clear-ShopdbBackupSkips once a capture actually succeeds.
#>
param([Parameter(Mandatory)][string]$Kind)
$file = Join-Path $script:LOGDIR ('{0}-backup.skips' -f $Kind)
if (-not (Test-Path $file)) { return $null }
try {
$data = Get-Content -LiteralPath $file -Raw | ConvertFrom-Json
return @{
count = [int]$data.count
first = [string]$data.first
last = [string]$data.last
reason = [string]$data.reason
}
} catch { return $null }
}
function Clear-ShopdbBackupSkips {
param([Parameter(Mandatory)][string]$Kind)
Remove-Item -LiteralPath (Join-Path $script:LOGDIR ('{0}-backup.skips' -f $Kind)) `
-Force -EA SilentlyContinue
}
function Get-ShopdbLastBackupAction {
<#
What ShopDB did with the most recent post for this kind: 'created',
'noop', or '' if nothing was posted or the post failed.
#>
param([Parameter(Mandatory)][string]$Kind)
if ($script:LASTACTION.ContainsKey($Kind)) { return $script:LASTACTION[$Kind] }
return ''
}
function Test-ShopdbBackupNoop {
<#
True when the last post for this kind changed nothing. A share kind uses
this to delete the snapshot it just wrote: a no-op proves byte-identical
content already exists in the previous snapshot, so removing the copy
loses nothing and stops one identical directory accumulating per cycle.
#>
param([Parameter(Mandatory)][string]$Kind)
return ((Get-ShopdbLastBackupAction -Kind $Kind) -eq 'noop')
}
Export-ModuleMember -Function Initialize-ShopdbBackup, Send-ShopdbBackup,
Write-ShopdbBackupLog, Write-ShopdbQuietState, Clear-ShopdbQuietState,
Get-ShopdbRegValue, Get-ShopdbCollectorKey, Get-ShopdbMachineNumber,
Get-ShopdbIntervalHours, Get-ShopdbLogPath
Get-ShopdbIntervalHours, Get-ShopdbLogPath,
Get-ShopdbLastBackupAction, Test-ShopdbBackupNoop,
Add-ShopdbBackupSkip, Get-ShopdbBackupSkips, Clear-ShopdbBackupSkips