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:
@@ -47,6 +47,13 @@ $script:SHOPDBREG = 'HKLM:\SOFTWARE\GE\ShopDB'
|
|||||||
$script:KEYFILE = 'C:\Enrollment\shopdb-key.txt'
|
$script:KEYFILE = 'C:\Enrollment\shopdb-key.txt'
|
||||||
$script:PCCONFIG = 'C:\Enrollment\pc-config.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 {
|
function Get-ShopdbLogPath {
|
||||||
param([Parameter(Mandatory)][string]$Kind)
|
param([Parameter(Mandatory)][string]$Kind)
|
||||||
@@ -294,6 +301,12 @@ function Send-ShopdbBackup {
|
|||||||
[string]$ContentHash,
|
[string]$ContentHash,
|
||||||
[string]$SharePath,
|
[string]$SharePath,
|
||||||
[string]$SourceFileName,
|
[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)
|
[int]$TimeoutSec = 30)
|
||||||
|
|
||||||
$kind = $Context.Kind
|
$kind = $Context.Kind
|
||||||
@@ -327,6 +340,7 @@ function Send-ShopdbBackup {
|
|||||||
}
|
}
|
||||||
if ($ContentHash) { $payload['contenthash'] = $ContentHash }
|
if ($ContentHash) { $payload['contenthash'] = $ContentHash }
|
||||||
if ($SharePath) { $payload['sharepath'] = $SharePath }
|
if ($SharePath) { $payload['sharepath'] = $SharePath }
|
||||||
|
if ($Manifest) { $payload['manifest'] = $Manifest }
|
||||||
|
|
||||||
$uri = '{0}/api/collector/backups' -f $Context.BaseUrl
|
$uri = '{0}/api/collector/backups' -f $Context.BaseUrl
|
||||||
$size = if ($Bytes) { '{0} bytes' -f $Bytes.Length } else { $SharePath }
|
$size = if ($Bytes) { '{0} bytes' -f $Bytes.Length } else { $SharePath }
|
||||||
@@ -336,11 +350,12 @@ function Send-ShopdbBackup {
|
|||||||
try {
|
try {
|
||||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
$resp = Invoke-RestMethod -Uri $uri -Method Post `
|
$resp = Invoke-RestMethod -Uri $uri -Method Post `
|
||||||
-Body ($payload | ConvertTo-Json -Compress) `
|
-Body ($payload | ConvertTo-Json -Compress -Depth 6) `
|
||||||
-ContentType 'application/json' `
|
-ContentType 'application/json' `
|
||||||
-Headers @{ 'X-API-Key' = $Context.CollectorKey } `
|
-Headers @{ 'X-API-Key' = $Context.CollectorKey } `
|
||||||
-TimeoutSec $TimeoutSec -EA Stop
|
-TimeoutSec $TimeoutSec -EA Stop
|
||||||
|
|
||||||
|
$script:LASTACTION[$kind] = "$($resp.data.action)"
|
||||||
switch ("$($resp.data.action)") {
|
switch ("$($resp.data.action)") {
|
||||||
'created' { Write-ShopdbBackupLog -Kind $kind -Message (
|
'created' { Write-ShopdbBackupLog -Kind $kind -Message (
|
||||||
'New revision {0} recorded.' -f $resp.data.backuprevisionid) }
|
'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,
|
Export-ModuleMember -Function Initialize-ShopdbBackup, Send-ShopdbBackup,
|
||||||
Write-ShopdbBackupLog, Write-ShopdbQuietState, Clear-ShopdbQuietState,
|
Write-ShopdbBackupLog, Write-ShopdbQuietState, Clear-ShopdbQuietState,
|
||||||
Get-ShopdbRegValue, Get-ShopdbCollectorKey, Get-ShopdbMachineNumber,
|
Get-ShopdbRegValue, Get-ShopdbCollectorKey, Get-ShopdbMachineNumber,
|
||||||
Get-ShopdbIntervalHours, Get-ShopdbLogPath
|
Get-ShopdbIntervalHours, Get-ShopdbLogPath,
|
||||||
|
Get-ShopdbLastBackupAction, Test-ShopdbBackupNoop,
|
||||||
|
Add-ShopdbBackupSkip, Get-ShopdbBackupSkips, Clear-ShopdbBackupSkips
|
||||||
|
|||||||
462
playbook/shopfloor-setup/common/lib/TelesisMarker.psm1
Normal file
462
playbook/shopfloor-setup/common/lib/TelesisMarker.psm1
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
# TelesisMarker - talk to a Telesis pinstamp controller over RS-232.
|
||||||
|
#
|
||||||
|
# WHAT THIS IS
|
||||||
|
#
|
||||||
|
# The wire protocol Telesis' own Backup470 utility speaks, reimplemented so a
|
||||||
|
# bay can back its marker up unattended. Backup470 is a GUI with no command
|
||||||
|
# line, so without this there is no way to capture a controller's patterns on a
|
||||||
|
# schedule - which is why the 470s have never been backed up at all.
|
||||||
|
#
|
||||||
|
# Reversed from a capture of the real utility against a TMC470 on 2026-08-25 and
|
||||||
|
# verified against hardware: a pattern pulled by this code is byte-identical to
|
||||||
|
# the one Backup470 saved, and a pattern written back by this code survives a
|
||||||
|
# power cycle.
|
||||||
|
#
|
||||||
|
# FRAMING
|
||||||
|
#
|
||||||
|
# host -> ctrl: EOT SOH <TYPE> <2-digit station> STX <data> ETX <BCC> CR
|
||||||
|
# ctrl -> host: SOH <TYPE> ACK STX <data> ETX <BCC> CR
|
||||||
|
#
|
||||||
|
# BCC is (TYPE + sum of DATA bytes) AND 0xFF, sent as three ASCII decimal
|
||||||
|
# digits. The station ID and block sequence numbers are NOT part of the sum -
|
||||||
|
# that asymmetry is real and every frame in the capture confirms it.
|
||||||
|
#
|
||||||
|
# CONTENT LISTS - three of them, and they are separate lists rather than one
|
||||||
|
# list filtered:
|
||||||
|
#
|
||||||
|
# patterns TYPE 'F' start "R" step ""
|
||||||
|
# parameters TYPE 'F' start "H" step "N"
|
||||||
|
# fonts TYPE '4' start "R" step "" (step UNVERIFIED, see below)
|
||||||
|
#
|
||||||
|
# An empty name ends a list. Built-in fonts are not exposed as files, so an
|
||||||
|
# empty font list is normal rather than a fault - which is also why the font
|
||||||
|
# step sub-command is a reasonable guess and not a verified fact: the test unit
|
||||||
|
# held no fonts, so the list ended on the first request. If a unit with custom
|
||||||
|
# fonts ever appears and the second name never arrives, that is the line to fix.
|
||||||
|
#
|
||||||
|
# TRANSFERS - the RECEIVER drives in both directions, and asking for the block
|
||||||
|
# past the end is what ends a transfer:
|
||||||
|
#
|
||||||
|
# read (TYPE 'U'): ctrl announces a size, then the HOST requests each block
|
||||||
|
# write (TYPE 'D'): host announces name;size;blocksize, then the CONTROLLER
|
||||||
|
# requests each block
|
||||||
|
#
|
||||||
|
# WHAT IS DELIBERATELY MISSING: TYPE 'R' deletes a file. It is immediate, takes
|
||||||
|
# no confirmation and has no undo. Nothing this module is for needs it, so it is
|
||||||
|
# not implemented - a backup tool that can delete is a backup tool that can be
|
||||||
|
# made to delete.
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
|
||||||
|
$script:SOH = [char]0x01
|
||||||
|
$script:STX = [char]0x02
|
||||||
|
$script:ETX = [char]0x03
|
||||||
|
$script:EOT = [char]0x04
|
||||||
|
$script:ACK = [char]0x06
|
||||||
|
$script:CR = [char]0x0D
|
||||||
|
|
||||||
|
$script:BLOCKSIZE = 128
|
||||||
|
$script:STATION = '00'
|
||||||
|
|
||||||
|
# NTLARS/eDNC is a 32-bit app, so the marker's serial settings live under
|
||||||
|
# WOW6432Node. GE-Enforce runs 64-bit, where the unredirected path is a
|
||||||
|
# different and usually absent key - reading it would find nothing and fall back
|
||||||
|
# to a guessed COM port. Spelled out, never abbreviated. Same trap as
|
||||||
|
# Backup-NtlarsSettings.ps1.
|
||||||
|
$script:MARKKEY = 'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\Mark'
|
||||||
|
|
||||||
|
|
||||||
|
function Get-TelesisPortConfig {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
The marker's serial settings, as the bay already has them configured.
|
||||||
|
.DESCRIPTION
|
||||||
|
The PC that drives a marker already knows how to reach it - NTLARS keeps
|
||||||
|
the port, baud, parity, data bits and stop bits in its own registry. Read
|
||||||
|
them rather than hardcoding COM4/9600 or scanning baud rates: a wrong
|
||||||
|
guess that happens to work on one bay is a silent trap on the next.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param()
|
||||||
|
|
||||||
|
if (-not (Test-Path $script:MARKKEY)) {
|
||||||
|
throw "No marker configuration at $script:MARKKEY - this PC does not drive a Telesis marker."
|
||||||
|
}
|
||||||
|
$props = Get-ItemProperty -Path $script:MARKKEY -ErrorAction Stop
|
||||||
|
|
||||||
|
$portid = [string]$props.'Port Id'
|
||||||
|
if (-not $portid) { throw "No 'Port Id' under $script:MARKKEY - marker serial port is not configured." }
|
||||||
|
|
||||||
|
[pscustomobject]@{
|
||||||
|
PortName = $portid.Trim()
|
||||||
|
BaudRate = [int]("$($props.Baud)".Trim() -replace '\D', '')
|
||||||
|
Parity = switch -Regex ("$($props.Parity)".Trim()) {
|
||||||
|
'^(?i)odd' { 'Odd' }
|
||||||
|
'^(?i)even' { 'Even' }
|
||||||
|
default { 'None' }
|
||||||
|
}
|
||||||
|
DataBits = [int]("$($props.'Data Bits')".Trim() -replace '\D', '')
|
||||||
|
StopBits = if ("$($props.'Stop Bits')".Trim() -eq '2') { 'Two' } else { 'One' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function Get-TelesisMarkerType {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
The controller model this bay declares it drives, or '' if unset.
|
||||||
|
.DESCRIPTION
|
||||||
|
DNC\Mark\MarkerType is the value the vendor's own driver switches its
|
||||||
|
protocol on - MarkTelesis logs "MarkerType=%s, Cnc=%s" right beside its
|
||||||
|
TMC090 / TMC400 / TMC420 handling. Observed values include those, plus
|
||||||
|
non-TMC markers like "2Line" and "BREN".
|
||||||
|
|
||||||
|
Treat it as a hint, not proof. Mark predates the TMC470, so a 470 bay
|
||||||
|
may hold something unhelpful here. It positively identifies the older
|
||||||
|
families; a 470 is confirmed from a pattern header instead, which reads
|
||||||
|
"TMP1700/470" or "TMP4210/470".
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param()
|
||||||
|
try {
|
||||||
|
$props = Get-ItemProperty -Path $script:MARKKEY -ErrorAction Stop
|
||||||
|
if ($props.PSObject.Properties.Name -contains 'MarkerType') {
|
||||||
|
return ([string]$props.MarkerType).Trim()
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function Test-TelesisPortBusy {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Is the marker application currently holding the serial port?
|
||||||
|
.DESCRIPTION
|
||||||
|
MarkTelesis holds the COM port whenever the marker is in service. A
|
||||||
|
backup that fires every GE-Enforce cycle must never take the port from a
|
||||||
|
marking operation, so this is checked before the port is opened and the
|
||||||
|
cycle is skipped when it is busy. The throttle means a missed window
|
||||||
|
costs nothing - the next one picks it up.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param()
|
||||||
|
# Backup470/Backup420 are the vendor utilities. A tech running one by hand
|
||||||
|
# holds the port exactly as MarkTelesis does, and the cycle must yield to
|
||||||
|
# them too - observed on the bench, where Backup470 left COM1 open and
|
||||||
|
# every open attempt returned 'Access to the port is denied'.
|
||||||
|
$names = @('MarkTelesis', 'Mark', 'MarkEdit', 'MarkPatterns', 'MarkUtil',
|
||||||
|
'Backup470', 'Backup420')
|
||||||
|
foreach ($name in $names) {
|
||||||
|
if (Get-Process -Name $name -ErrorAction SilentlyContinue) { return $name }
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function New-TelesisBcc {
|
||||||
|
param([char]$Type, [byte[]]$Data)
|
||||||
|
$sum = [int][byte][char]$Type
|
||||||
|
foreach ($b in $Data) { $sum += $b }
|
||||||
|
return '{0:D3}' -f ($sum -band 0xFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function Open-TelesisPort {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Open the marker's serial port with the bay's own settings.
|
||||||
|
.PARAMETER Config
|
||||||
|
From Get-TelesisPortConfig. Passed in rather than read here so a caller
|
||||||
|
can override for a bench test.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][pscustomobject]$Config,
|
||||||
|
[int]$TimeoutMs = 6000
|
||||||
|
)
|
||||||
|
|
||||||
|
$port = New-Object System.IO.Ports.SerialPort(
|
||||||
|
$Config.PortName, $Config.BaudRate, $Config.Parity,
|
||||||
|
$Config.DataBits, $Config.StopBits)
|
||||||
|
$port.Handshake = 'None'
|
||||||
|
$port.ReadTimeout = $TimeoutMs
|
||||||
|
$port.WriteTimeout = $TimeoutMs
|
||||||
|
# Raw bytes only. The payloads are binary and any encoding layer would
|
||||||
|
# mangle them; every read in this module goes through the byte API.
|
||||||
|
$port.Open()
|
||||||
|
$port.DiscardInBuffer()
|
||||||
|
$port.DiscardOutBuffer()
|
||||||
|
return $port
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function Invoke-TelesisCommand {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Send one host command, return the controller's reply data as a string.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]$Port,
|
||||||
|
[Parameter(Mandatory)][char]$Type,
|
||||||
|
[string]$Data = ''
|
||||||
|
)
|
||||||
|
|
||||||
|
$bytes = [Text.Encoding]::ASCII.GetBytes($Data)
|
||||||
|
$frame = '{0}{1}{2}{3}{4}{5}{6}{7}{8}' -f `
|
||||||
|
$script:EOT, $script:SOH, $Type, $script:STATION, $script:STX,
|
||||||
|
$Data, $script:ETX, (New-TelesisBcc -Type $Type -Data $bytes), $script:CR
|
||||||
|
|
||||||
|
# Flush first: a previous desync must not poison this transaction.
|
||||||
|
$Port.DiscardInBuffer()
|
||||||
|
$out = [Text.Encoding]::ASCII.GetBytes($frame)
|
||||||
|
$Port.Write($out, 0, $out.Length)
|
||||||
|
|
||||||
|
# Reply frames are ASCII and terminated by CR. Data BLOCKS are not - see
|
||||||
|
# Read-TelesisBlock, which frames by length because payloads contain 0x0d.
|
||||||
|
$reply = New-Object Text.StringBuilder
|
||||||
|
while ($true) {
|
||||||
|
$b = $Port.ReadByte()
|
||||||
|
if ($b -lt 0) { throw "No reply to command '$Type' '$Data'." }
|
||||||
|
if ($b -eq 0x0D) { break }
|
||||||
|
[void]$reply.Append([char]$b)
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = $reply.ToString()
|
||||||
|
if ($text.IndexOf($script:ACK) -lt 0) {
|
||||||
|
throw "Controller did not acknowledge command '$Type' '$Data' (reply: $text)."
|
||||||
|
}
|
||||||
|
$start = $text.IndexOf($script:STX)
|
||||||
|
$end = $text.IndexOf($script:ETX)
|
||||||
|
if ($start -lt 0 -or $end -lt $start) {
|
||||||
|
throw "Malformed reply to command '$Type' '$Data'."
|
||||||
|
}
|
||||||
|
return $text.Substring($start + 1, $end - $start - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function Get-TelesisControllerFamily {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Which controller family is on the other end: 'TMC470', 'TMC420', or ''.
|
||||||
|
.DESCRIPTION
|
||||||
|
Uses the Type I status request - part of the PUBLISHED Extended Protocol
|
||||||
|
and read-only, so it is safe against any model, including ones nobody
|
||||||
|
here has seen. It asks for the state of the output and input signals and
|
||||||
|
changes nothing.
|
||||||
|
|
||||||
|
The two families answer in different shapes, and that is the whole
|
||||||
|
discriminator:
|
||||||
|
|
||||||
|
TMC470 -> "0A;000" outputs;inputs
|
||||||
|
TMC420 -> "3" single value
|
||||||
|
|
||||||
|
Both were captured from real hardware on 2026-08-25.
|
||||||
|
|
||||||
|
Do NOT use pattern headers for this. A pattern reads "TMP1700/470" or
|
||||||
|
"TMP3200/470" and that "/470" appears on BOTH families - it names a file
|
||||||
|
format, not a controller. The leading field is the marking head.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param([Parameter(Mandatory)]$Port)
|
||||||
|
|
||||||
|
try {
|
||||||
|
$status = Invoke-TelesisCommand -Port $Port -Type 'I'
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
if ($status -match '^[0-9A-Fa-f]{2};[0-9A-Fa-f]{3}$') { return 'TMC470' }
|
||||||
|
if ($status -match '^[0-9A-Fa-f]{1,4}$') { return 'TMC420' }
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function Get-TelesisContentClasses {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Which content classes this controller actually has.
|
||||||
|
.DESCRIPTION
|
||||||
|
Patterns and fonts exist on both families. PARAMETERS (.DAT - NET, HOST,
|
||||||
|
MARKER, PASSWORD and the rest) were found only on the TMC470: Backup420
|
||||||
|
offers no DAT filter and never sends the F "H" listing command, so it is
|
||||||
|
unknown whether a 420 even holds them.
|
||||||
|
|
||||||
|
Rather than send an unproven sub-command to a marking controller, ask
|
||||||
|
for parameters only where they are known to exist. An unknown family
|
||||||
|
gets the safe subset; when the e10 arrives, capture it and add it here.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param([string]$Family)
|
||||||
|
if ($Family -eq 'TMC470') { return @('Patterns', 'Parameters', 'Fonts') }
|
||||||
|
return @('Patterns', 'Fonts')
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function Get-TelesisFileList {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Every name the controller holds of one content class.
|
||||||
|
.PARAMETER Class
|
||||||
|
Patterns, Parameters or Fonts. Three separate lists, not one filtered -
|
||||||
|
a full snapshot walks all three.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]$Port,
|
||||||
|
[Parameter(Mandatory)][ValidateSet('Patterns', 'Parameters', 'Fonts')][string]$Class
|
||||||
|
)
|
||||||
|
|
||||||
|
$spec = switch ($Class) {
|
||||||
|
'Patterns' { @{ Type = 'F'; Start = 'R'; Step = ''; Extension = '.PAT' } }
|
||||||
|
'Parameters' { @{ Type = 'F'; Start = 'H'; Step = 'N'; Extension = '.DAT' } }
|
||||||
|
'Fonts' { @{ Type = '4'; Start = 'R'; Step = ''; Extension = '.TFF' } }
|
||||||
|
}
|
||||||
|
|
||||||
|
$names = New-Object System.Collections.Generic.List[string]
|
||||||
|
$name = Invoke-TelesisCommand -Port $Port -Type $spec.Type -Data $spec.Start
|
||||||
|
while ($name) {
|
||||||
|
$names.Add($name)
|
||||||
|
$name = Invoke-TelesisCommand -Port $Port -Type $spec.Type -Data $spec.Step
|
||||||
|
}
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Class = $Class
|
||||||
|
Extension = $spec.Extension
|
||||||
|
Names = $names.ToArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function Read-TelesisFile {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Fetch one file off the controller. Returns raw bytes.
|
||||||
|
.DESCRIPTION
|
||||||
|
The controller announces a byte count, then the HOST pulls each block.
|
||||||
|
Blocks are framed BY LENGTH, never by scanning for CR: payloads are
|
||||||
|
binary and contain 0x0d routinely. Framing on CR appears to work on
|
||||||
|
files that happen to lack one and silently desyncs on files that have
|
||||||
|
one - QUERY.DAT has exactly one 0x0d in 1244 bytes, and that is how this
|
||||||
|
was found.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]$Port,
|
||||||
|
[Parameter(Mandatory)][string]$FileName
|
||||||
|
)
|
||||||
|
|
||||||
|
$size = [int](Invoke-TelesisCommand -Port $Port -Type 'U' -Data $FileName)
|
||||||
|
$data = New-Object byte[] $size
|
||||||
|
$offset = 0
|
||||||
|
$block = 1
|
||||||
|
|
||||||
|
while ($offset -lt $size) {
|
||||||
|
$expect = [Math]::Min($script:BLOCKSIZE, $size - $offset)
|
||||||
|
# Format FIRST, into a variable. PowerShell treats commas inside a
|
||||||
|
# method call as argument separators, so GetBytes('..' -f $a, $b, $c)
|
||||||
|
# is parsed as a three-argument GetBytes and fails at runtime.
|
||||||
|
$text = '{0}{1:D5}{2}' -f $script:ACK, $block, $script:CR
|
||||||
|
$request = [Text.Encoding]::ASCII.GetBytes($text)
|
||||||
|
$Port.Write($request, 0, $request.Length)
|
||||||
|
|
||||||
|
# STX + 5-digit sequence + payload + 3-digit BCC + CR
|
||||||
|
$frame = New-Object byte[] (1 + 5 + $expect + 3 + 1)
|
||||||
|
$got = 0
|
||||||
|
while ($got -lt $frame.Length) {
|
||||||
|
$read = $Port.Read($frame, $got, $frame.Length - $got)
|
||||||
|
if ($read -le 0) { throw "Short read on block $block of $FileName." }
|
||||||
|
$got += $read
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($frame[0] -ne 0x02) {
|
||||||
|
throw "Block $block of $FileName did not start with STX."
|
||||||
|
}
|
||||||
|
$seq = [Text.Encoding]::ASCII.GetString($frame, 1, 5)
|
||||||
|
if ([int]$seq -ne $block) {
|
||||||
|
throw "Expected block $block of $FileName, got $seq."
|
||||||
|
}
|
||||||
|
$sum = 0
|
||||||
|
for ($i = 0; $i -lt $expect; $i++) {
|
||||||
|
$data[$offset + $i] = $frame[6 + $i]
|
||||||
|
$sum += $frame[6 + $i]
|
||||||
|
}
|
||||||
|
$bcc = [Text.Encoding]::ASCII.GetString($frame, 6 + $expect, 3)
|
||||||
|
if ([int]$bcc -ne ($sum -band 0xFF)) {
|
||||||
|
throw "Bad checksum on block $block of $FileName."
|
||||||
|
}
|
||||||
|
|
||||||
|
$offset += $expect
|
||||||
|
$block++
|
||||||
|
}
|
||||||
|
|
||||||
|
# Asking for the block past the end is what ends the transfer.
|
||||||
|
$finaltext = '{0}{1:D5}{2}' -f $script:ACK, $block, $script:CR
|
||||||
|
$final = [Text.Encoding]::ASCII.GetBytes($finaltext)
|
||||||
|
$Port.Write($final, 0, $final.Length)
|
||||||
|
|
||||||
|
return $data
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function Write-TelesisFile {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Send one file TO the controller. Restore only - never called on a cycle.
|
||||||
|
.DESCRIPTION
|
||||||
|
The controller requests each block, mirroring the read direction. This
|
||||||
|
WRITES TO A MARKING CONTROLLER, so it is deliberately not wired into any
|
||||||
|
unattended path: GE-Enforce runs every five minutes across the fleet and
|
||||||
|
a write on that trigger is a defect waiting to reach production.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]$Port,
|
||||||
|
[Parameter(Mandatory)][string]$FileName,
|
||||||
|
[Parameter(Mandatory)][byte[]]$Data
|
||||||
|
)
|
||||||
|
|
||||||
|
$header = '{0};{1:D6};{2}' -f $FileName, $Data.Length, $script:BLOCKSIZE
|
||||||
|
[void](Invoke-TelesisCommand -Port $Port -Type 'D' -Data $header)
|
||||||
|
|
||||||
|
$total = [Math]::Ceiling($Data.Length / [double]$script:BLOCKSIZE)
|
||||||
|
while ($true) {
|
||||||
|
# ACK + 5-digit block number + CR, from the controller.
|
||||||
|
$request = New-Object Text.StringBuilder
|
||||||
|
while ($true) {
|
||||||
|
$b = $Port.ReadByte()
|
||||||
|
if ($b -lt 0) { throw "Controller stopped requesting blocks for $FileName." }
|
||||||
|
if ($b -eq 0x0D) { break }
|
||||||
|
[void]$request.Append([char]$b)
|
||||||
|
}
|
||||||
|
$text = $request.ToString()
|
||||||
|
$ack = $text.IndexOf($script:ACK)
|
||||||
|
if ($ack -lt 0) { throw "Unexpected frame while sending $FileName." }
|
||||||
|
$n = [int]$text.Substring($ack + 1)
|
||||||
|
|
||||||
|
if ($n -gt $total) { return $total } # asked past the end: complete
|
||||||
|
|
||||||
|
$offset = ($n - 1) * $script:BLOCKSIZE
|
||||||
|
$length = [Math]::Min($script:BLOCKSIZE, $Data.Length - $offset)
|
||||||
|
$payload = New-Object byte[] $length
|
||||||
|
[Array]::Copy($Data, $offset, $payload, 0, $length)
|
||||||
|
|
||||||
|
$sum = 0
|
||||||
|
foreach ($b in $payload) { $sum += $b }
|
||||||
|
|
||||||
|
$prefixtext = '{0}{1:D5}' -f $script:STX, $n
|
||||||
|
$suffixtext = '{0:D3}{1}' -f ($sum -band 0xFF), $script:CR
|
||||||
|
$prefix = [Text.Encoding]::ASCII.GetBytes($prefixtext)
|
||||||
|
$suffix = [Text.Encoding]::ASCII.GetBytes($suffixtext)
|
||||||
|
|
||||||
|
$Port.Write($prefix, 0, $prefix.Length)
|
||||||
|
$Port.Write($payload, 0, $payload.Length)
|
||||||
|
$Port.Write($suffix, 0, $suffix.Length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Export-ModuleMember -Function Get-TelesisPortConfig, Get-TelesisMarkerType,
|
||||||
|
Get-TelesisControllerFamily, Get-TelesisContentClasses,
|
||||||
|
Test-TelesisPortBusy,
|
||||||
|
Open-TelesisPort, Invoke-TelesisCommand, Get-TelesisFileList,
|
||||||
|
Read-TelesisFile, Write-TelesisFile
|
||||||
399
playbook/shopfloor-setup/common/scripts/Backup-PartMarker.ps1
Normal file
399
playbook/shopfloor-setup/common/scripts/Backup-PartMarker.ps1
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
# Backup-PartMarker.ps1
|
||||||
|
#
|
||||||
|
# Captures a Telesis pinstamp controller's entire contents - patterns, fonts and
|
||||||
|
# parameters - to the SFLD share and records the snapshot in ShopDB against the
|
||||||
|
# MARKER asset. A tech can then see what a marker held on any past date, and
|
||||||
|
# restore a pattern that was deleted months ago.
|
||||||
|
#
|
||||||
|
# Runs from the SFLD share every GE-Enforce cycle as a Type=PS1 manifest entry
|
||||||
|
# with DetectionMethod=Always. Updating this file on the share changes fleet
|
||||||
|
# behaviour on the next cycle - there is no local copy to heal.
|
||||||
|
#
|
||||||
|
# WHY A FULL SNAPSHOT RATHER THAN A MIRRORED DIRECTORY
|
||||||
|
#
|
||||||
|
# A mirror forces an impossible choice when a pattern is deleted on the
|
||||||
|
# controller: delete it from the backup too and destroy the only copy, or keep
|
||||||
|
# it and claim a pattern that no longer exists. A snapshot has neither
|
||||||
|
# problem. The deleted pattern is simply absent from the new snapshot and
|
||||||
|
# still present in the old one, and the change is dated because the manifest
|
||||||
|
# hash moves. Cost is trivial - 34 patterns and 12 parameter files is well
|
||||||
|
# under a megabyte, and a snapshot is only written when something changed.
|
||||||
|
#
|
||||||
|
# WHY IT POSTS EVEN WHEN NOTHING CHANGED
|
||||||
|
#
|
||||||
|
# ShopDB touches lastseenat on a no-op post, and that is the field the fleet
|
||||||
|
# Backup badge judges health on. A marker that skipped posting because nothing
|
||||||
|
# changed would look exactly like one whose backup died six months ago. So an
|
||||||
|
# unchanged controller still posts, gets action=noop, and stays green.
|
||||||
|
#
|
||||||
|
# ORDER OF OPERATIONS - write to the share BEFORE posting, then clean up on a
|
||||||
|
# no-op. A failed post then leaves a real directory the next run tidies, rather
|
||||||
|
# than a database row pointing at a directory that was never created. Deleting
|
||||||
|
# after a no-op is safe precisely because a no-op proves identical content
|
||||||
|
# already exists in the previous snapshot.
|
||||||
|
#
|
||||||
|
# PORT CONTENTION IS THE MAIN HAZARD. MarkTelesis holds the serial port whenever
|
||||||
|
# the marker is in service, and so does a tech running Backup470 by hand. This
|
||||||
|
# never takes the port from either - it checks, skips the cycle, and says so
|
||||||
|
# once. At the default 24h interval a lost window costs nothing.
|
||||||
|
#
|
||||||
|
# Always exits 0 so the GE-Enforce "last run result" stays clean. Failures are
|
||||||
|
# logged, never thrown.
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$BaseUrl = '',
|
||||||
|
# Root of the opaque-backup tree. The snapshot lands in
|
||||||
|
# <ShareRoot>\partmarker\<marker asset>\<yyyyMMdd-HHmmss>\.
|
||||||
|
[string]$ShareRoot = '\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\backup',
|
||||||
|
[int]$TimeoutSec = 30,
|
||||||
|
# Hour of the day (LOCAL, 24h) the daily capture starts. 14:00 for two
|
||||||
|
# reasons, and the second one is the important one:
|
||||||
|
#
|
||||||
|
# 1. It is a shift change - 06:00, 14:00 and 22:00 all are - so the bay is
|
||||||
|
# between operators and the marker port is free. The port is exclusive:
|
||||||
|
# eDNC and this backup cannot both hold it, and whoever loses gets a
|
||||||
|
# visible failure at the bay.
|
||||||
|
# 2. Someone is on site at 14:00 to notice and fix a problem. A capture
|
||||||
|
# that misbehaves at 22:00 is found the next morning, after a shift of
|
||||||
|
# failed marks.
|
||||||
|
#
|
||||||
|
# GE-Enforce fires every 5 minutes, so this runs on the first cycle inside
|
||||||
|
# the hour and the 24h throttle stops it running again that day.
|
||||||
|
[int[]]$WindowHours = @(14),
|
||||||
|
# Used ONLY once the backup is overdue - the other two shift changes. They
|
||||||
|
# are unattended, so they are a fallback for a bay that is busy at 14:00
|
||||||
|
# every day, not part of the normal schedule.
|
||||||
|
[int[]]$OverdueWindowHours = @(6, 22),
|
||||||
|
# Days without a capture before the fallback shift changes engage. The
|
||||||
|
# windows are NEVER ignored - outside a shift change the port belongs to
|
||||||
|
# production - so a bay that is busy at all three just keeps skipping, and
|
||||||
|
# says so progressively louder rather than capturing at a bad moment.
|
||||||
|
[int]$WindowMaxSkipDays = 3,
|
||||||
|
# Force a capture regardless of the throttle and the window. For a tech
|
||||||
|
# taking a snapshot on demand before touching a controller.
|
||||||
|
[switch]$Force
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
$KIND = 'partmarker'
|
||||||
|
|
||||||
|
Import-Module (Join-Path $PSScriptRoot '..\lib\ShopdbBackupClient.psm1') -Force
|
||||||
|
Import-Module (Join-Path $PSScriptRoot '..\lib\TelesisMarker.psm1') -Force
|
||||||
|
|
||||||
|
|
||||||
|
function Get-SnapshotManifest {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Deterministic description of one snapshot, and the hash ShopDB dedupes on.
|
||||||
|
.DESCRIPTION
|
||||||
|
The hash covers sorted filenames AND per-file hashes, so an addition, a
|
||||||
|
deletion or an edit all move it, and the order files came off the wire
|
||||||
|
cannot. That is what makes an unchanged controller a no-op and a changed
|
||||||
|
one exactly one new revision.
|
||||||
|
|
||||||
|
Pattern headers carry the marking system they were authored for
|
||||||
|
("TMP1700/470", "TMP4210/470"). Patterns for different heads coexist on
|
||||||
|
one controller, so the header is recorded per file - restoring a pattern
|
||||||
|
onto a system with a different head is a real mistake and this is the
|
||||||
|
only warning available.
|
||||||
|
#>
|
||||||
|
param([Parameter(Mandatory)][hashtable]$Files,
|
||||||
|
[string]$Family = '')
|
||||||
|
|
||||||
|
$sha = [Security.Cryptography.SHA256]::Create()
|
||||||
|
$entries = New-Object System.Collections.Generic.List[object]
|
||||||
|
|
||||||
|
foreach ($name in ($Files.Keys | Sort-Object)) {
|
||||||
|
$bytes = $Files[$name]
|
||||||
|
$hash = [BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-', '').ToLower()
|
||||||
|
|
||||||
|
# The system string sits in the first ~16 bytes of a pattern file.
|
||||||
|
$head = ''
|
||||||
|
if ($name -like '*.PAT' -and $bytes.Length -ge 16) {
|
||||||
|
$ascii = [Text.Encoding]::ASCII.GetString($bytes, 0, 16)
|
||||||
|
$match = [regex]::Match($ascii, '[A-Z]{2,4}[0-9]{3,4}/[0-9]{3}')
|
||||||
|
if ($match.Success) { $head = $match.Value }
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries.Add([pscustomobject]@{
|
||||||
|
name = $name
|
||||||
|
bytes = $bytes.Length
|
||||||
|
sha256 = $hash
|
||||||
|
system = $head
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hash the manifest text itself, so the dedup key covers the whole file set.
|
||||||
|
$text = ($entries | ForEach-Object { '{0}:{1}:{2}' -f $_.name, $_.bytes, $_.sha256 }) -join "`n"
|
||||||
|
$manifesthash = [BitConverter]::ToString(
|
||||||
|
$sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($text))).Replace('-', '').ToLower()
|
||||||
|
|
||||||
|
# Both models are recorded: what the bay SAYS it drives (registry) and what
|
||||||
|
# the patterns say they were authored for (header). They can disagree - a
|
||||||
|
# stale registry, or patterns carried over from another system - and a
|
||||||
|
# restore is exactly when that matters.
|
||||||
|
$systems = @($entries | Where-Object { $_.system } |
|
||||||
|
Select-Object -ExpandProperty system -Unique | Sort-Object)
|
||||||
|
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Entries = $entries
|
||||||
|
Skips = (Get-ShopdbBackupSkips -Kind 'partmarker')
|
||||||
|
ManifestHash = $manifesthash
|
||||||
|
TotalBytes = ($entries | Measure-Object -Property bytes -Sum).Sum
|
||||||
|
DeclaredType = (Get-TelesisMarkerType)
|
||||||
|
Family = $Family
|
||||||
|
Systems = $systems
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Main
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
if ($MyInvocation.InvocationName -eq '.') { return }
|
||||||
|
|
||||||
|
# --- Time-of-day gate -------------------------------------------------------
|
||||||
|
# Checked before the port is opened and before Initialize-ShopdbBackup, so an
|
||||||
|
# out-of-window cycle costs a clock read and nothing else.
|
||||||
|
if (-not $Force) {
|
||||||
|
$now = Get-Date
|
||||||
|
$inwindow = ($WindowHours -contains $now.Hour)
|
||||||
|
|
||||||
|
# The marker file is stamped on every post attempt, so its age answers
|
||||||
|
# "when did a capture last get through" - which is what the escalation
|
||||||
|
# below needs to know.
|
||||||
|
#
|
||||||
|
# A bay that has NEVER captured has no marker file, and treating that as
|
||||||
|
# infinitely overdue made a freshly imaged PC shout that every shift change
|
||||||
|
# had been busy before it had waited for a single one. So age is measured
|
||||||
|
# from a first-run stamp instead: a new bay starts the clock now, and only
|
||||||
|
# escalates if it still has nothing days later. That keeps the real case -
|
||||||
|
# a marker whose port is permanently held, which never stamps the marker
|
||||||
|
# file - loud, without slandering a PC imaged ten minutes ago.
|
||||||
|
$logdir = 'C:\Logs\Shopfloor'
|
||||||
|
$markerfile = Join-Path $logdir ('{0}-backup.marker' -f $KIND)
|
||||||
|
$firstrun = Join-Path $logdir ('{0}-backup.firstrun' -f $KIND)
|
||||||
|
|
||||||
|
if (-not (Test-Path $logdir)) {
|
||||||
|
New-Item -ItemType Directory -Path $logdir -Force -ErrorAction SilentlyContinue | Out-Null
|
||||||
|
}
|
||||||
|
if (-not (Test-Path $firstrun)) {
|
||||||
|
Set-Content -Path $firstrun -Value (Get-Date -Format 'o') -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
$since = if (Test-Path $markerfile) { Get-Item $markerfile } else { Get-Item $firstrun }
|
||||||
|
$daysold = 0.0
|
||||||
|
try { $daysold = ($now - $since.LastWriteTime).TotalDays } catch { }
|
||||||
|
$overdue = ($daysold -ge $WindowMaxSkipDays)
|
||||||
|
$veryoverdue = ($daysold -ge ($WindowMaxSkipDays * 2))
|
||||||
|
|
||||||
|
# Normal: 14:00, when someone is on site. Overdue: the other two shift
|
||||||
|
# changes join in. Never anything else - outside a shift change the bay is
|
||||||
|
# marking parts and the port is not ours to take.
|
||||||
|
$allowed = $inwindow
|
||||||
|
if (-not $allowed -and $overdue) {
|
||||||
|
$allowed = ($OverdueWindowHours -contains $now.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $allowed) {
|
||||||
|
if ($veryoverdue) {
|
||||||
|
# Past the point where quiet is appropriate. Every shift change has
|
||||||
|
# been busy for twice the grace period, which means this marker has
|
||||||
|
# no current backup and nobody has noticed. Said on EVERY cycle
|
||||||
|
# rather than once, because the quiet path is what let it get here.
|
||||||
|
Write-ShopdbBackupLog -Kind $KIND -Message (
|
||||||
|
'WARNING: no successful capture in {0:N1} days ({1}). Every shift change has been busy - check whether the marker application is holding the port permanently.' -f
|
||||||
|
$daysold, $(if (Test-Path $markerfile) { 'last attempt ' + $since.LastWriteTime.ToString('yyyy-MM-dd HH:mm') } else { 'never captured on this PC' }))
|
||||||
|
} else {
|
||||||
|
Write-ShopdbQuietState -Kind $KIND -State ('outside-window-{0}' -f $now.Hour) -Message (
|
||||||
|
'Outside the capture window ({0}). The marker port is shared with eDNC, so the daily capture waits for a shift change. Use -Force to override.' -f
|
||||||
|
(($WindowHours | ForEach-Object { '{0:00}:00' -f $_ }) -join ', '))
|
||||||
|
}
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
if (-not $inwindow) {
|
||||||
|
Write-ShopdbBackupLog -Kind $KIND -Message (
|
||||||
|
'No capture for {0:N1} days - 14:00 has been busy, using the {1:00}:00 shift change instead.' -f
|
||||||
|
$daysold, $now.Hour)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$busy = Test-TelesisPortBusy
|
||||||
|
if ($busy) {
|
||||||
|
# Not an error, and the expected state on a bay that is actually marking
|
||||||
|
# parts. Said once rather than 288 times a day.
|
||||||
|
Add-ShopdbBackupSkip -Kind $KIND -Reason "port in use by $busy"
|
||||||
|
Write-ShopdbQuietState -Kind $KIND -State "port-busy-$busy" -Message (
|
||||||
|
"$busy is running and holds the marker's serial port. Skipping this " +
|
||||||
|
'cycle rather than interrupting a marking operation.')
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$portconfig = Get-TelesisPortConfig
|
||||||
|
} catch {
|
||||||
|
# Every PC type that is not a part-marker bay lands here, permanently.
|
||||||
|
Write-ShopdbQuietState -Kind $KIND -State 'no-marker-config' -Message (
|
||||||
|
"No Telesis marker configured on this PC ($_). Nothing to do.")
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$ctx = Initialize-ShopdbBackup -Kind $KIND -Force:$Force `
|
||||||
|
-BaseUrl $(if ($BaseUrl) { $BaseUrl } else { '' })
|
||||||
|
if (-not $ctx.Proceed) { exit 0 }
|
||||||
|
|
||||||
|
# --- Capture everything off the controller ----------------------------------
|
||||||
|
$files = @{}
|
||||||
|
$family = ''
|
||||||
|
$port = $null
|
||||||
|
try {
|
||||||
|
$port = Open-TelesisPort -Config $portconfig
|
||||||
|
Write-ShopdbBackupLog -Kind $KIND -Message (
|
||||||
|
'Opened {0} at {1} baud' -f $portconfig.PortName, $portconfig.BaudRate)
|
||||||
|
|
||||||
|
# Identify the controller with the published, read-only Type I status
|
||||||
|
# request before sending anything model-specific, and ask only for the
|
||||||
|
# content classes that family is known to have. A 420 has no parameter
|
||||||
|
# files on offer, and sending it an unproven listing command to find out is
|
||||||
|
# not a trade worth making on a marking controller.
|
||||||
|
$family = Get-TelesisControllerFamily -Port $port
|
||||||
|
$classes = Get-TelesisContentClasses -Family $family
|
||||||
|
Write-ShopdbBackupLog -Kind $KIND -Message (
|
||||||
|
'Controller family: {0}; capturing {1}' -f
|
||||||
|
$(if ($family) { $family } else { 'unknown' }), ($classes -join ', '))
|
||||||
|
|
||||||
|
foreach ($class in $classes) {
|
||||||
|
$list = Get-TelesisFileList -Port $port -Class $class
|
||||||
|
Write-ShopdbBackupLog -Kind $KIND -Message (
|
||||||
|
'{0}: {1} file(s)' -f $class, $list.Names.Count)
|
||||||
|
$sincecheck = 0
|
||||||
|
foreach ($name in $list.Names) {
|
||||||
|
# Yield the moment the marker app wants the port. The check at the
|
||||||
|
# top of the run is point-in-time - eDNC can start a millisecond
|
||||||
|
# after it - so re-checking every few files bounds our interference
|
||||||
|
# to about a second rather than the ~26 a full capture takes. The
|
||||||
|
# throttle marker is NOT stamped on this path, so the next cycle
|
||||||
|
# retries instead of waiting a day.
|
||||||
|
if (++$sincecheck -ge 5) {
|
||||||
|
$sincecheck = 0
|
||||||
|
$arrived = Test-TelesisPortBusy
|
||||||
|
if ($arrived) {
|
||||||
|
Add-ShopdbBackupSkip -Kind $KIND -Reason "$arrived started mid-capture"
|
||||||
|
Write-ShopdbBackupLog -Kind $KIND -Message (
|
||||||
|
"$arrived started mid-capture - releasing the port, retrying next cycle.")
|
||||||
|
$files = $null
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$filename = '{0}{1}' -f $name, $list.Extension
|
||||||
|
try {
|
||||||
|
$files[$filename] = Read-TelesisFile -Port $port -FileName $filename
|
||||||
|
} catch {
|
||||||
|
# One unreadable file must not cost the whole snapshot, but the
|
||||||
|
# snapshot is then incomplete and must NOT be recorded as a
|
||||||
|
# faithful copy - see the abort below.
|
||||||
|
Write-ShopdbBackupLog -Kind $KIND -Message " FAILED to read ${filename}: $_"
|
||||||
|
$files = $null
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($null -eq $files) { break }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-ShopdbBackupLog -Kind $KIND -Message "Capture failed: $_"
|
||||||
|
$files = $null
|
||||||
|
} finally {
|
||||||
|
if ($port -and $port.IsOpen) { $port.Close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -eq $files -or $files.Count -eq 0) {
|
||||||
|
# A failed capture is a missed backup too, and it is the more worrying kind:
|
||||||
|
# port contention resolves itself, a controller that will not answer does
|
||||||
|
# not. Counted with its own reason so the two are distinguishable in the
|
||||||
|
# manifest that eventually reaches ShopDB.
|
||||||
|
if (-not (Test-TelesisPortBusy)) {
|
||||||
|
Add-ShopdbBackupSkip -Kind $KIND -Reason 'capture failed or incomplete'
|
||||||
|
}
|
||||||
|
# A partial snapshot recorded as a revision is worse than no revision: it
|
||||||
|
# would dedup against the real one and make a complete backup look like a
|
||||||
|
# change, or be restored from as though it were whole.
|
||||||
|
Write-ShopdbBackupLog -Kind $KIND -Message (
|
||||||
|
'Snapshot incomplete - not recording a revision. Next cycle will retry.')
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifest = Get-SnapshotManifest -Files $files -Family $family
|
||||||
|
Write-ShopdbBackupLog -Kind $KIND -Message (
|
||||||
|
'Captured {0} file(s), {1} bytes, manifest {2}' -f
|
||||||
|
$files.Count, $manifest.TotalBytes, $manifest.ManifestHash.Substring(0, 12))
|
||||||
|
Write-ShopdbBackupLog -Kind $KIND -Message (
|
||||||
|
'Marker type declared "{0}", patterns authored for {1}' -f
|
||||||
|
$(if ($manifest.DeclaredType) { $manifest.DeclaredType } else { 'unset' }),
|
||||||
|
$(if ($manifest.Systems) { $manifest.Systems -join ', ' } else { 'unknown' }))
|
||||||
|
|
||||||
|
# --- Write the snapshot to the share ----------------------------------------
|
||||||
|
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||||
|
$assetdir = Join-Path (Join-Path $ShareRoot 'partmarker') $ctx.MachineNumber
|
||||||
|
$snapshotdir = Join-Path $assetdir $stamp
|
||||||
|
|
||||||
|
try {
|
||||||
|
New-Item -ItemType Directory -Path $snapshotdir -Force -ErrorAction Stop | Out-Null
|
||||||
|
foreach ($name in $files.Keys) {
|
||||||
|
# Original filename and extension, unaltered - names contain spaces
|
||||||
|
# ("M20 RE-MARK") and the vendor tool rejects a renamed file.
|
||||||
|
[IO.File]::WriteAllBytes((Join-Path $snapshotdir $name), $files[$name])
|
||||||
|
}
|
||||||
|
[pscustomobject]@{
|
||||||
|
capturedat = (Get-Date).ToUniversalTime().ToString('o')
|
||||||
|
machine = $ctx.MachineNumber
|
||||||
|
sourcehost = $env:COMPUTERNAME
|
||||||
|
declaredtype = $manifest.DeclaredType
|
||||||
|
family = $manifest.Family
|
||||||
|
# Cycles that wanted to capture and could not, since the last success.
|
||||||
|
# ShopDB cannot see a skipped cycle - nothing is posted, because a
|
||||||
|
# dishonest no-op would keep the health badge green - so the count
|
||||||
|
# travels with the snapshot that finally got through.
|
||||||
|
skippedcycles = $manifest.Skips
|
||||||
|
systems = $manifest.Systems
|
||||||
|
manifesthash = $manifest.ManifestHash
|
||||||
|
files = $manifest.Entries
|
||||||
|
} | ConvertTo-Json -Depth 4 |
|
||||||
|
Set-Content -LiteralPath (Join-Path $snapshotdir 'manifest.json') -Encoding UTF8
|
||||||
|
} catch {
|
||||||
|
Write-ShopdbBackupLog -Kind $KIND -Message "Could not write $snapshotdir : $_"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Record it in ShopDB ----------------------------------------------------
|
||||||
|
# ConvertTo-Json/FromJson round trip: Send-ShopdbBackup takes a hashtable, and
|
||||||
|
# building one directly here would duplicate the shape written to the share.
|
||||||
|
# This keeps ONE definition of a manifest - what is on the share and what
|
||||||
|
# ShopDB stores are the same object by construction.
|
||||||
|
$manifestjson = Get-Content -LiteralPath (Join-Path $snapshotdir 'manifest.json') -Raw
|
||||||
|
$manifesthash = @{}
|
||||||
|
foreach ($property in (ConvertFrom-Json $manifestjson).PSObject.Properties) {
|
||||||
|
$manifesthash[$property.Name] = $property.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
$posted = Send-ShopdbBackup -Context $ctx `
|
||||||
|
-ContentHash $manifest.ManifestHash -SharePath $snapshotdir `
|
||||||
|
-SourceFileName 'manifest.json' -Manifest $manifesthash -TimeoutSec $TimeoutSec
|
||||||
|
|
||||||
|
# Send-ShopdbBackup logs created vs noop. On a no-op the directory just written
|
||||||
|
# is byte-identical to the previous snapshot, so it is removed rather than left
|
||||||
|
# to accumulate one identical copy per collection.
|
||||||
|
if ($posted -and (Test-ShopdbBackupNoop -Kind $KIND)) {
|
||||||
|
Remove-Item -LiteralPath $snapshotdir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
Write-ShopdbBackupLog -Kind $KIND -Message (
|
||||||
|
'Controller unchanged - snapshot discarded, existing revision confirmed.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($posted) {
|
||||||
|
# Cleared only on a successful post: an unreachable server must not lose the
|
||||||
|
# record of why this marker has not been captured.
|
||||||
|
Clear-ShopdbBackupSkips -Kind $KIND
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-ShopdbBackupLog -Kind $KIND -Message '=== Backup-PartMarker end ==='
|
||||||
|
exit 0
|
||||||
Reference in New Issue
Block a user