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:
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