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

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

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

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

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

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

View File

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

View 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