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: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
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user