diff --git a/playbook/shopfloor-setup/common/lib/ShopdbBackupClient.psm1 b/playbook/shopfloor-setup/common/lib/ShopdbBackupClient.psm1 index 8ab9c39..ea01f66 100644 --- a/playbook/shopfloor-setup/common/lib/ShopdbBackupClient.psm1 +++ b/playbook/shopfloor-setup/common/lib/ShopdbBackupClient.psm1 @@ -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 diff --git a/playbook/shopfloor-setup/common/lib/TelesisMarker.psm1 b/playbook/shopfloor-setup/common/lib/TelesisMarker.psm1 new file mode 100644 index 0000000..dbd5a9b --- /dev/null +++ b/playbook/shopfloor-setup/common/lib/TelesisMarker.psm1 @@ -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 <2-digit station> STX ETX CR +# ctrl -> host: SOH ACK STX ETX 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 diff --git a/playbook/shopfloor-setup/common/scripts/Backup-PartMarker.ps1 b/playbook/shopfloor-setup/common/scripts/Backup-PartMarker.ps1 new file mode 100644 index 0000000..228cd25 --- /dev/null +++ b/playbook/shopfloor-setup/common/scripts/Backup-PartMarker.ps1 @@ -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 + # \partmarker\\\. + [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